This document is the single source of truth for large-payload data transfer in application-sdk v3. Read it before writing any code that passes files between tasks, writes run artifacts, or pushes data upstream.
Temporal serialises every Input and Output through its payload codec. The hard limit is 2 MB per payload. A single SQLite database, Parquet file, or JSON manifest from a real connector run is almost always larger than that. Embedding raw bytes in your model will fail at runtime with a cryptic Temporal serialisation error.
FileReference solves this by storing the actual bytes in object storage (S3,
GCS, Azure Blob) and putting only a tiny pointer in the Temporal payload. The SDK
uploads and downloads automatically — task authors never call storage APIs
directly.
App.upload() solves a different problem: pushing a file to a stable, addressable
path that survives beyond the current run and is visible to external
systems (the Atlan platform, downstream pipelines, SDR).
These are two distinct APIs with distinct purposes. Using the wrong one causes subtle failures.
| Scenario | API | Why |
|---|---|---|
| Pass a SQLite / Parquet / JSON file from task A to task B in the same run | FileReference field on Input/Output |
Auto persisted on output, auto materialised on input. Zero storage code needed. |
| Task B always needs what task A produced, and the file is always present | FileReference field (eager, default) |
SDK downloads it before task B runs. |
Task B only sometimes needs the file (e.g. only when mode == "full") |
Annotated[FileReference | None, Lazy()] |
SDK skips the download; task B fetches on demand with fetch(). |
| Multiple tasks all share the same large manifest file | Single FileReference passed through the chain |
SDK downloads it exactly once per task via dedup. |
| Push a completed artifact so it appears in the Atlan UI after the run | await self.upload(UploadInput(local_path=...)) |
RETAINED is the default tier — writes to a stable run-scoped prefix that the platform indexes. |
| Write a file that must persist across multiple runs (e.g. incremental bookmark) | await self.upload(UploadInput(..., tier=StorageTier.PERSISTENT)) |
Writes to a fixed path not cleaned up between runs. |
| Directory of output files (e.g. partitioned Parquet) from task A to task B | FileReference(local_path=str(dir_path)) — same API, directory-aware |
SDK uploads all files in the directory and re-creates the structure on download. |
| Assert that everything a step wrote is really in the store before handing a prefix downstream | await self.verify_refs(VerifyRefsInput(refs=..., prefix=...)) |
The refs are the producer's declaration; a prefix walk cannot tell "absent" from "lost". |
| Land a fanned-out step's output as one outbound tree for publish / QI / lineage | await self.upload_refs(UploadRefsInput(files=..., prefix=...)) |
Uploads each declared ref by reference, so it works when the producing activities ran on other pods. |
Pass intermediate data between tasks using App.upload() |
Don't — use FileReference |
App.upload() routes to Atlan's atlan-objectstore in SDR, polluting it with internal artifacts; bypasses SHA-256 dedup; and doesn't wire into cross-worker auto-materialization. |
Key rule: FileReference is for within-run transfer. App.upload() is
for durable outbound delivery.
Anti-pattern:
App.upload()for task-to-task data. UsingApp.upload()to move data between tasks — instead ofFileReference— causes three distinct harms:
- Wrong bucket. In SDR,
App.upload()routes to the Atlan-ownedatlan-objectstore. Intermediate pipeline data (raw SQL rows, partial transforms) pollutes Atlan's bucket with artifacts that the publish app treats as connector output.- Re-uploads by default.
App.upload()runs on the same upload engine as the interceptor's persist, so it writes the same sidecars, local markers and directory manifest. But it is a separate@taskcall that uploads every file again unless you passskip_if_exists=True, and its destination is a durable outbound prefix, not a run-scoped hand-off.- No cross-worker auto-materialization.
FileReferenceinput fields are automatically re-downloaded before a task runs when the local file is absent (cross-worker fault tolerance).App.upload()produces a bareFileReferencewithout wiring it into the SDK's materialization machinery.For task-to-task data: declare
FileReferenceon yourOutputandInputcontracts — the interceptor handles persistence and materialization automatically.
These two APIs route to different object stores:
| API | Store | Who can read it |
|---|---|---|
FileReference (via interceptor) |
objectstore — infra.storage, customer-owned |
Other tasks in the same deployment |
App.upload() |
atlan-objectstore — infra.upstream_storage, Atlan-owned (in SDR); falls back to infra.storage in local dev |
Atlan system apps (publish, QI, lineage) and the Atlan UI |
The activity interceptor's persist step always writes FileReference objects to
infra.storage (objectstore). This is intentional: intermediate task outputs
stay inside the customer's deployment perimeter and never cross into Atlan's
infrastructure unless the connector explicitly decides to hand them off.
App.upload() uses upstream_storage or storage. In SDR deployments
upstream_storage is the atlan-objectstore Dapr binding (pointing to
{tenant}/api/blobstorage), so App.upload() routes to Atlan's S3.
App.download() reads from the same place. A ref that App.upload() or
App.download() returns from a distinct upstream store carries
store=StoreTarget.UPSTREAM. When you pass it into another @task, the
interceptor materialises it from the upstream store, not the deployment store.
Without that field the next task would list an empty prefix in the wrong bucket
and fail with AAF-STR-009. Every other ref, including those from a single-store
deployment where both names alias one store, has store=DEPLOYMENT. Do not set
the field by hand. It records where the bytes are, and the SDK sets it.
App.download() and the interceptor share one transfer engine. Passing
dl.ref to a task on the same worker therefore costs one listing and a local
re-hash, not a second download, because download() leaves the same local
.sha256 markers that materialise does. App.upload() and the interceptor's
persist also share one upload engine. A directory up.ref carries a
manifest_path and leaves a local .sha256 marker beside each file, so a task
on the same worker reuses the files instead of downloading them back, and never
reads a stray object under the prefix as part of the hand-off. Both refuse the
same sensitive local paths: a protected system directory itself or anything
under it (/etc, /proc, /sys, /dev, /root), and any path through a
credential directory (.ssh, .aws, .gnupg, .kube, .vault), an
.env* name, or a match for ATLAN_UPLOAD_FILE_BLOCKED_PATHS. For a directory
that applies to every file in it: one .env inside an output directory refuses
the whole upload, naming the file, rather than shipping it or dropping it
silently. The tag is honoured only for refs
produced inside the run. A workflow's start
input (including continue_with) and every @update argument have any
store=UPSTREAM reset to DEPLOYMENT, so to consume another
app's upstream output, call App.download() on it.
Consequence for connector authors: if your connector produces artifacts that
Atlan's publish app must consume, you must call App.upload() explicitly from
run() — the interceptor alone is not sufficient. Omitting the explicit call
produces a silent failure: the DAG runs to completion but the publish app finds
nothing in Atlan's S3 and publishes zero assets. See
ADR-0014 for the full
rationale.
A frozen Pydantic model that holds a pointer to a file (or directory) in object storage plus an optional local path on the current worker:
class FileReference(BaseModel, frozen=True):
local_path: str | None = None # absolute path on the worker's disk
storage_path: str | None = None # object-store key (set after persist)
is_durable: bool = False # True once uploaded to the store
tier: StorageTier = StorageTier.TRANSIENT
file_count: int = 1 # number of files; >1 for directory uploads
auto_materialize: bool = True # False = SDK never touches this ref
manifest_path: str | None = None # directory refs: key of the exact-file-set manifestYou never set is_durable, file_count, or manifest_path yourself — the SDK
fills them in during persist and materialize. storage_path is optional: leave
it unset and the SDK generates a unique file_refs/{uuid} key, or set it to pin
a canonical key (single file) or prefix (directory).
On an entry point's contract, a
FileReferencemust declare its shape. An entry point'sinput/return contracts are a public boundary — another app or the platform DAG reads what you write there — so everyFileReferencefield on one needs anartifactSchemasentry in your pkl contract. Omitting it warns at worker build today and is an error in v4.0. AFileReferenceon an internal@taskcontract is exempt. See Apps — Declaring artifact schemas.Declared or not, every
FileReferencethe interceptor sees emits one artifact-validation outcome on each side of the task — at ingest after materialize and at hand-off before persist. A declared artifact is scanned against its declaration; an undeclared one reportsnot_declaredrather than nothing at all. Report-only unless the app opts intoartifact_validation_mode = "hard".
Constructing a FileReference is just a Pydantic model instantiation — it works
anywhere (a @task, run(), a helper function). What requires a @task-decorated
method is persisting the ref so the SDK can auto-upload it on activity return:
# Single file
ref = FileReference(local_path="/tmp/output/tables.parquet")
# Single file, non-default tier
ref = FileReference(
local_path="/tmp/output/tables.parquet",
tier=StorageTier.RETAINED,
)
# Directory (SDK walks all files recursively)
ref = FileReference(local_path="/tmp/output/")
# Opt-out of automatic lifecycle (advanced — you own download/upload)
ref = FileReference(local_path="/tmp/special.bin", auto_materialize=False)The SDK only auto-persists a FileReference when it is returned from a @task
method as part of an Output model. A ref constructed in run() and never
returned from a task will not be uploaded — run() is a Temporal workflow
function with sandbox restrictions, and the upload happens activity-side. As a
rule of thumb: build refs inside @task methods and return them; treat refs
received in run() as already-durable handles to pass between tasks.
The tier controls where the file is stored and when it is cleaned up.
Prefix: file_refs/{uuid}
Cleanup: deleted by App.cleanup_storage() at the end of every run
Use for: intermediate files between tasks that are no longer needed after
the run completes
Example — extraction output that the transform task needs but nothing else:
class ExtractOutput(Output):
raw_db: FileReference | None = None
# In the extract task:
return ExtractOutput(
raw_db=FileReference(local_path=str(sqlite_path))
# tier=StorageTier.TRANSIENT is the default
)Prefix: artifacts/apps/{app_name}/workflows/{workflow_id}/{run_id}/file_refs/
Cleanup: NOT deleted by standard cleanup — must be removed manually or via
a retention policy
Use for: run artifacts you want available after the run completes
(downloadable from the Atlan UI, inspectable for debugging)
Example — a processed Parquet file that needs to be accessible after the run:
class TransformOutput(Output):
result_parquet: FileReference | None = None
return TransformOutput(
result_parquet=FileReference(
local_path=str(output_parquet),
tier=StorageTier.RETAINED,
)
)Prefix: persistent-artifacts/apps/{app_name}/file_refs/
Cleanup: never deleted by any standard SDK operation
Use for: files that must survive across multiple runs — incremental
watermarks, shared lookup tables, cross-run caches
Example — an incremental bookmark that the next run will read:
class BookmarkOutput(Output):
watermark_file: FileReference | None = None
return BookmarkOutput(
watermark_file=FileReference(
local_path=str(watermark_path),
tier=StorageTier.PERSISTENT,
)
)After your @task returns an Output containing an ephemeral FileReference
(one with local_path set but is_durable=False), the SDK automatically
uploads it to infra.storage (objectstore — the deployment store):
- Computes the storage path from the tier prefix.
- Uploads the local file (or all files in a local directory) to the store.
- Writes a
{storage_path}.sha256sidecar to the store — used for integrity verification on download. - Writes a
{local_path}.sha256marker locally — the digest plus the file's(size, mtime_ns, inode). A later task on the same worker reuses the file without downloading it, and without reading it while that stat still matches. - Returns a durable ref (
is_durable=True,storage_pathset, andsize_bytesfor a single file) in the serialised Output that Temporal stores.
The original local file is not deleted unless you pass
retain_local_copy=False to App.upload() (that is a different code path).
Before your @task runs, the SDK inspects every FileReference field on the
Input. For each durable ref that needs downloading (file absent, marker missing,
size different from size_bytes, or file hash different from the marker), the
SDK:
- Downloads the file from the store. A ref with no
local_path(the usual hand-off shape: only astorage_path) gets a fresh temp name under the run's scratch directory,{TEMPORARY_PATH}/artifacts/apps/{app}/workflows/{workflow_id}/{run_id}/, whichcleanup_files()removes when the run completes. Directory refs get a fresh subdirectory there. Outside an activity there is no run to scope to, so the destination is{TEMPORARY_PATH}/file-refs/. Either way it is never the system temp directory. - Verifies the downloaded content SHA-256 against the stored sidecar.
- Writes a fresh local sidecar.
- Sets
local_pathon the ref so your task sees a ready-to-use local file.
Retry safety: if the task retries on a different worker pod, local_path
will be a stale path from another machine. The sidecar check catches this: the
local sidecar is absent (different pod, different disk), so the SDK re-downloads.
Cache hit: if the same worker hands the ref on, or retries, and the local
file is intact, the SDK skips the download entirely. A file whose size, mtime and
inode still match its marker is trusted without being read, so even a multi-GB
hand-off costs only a stat. If the stat has moved, or the marker was written by
an older SDK that recorded only the digest, the file is re-hashed in a worker
thread (never on the event loop): a match re-stamps the marker, a mismatch
re-downloads. A replacement that restores the original size and mtime on the
same inode (cp -p over the file in place) is not caught.
The local marker is SDK bookkeeping. Do not parse it in app code or tests: its format is not a contract.
Passing a directory works exactly like a single file from the task author's perspective:
# Produce: task returns a directory ref
output_dir = Path("/tmp/output/partitioned/")
return ExtractOutput(result_dir=FileReference(local_path=str(output_dir)))
# Consume: task receives a materialized directory ref
async def transform(self, input: TransformInput) -> TransformOutput:
dir_path = Path(input.result_dir.local_path)
for parquet_file in dir_path.glob("*.parquet"):
process(parquet_file)The SDK uploads every file in the directory recursively (bounded concurrency),
preserves the directory structure under the storage prefix, and recreates it on
download. file_count on the durable ref tells you how many files were uploaded.
The manifest. After the last file lands, persist writes a manifest — every
file's relative path, SHA-256 and size — and puts its key on the durable ref as
manifest_path. The manifest lives under the tier's file_refs/ namespace,
never inside the data prefix. Materialize downloads exactly the manifest's files
and checks each against the manifest digest; it does not list the prefix. So:
- objects under the prefix that the manifest does not name — a failed earlier attempt's leftovers, a timed-out attempt still writing — are ignored;
- a listed file overwritten after the manifest was written fails with
StorageIntegrityErrorinstead of being processed; - a missing manifest fails retryably rather than falling back to a listing.
Pinning a directory prefix. Because consumers read the manifest, a directory
ref may pin storage_path to a canonical prefix exactly as a single-file ref
pins its key. For TRANSIENT and RETAINED refs the pin must sit under the
run prefix (output_path), or persist raises StorageConfigError: two runs
pinning one shared prefix would write the same keys, and either run's cleanup
would delete the other's data. PERSISTENT pins may live anywhere under
persistent-artifacts/:
return ExtractOutput(
tables=FileReference(
local_path=str(output_dir),
storage_path=f"{output_path}/raw/table/", # honoured; trailing / optional
)
)A TRANSIENT pin is still deleted at end of run, manifest included.
Refs without a manifest — produced by an SDK that predates manifests, or
built by hand — are materialized by listing the prefix. The listing is checked
against file_count as a lower bound: fewer objects than declared fails
retryably (StorageError, code STR-009), including an empty listing for a ref
that declares any files — the default file_count=1 does. More objects than
declared only logs file_ref.materialize.count_mismatch at WARNING. A
hand-built ref over a prefix that can legitimately be empty must declare
file_count=0.
The per-file sidecar fast-path applies inside directories too: on retry, files whose local copy still matches are skipped (against the manifest digest when there is one, else the local sidecar); only changed or missing files are re-downloaded.
By default every FileReference field is downloaded before the task runs.
Mark a field Lazy to skip the automatic download:
from typing import Annotated
from application_sdk.contracts.types import FileReference, Lazy
class MyInput(Input):
# This is always downloaded before the task runs:
manifest: FileReference | None = None
# This is LEFT AS A DURABLE REF — not downloaded automatically:
full_export: Annotated[FileReference | None, Lazy()] = NoneInside the task, call fetch() to download on demand:
from application_sdk.storage.reference import fetch
async def my_task(self, input: MyInput) -> MyOutput:
if input.full_export and self.needs_full_export():
ref = await fetch(input.full_export) # downloads now, cached for this task
with open(ref.local_path, "rb") as f:
data = f.read()When to use Lazy:
- Your task only sometimes needs the file (conditional logic, mode flags).
- Lightweight tasks (send-metrics, notify, query-metastore) that happen to receive an Input model with large refs they don't use — forcing a 500 MB download on a 50 ms task will cause timeouts.
- Fan-out tasks that receive a shared manifest but only need their slice.
When NOT to use Lazy:
- The task always reads the file — eager download is simpler and you get the download logged and retried before your code runs.
If multiple Input fields point at the same storage_path (e.g. a shared spec
file passed through a pipeline), the SDK downloads it exactly once:
class PipelineInput(Input):
spec: FileReference | None = None # storage_path = "file_refs/abc"
spec_copy: FileReference | None = None # storage_path = "file_refs/abc" (same)Both fields end up with the same local_path after materialization. A
file_ref.materialize.dedup_hit DEBUG event is emitted for the second ref. You
do not need to deduplicate manually.
The same holds across activities. local_path is a deterministic function of
(run, stage, entity), so concurrent activities dispatched together can hand the
SDK the same destination. The materialise-and-verify step holds a per-path lock
and every download stages in .sdk-partial/ before an atomic os.replace
publish, so the second activity waits and reuses the file, and a reader never
observes a partial download at local_path (CONNECT-1126).
App.upload() uploads a local file to a stable, run-scoped path in the
deployment object store and returns a FileReference pointing at it. Use it when:
- You want the artifact to outlive the current run.
- You need to push a file to Atlan's artifact layer (visible in the UI, downloadable by support, indexed by the platform).
- You need a
FileReferencewith a known prefix (not a randomfile_refs/{uuid}).
await self.upload(input: UploadInput) -> UploadOutputclass UploadInput(BaseModel):
local_path: str = "" # path to the file or directory on disk
ref: FileReference | None = None # upload from a store key instead of local disk
tier: StorageTier = StorageTier.RETAINED # where to store it
storage_path: str | None = None # override the full destination key
storage_subdir: str | None = None # append a subdir under the run prefix
skip_if_exists: bool = False # skip upload when remote SHA-256 matches
raise_on_empty: bool = False # raise if the upload produces 0 files| Field | Required | Description |
|---|---|---|
local_path |
One of local_path / ref |
Path to the file or directory to upload. Defaults to "". |
ref |
One of local_path / ref |
Existing FileReference to upload from: its storage_path is the deployment-store source key used when local_path is absent on this pod. See Uploading without a local copy. Symmetric with DownloadInput.ref. |
tier |
No (default RETAINED) |
Tier that controls the destination prefix and cleanup policy. |
storage_path |
No | Fully-qualified destination key. Overrides the auto-generated path. Use this when you need an exact fixed path (e.g. argo-artifacts/spec.json). |
storage_subdir |
No | Subdirectory appended under the run prefix. Useful for grouping related uploads without spelling out the full path. |
skip_if_exists |
No (default False) |
When True, skip uploading files whose SHA-256 already matches the stored {key}.sha256 sidecar. Useful for retried tasks and idempotent re-uploads. |
raise_on_empty |
No (default False) |
When True, raise StorageEmptyUploadError if the upload completes with zero files (e.g. local_path pointed at an empty directory). Leave False for incremental extractors where a quiet run legitimately produces no output; set True when zero files indicates a bug. |
When storage_path is not set, the destination key is computed as:
{app_prefix}/{filename}
Where app_prefix depends on the tier:
| Tier | app_prefix (= destination key without filename) |
|---|---|
RETAINED (default) |
artifacts/apps/{app_name}/workflows/{run_id} |
TRANSIENT |
file_refs |
PERSISTENT |
persistent-artifacts/apps/{app_name} |
Note: the
{uuid}segment (e.g.file_refs/{uuid}/tables.parquet) only appears when the FileReference activity interceptor auto-persists a ref returned from a@task— that path is computed byStorageTier.auto_persist_key(), not byApp.upload().App.upload()usesStorageTier.upload_prefix(), which puts the file directly under{app_prefix}/{filename}.
When storage_subdir is set, it is appended between the run prefix and the filename:
artifacts/apps/{app_name}/workflows/{run_id}/{storage_subdir}/{filename}
TRANSIENT and PERSISTENT tiers ignore storage_subdir.
App.upload() does not require the artifacts to be on the calling pod. When
local_path is absent, it streams from the deployment store at
ref.storage_path instead of the local filesystem — so it works when the tasks
that produced the tree ran on other workers (KEDA-scaled SDR), when the writer
deleted its local copy (use_consolidation=True), or when the caller only ever
had a FileReference:
# The transformed tree is in the deployment store; this pod never held it.
result = await self.upload(
UploadInput(
ref=staged_ref, # source: deployment store at staged_ref.storage_path
storage_path=staged_ref.storage_path, # pin the destination → key-preserving
tier=StorageTier.RETAINED,
)
)Two behaviours worth knowing:
- Pin
storage_pathto the ref's own prefix and the copy is key-preserving — each key keeps its name in the destination store. Leave it off and the artifacts land under the canonical prefix for the tier (see the table above). - A copy whose source and destination are the same object is already
satisfied: it is recognised and skipped (
reason="skipped:same_object") rather than moving bytes. That is what makes the two-store dual write free in this shape. Arefpointing at a key that was never written is not silently treated as satisfied — it fails withStorageNotFoundError.
A partially-present local directory gets both halves when the destination is a different store — i.e. an SDR hand-off to the upstream store: the local files are uploaded and anything present only in the deployment store is streamed, so the destination copy is complete regardless of which pod produced what. Within a single store the local directory stays authoritative and store-only files are not merged in; there is no second store to reconcile against, so a partial local tree publishes as partial.
Deriving the ref is automatic — passing local_path alone gives you the same
fallback, since the SDK derives ref from it. Pass ref explicitly when the
source key is not the mirror of a local path. Either way, the artifact must have
reached the deployment store in the first place: that happens when a @task
returns a FileReference (the interceptor persists it), and a ref created
with is_durable=True opts out of that copy — leaving nothing to fall back to.
class UploadOutput(BaseModel):
ref: FileReference # durable ref pointing at the uploaded file
synced: bool = False # True if at least one file was actually transferred
reason: str = "" # human-readable outcome, e.g. "uploaded" or "skipped:hash_match"| Field | Description |
|---|---|
ref |
Durable FileReference with is_durable=True and storage_path set. file_count reflects the number of files uploaded (1 for a single file, N for a directory). |
synced |
True if at least one file was transferred to the store. False when all files were skipped (e.g. skip_if_exists=True and every file already matched its SHA-256 sidecar). |
reason |
Short string describing the transfer outcome. Typical values: "uploaded", "skipped:hash_match", "skipped:same_object" (source and destination are the same object — see Uploading without a local copy), "empty". Useful for logging. |
The returned ref is already durable — you can pass it as a FileReference field on a subsequent task's Input.
from application_sdk.contracts.storage import UploadInput
from application_sdk.contracts.types import StorageTier
# Inside run() or a @task method:
upload_result = await self.upload(
UploadInput(local_path="/tmp/output/tables.parquet") # RETAINED is the default tier
)
# upload_result.ref is now a durable FileReference
# storage_path will be something like:
# artifacts/apps/myapp/workflows/run-abc123/tables.parquetupload_result = await self.upload(
UploadInput(
local_path="/tmp/spec.json",
storage_subdir="argo-artifacts",
)
)
# storage_path: artifacts/apps/myapp/workflows/.../run-.../argo-artifacts/spec.jsonupload_result = await self.upload(
UploadInput(
local_path="/tmp/spec.json",
storage_path="argo-artifacts/my-connector/latest-spec.json",
)
)
# storage_path: argo-artifacts/my-connector/latest-spec.json (exactly)# In the extract task:
upload_result = await self.upload(UploadInput(local_path=extract_output.db_path))
# Pass the durable ref to the transform task:
transform_input = TransformInput(source_db=upload_result.ref)
transform_output = await self.transform(transform_input)# Write and upload a watermark after each successful run:
watermark_path = Path(TEMPORARY_PATH) / "watermark.json"
watermark_path.write_text(json.dumps({"last_run": arrow.utcnow().isoformat()}))
await self.upload(
UploadInput(
local_path=str(watermark_path),
tier=StorageTier.PERSISTENT,
storage_path=f"persistent-artifacts/apps/{self._app_name}/watermark.json",
)
)# ── FileReference (inter-task, automatic) ─────────────────────────────────
class ExtractOutput(Output):
raw_db: FileReference | None = None # tier=TRANSIENT by default
# In the extract task — just set local_path:
return ExtractOutput(raw_db=FileReference(local_path=str(db_path)))
# In the transform task — local_path is already set by the SDK:
async def transform(self, input: TransformInput) -> TransformOutput:
db = sqlite3.connect(input.raw_db.local_path) # ready to use
# ── App.upload() (outbound, stable path) ──────────────────────────────────
# In the run() method — push result for the platform to index:
result = await self.upload(
UploadInput(local_path=transform_output.result_parquet.local_path) # RETAINED is the default
)
# result.ref.storage_path is stable and survives workflow completionA FileReference names one object. A prefix names whatever a listing
happens to return — and a listing cannot tell "absent" from "lost". A
transformed/ tree short by one entity looks exactly like a run that only
produced three, so a consumer that walks the prefix (the publish app does)
diffs the tenant against the subset and archives everything the walk missed.
APP-CORRECTNESS-001 is the rule: verify cross-activity handoffs against a
producer-written declaration, never by listing a storage prefix. The refs the
producing tasks returned are that declaration — App.verify_refs() is what
turns them from a value nobody read into an assertion.
Call it from run() at the point a step stops passing typed refs and starts
passing a prefix:
from application_sdk.contracts.storage import VerifyRefsInput
results = await asyncio.gather(*transform_tasks)
refs = [r.transformed_file for r in results if r.transformed_file is not None]
await self.verify_refs(
VerifyRefsInput(
refs=[r.model_copy(update={"auto_materialize": False}) for r in refs],
prefix=transformed_data_prefix,
)
)Each ref is checked by its storage_path, used verbatim — matching the key
persist_file_reference wrote, so the check asks about the key that actually
exists rather than a re-normalised guess at it.
A ref counts as a directory ref when its key ends in / (what the
interceptor's directory branch writes) or its file_count is anything other
than 1 (which catches a hand-pinned directory key with no slash). Those are
listed — with sidecars excluded, since every object carries a {key}.sha256
and a raw listing would return roughly 2N keys for N files — and must yield at
least file_count data objects. Everything else is checked with a HEAD.
Both signals are needed: a directory holding exactly one file has
file_count == 1, so discriminating on the count alone would HEAD its prefix
as an object key and 404 on a tree that is perfectly intact.
With prefix set, a ref that resolves outside it fails too: that is data the
downstream walk will never reach, which is the same hole by a different route.
Anything missing raises StorageHandoffIncompleteError (DATA_INTEGRITY,
non-retryable) naming the keys, so the run fails at the producer instead of
surfacing as a short publish two stages later.
Set auto_materialize=False on the copies you pass in. Verification is a
metadata lookup; without it the interceptor would download every declared file
onto whichever pod runs the check.
store selects which store to assert against and defaults to
StoreTarget.DEPLOYMENT — the store the interceptor persists refs to. Pass
StoreTarget.UPSTREAM after an App.upload() fan-out to assert what the
publish app will actually read.
SqlApp.run() does all of this for you and surfaces the refs it verified on
ExtractionOutput.transformed_files, so a connector's own Atlan bridge can
hand each one to App.upload(ref=...) instead of scanning a directory that,
on a fanned-out run, was written by pods it never shared a filesystem with.
A run() override that adds an entity the default run() does not drive must
finish with SqlApp.finalize_extraction(base, [extra_transform_output]), not by
concatenating onto base.transformed_files by hand. super().run() verified
the four refs it drove and then returned, so a ref appended afterwards is
never asserted — the override reads as if it verified everything while shipping
an unverified entity, which is this same shortfall one layer up.
verify_refs() asserts a declaration. upload_refs() delivers one: it
takes the same list and lands every ref under a single destination prefix, then
verifies what it delivered. This is the fan-in half of the same handoff, and it
is the loop connectors that cross the SDR store boundary have been hand-rolling.
The hand-rolled version usually scans os.path.join(output_path, "transformed")
and uploads whatever it finds. That is wrong on any run where the transforms
fanned out: the directory is written by the activities, so the calling pod
sees only the subset that happened to run locally — and on a fully distributed
run, nothing at all. upload_refs() passes each ref instead, so the SDK streams
from the deployment store for the files this pod never held.
from application_sdk.contracts.storage import DeclaredFile, UploadRefsInput
delivered = await self.upload_refs(
UploadRefsInput(
files=[DeclaredFile(ref=r) for r in extraction.transformed_files],
source_prefix=extraction.transformed_data_prefix,
prefix=f"artifacts/apps/{app}/workflows/{wf_id}/{run_id}/transformed",
)
)
return MyOutput(transformed_data_prefix=delivered.prefix)Three things it does not leave optional:
- Every upload opts into
raise_on_empty=True. A declared file that contributes zero objects is a hole in the tree the consumer will walk. - An empty declaration returns an empty prefix.
delivered.prefixis""when there was nothing to deliver, so you hand""downstream rather than a prefix naming an empty tree. An empty tree is not a quiet no-op to a consumer that diffs against it — PublishNode reads it as "delete everything". - The delivery is verified against the declaration in the store it landed in, before the task returns.
Destination keys come from one of two explicit knobs, never a guess:
DeclaredFile(label=...) names the leaf directly (use this when the refs'
own keys carry no structure, e.g. file_refs/<uuid>.json), or
source_prefix is stripped from each ref's key to give the leaf (use this when
the keys already have the shape the destination should keep). A declared file
that yields neither raises UnplaceableDeclaredFileError — a rule that
recovers the entity segment from four refs and drops it from one would reshape
the tree on exactly the small runs nobody inspects.
# contracts.py
class ExtractOutput(Output):
raw_db: FileReference | None = None # TRANSIENT: temp inter-task file
class TransformInput(Input):
raw_db: FileReference | None = None
class TransformOutput(Output):
result_parquet: FileReference | None = None # RETAINED: kept after run
# connector.py
class MyConnector(App):
@task
async def extract(self, input: ExtractInput) -> ExtractOutput:
db_path = Path(TEMPORARY_PATH) / "raw.db"
run_extraction(db_path)
return ExtractOutput(raw_db=FileReference(local_path=str(db_path)))
# SDK uploads raw.db to file_refs/{uuid} automatically on return
@task
async def transform(self, input: TransformInput) -> TransformOutput:
# SDK downloaded raw.db before this method was called
df = read_sqlite(input.raw_db.local_path)
out = Path(TEMPORARY_PATH) / "result.parquet"
df.to_parquet(out)
return TransformOutput(
result_parquet=FileReference(local_path=str(out), tier=StorageTier.RETAINED)
)
# SDK uploads result.parquet to artifacts/.../run-.../file_refs/result.parquet
async def run(self, input: RunInput) -> RunOutput:
extract_out = await self.extract(ExtractInput(...))
transform_out = await self.transform(
TransformInput(raw_db=extract_out.raw_db)
)
# Push artifact so the platform can index it:
upload = await self.upload(
UploadInput(
local_path=transform_out.result_parquet.local_path,
storage_subdir="argo-artifacts",
)
)
return RunOutput(artifact_ref=upload.ref)class ParseInput(Input):
manifest: FileReference | None = None # eager
full_snapshot: Annotated[FileReference | None, Lazy()] = None # lazy
class MyConnector(App):
@task
async def parse(self, input: ParseInput) -> ParseOutput:
# manifest is already downloaded — use it directly
items = json.loads(Path(input.manifest.local_path).read_text())
if self.requires_full_snapshot(items):
# Only download the large snapshot when actually needed
snap_ref = await fetch(input.full_snapshot)
snapshot_data = load_snapshot(snap_ref.local_path)
return ParseOutput(results=process(items))async def run(self, input: RunInput) -> RunOutput:
# Try to read the watermark from the previous run:
watermark_key = f"persistent-artifacts/apps/{self._app_name}/watermark.json"
try:
local_wm = await download_file(watermark_key, "/tmp/watermark.json")
last_run = json.loads(Path("/tmp/watermark.json").read_text())["last_run"]
except StorageNotFoundError:
last_run = None # first run
# ... do the work ...
# Write updated watermark:
Path("/tmp/watermark.json").write_text(json.dumps({"last_run": now_iso()}))
await self.upload(UploadInput(
local_path="/tmp/watermark.json",
storage_path=watermark_key,
tier=StorageTier.PERSISTENT,
))# WRONG — module level or __init__:
MY_REF = FileReference(local_path="/tmp/file.bin") # breaks Temporal sandbox
# CORRECT — inside a @task function body:
@task
async def my_task(self, input) -> Output:
ref = FileReference(local_path="/tmp/file.bin")
return Output(ref=ref)# WRONG:
class BadOutput(Output):
raw_bytes: bytes = b"" # will hit 2 MB Temporal limit
# CORRECT:
class GoodOutput(Output):
data_file: FileReference | None = None# WRONG — the SDK walks dict values, not keys:
data = {ref: "some_value"} # ref will not be persisted
# CORRECT:
data = {"file": ref}# WRONG — in run(), local_path is a stale hint, not guaranteed to exist:
path = input.ref.local_path
data = open(path).read() # may not exist on this machine
# CORRECT — read local_path only inside @task, after the SDK has materialised it:
@task
async def my_task(self, input: MyInput) -> MyOutput:
data = open(input.ref.local_path).read() # safe: SDK set this before call# WRONG — FileReference is internal to the run:
return Output(ref=FileReference(local_path=str(result), tier=StorageTier.RETAINED))
# The Atlan platform cannot index this on its own
# CORRECT — use App.upload() to push to a stable, platform-visible path:
await self.upload(UploadInput(local_path=str(result))) # RETAINED is the default tierAll events below are structured log records routed through OTLP. They appear in
Grafana under the application_sdk.storage logger with the fields listed.
Events for files ≥ 10 MiB are logged at INFO; smaller files at DEBUG.
| Event | Fields |
|---|---|
file_ref.persist.start |
storage_path, local_path, file_size_bytes, tier |
file_ref.persist.complete |
storage_path, bytes_uploaded, duration_ms, sha256 |
file_ref.persist.failed |
storage_path, local_path, error_type, bytes_uploaded |
| Event | Fields |
|---|---|
file_ref.materialize.start |
storage_path, file_size_bytes, is_cache_hit=False |
file_ref.materialize.complete |
storage_path, bytes_downloaded, duration_ms, sha256 |
file_ref.materialize.skipped |
storage_path, local_path, is_cache_hit=True |
file_ref.materialize.failed |
storage_path, bytes_transferred_before_failure, error_type |
file_ref.materialize.dedup_hit |
storage_path, dedup_key, reused_local_path, local_path |
file_ref.materialize.lazy_skipped |
storage_path |
| Event | Fields |
|---|---|
storage.upload success |
store_path, size_bytes, elapsed_ms, throughput_mibps |
storage.download success |
store_path, size_bytes, elapsed_ms, throughput_mibps |
storage.upload failure |
store_path, error_class, elapsed_ms |
storage.download failure |
store_path, error_class, elapsed_ms |
BaseMetadataExtractor.upload_to_atlan is deprecated. It still works (it
now just forwards to App.upload internally), but emits a DeprecationWarning
and will be removed in the next major SDK release.
Deprecated:
await self.upload_to_atlan(UploadInput(output_path=input.output_path))Replacement:
up = await self.upload(UploadInput(local_path=input.output_path))
records_uploaded = up.ref.file_count or 0App.upload() is concurrent, SHA-256 verified, and works identically in hosted
and SDR deployments.