Storage seam¶
The fsspec-backed IO layer that makes the framework cloud-agnostic
(ADR-0009) — file://, s3://, gcs://, az:// are
one line of config apart.
storage ¶
fsspec-backed Storage seam (ADR-0009).
All framework IO — input documents, inter-Stage records, checkpoints, exports, the Quality Report — flows through this one abstraction. Local disk, S3, GCS, Azure Blob, and in-memory are addressed by URI scheme with no framework code change; credentials resolve through fsspec's own default chains, so the framework never stores secrets.
SchemeNotAllowedError ¶
Bases: ConfigError
Raised when a Storage is built for a URI scheme not on the allowlist.
A :class:~latence_core.errors.ConfigError (a disallowed scheme is an operator config
mistake, #73) — and, transitively, still a ValueError, so the pre-taxonomy
except ValueError / except SchemeNotAllowedError guards keep catching it.
Storage ¶
Storage(
protocol: str = "file",
*,
allowed_schemes: Iterable[str] | None = None,
**storage_options: Any
)
Thin, URI-scheme-agnostic IO surface over fsspec.
A :class:Storage is bound to a single filesystem (derived from the scheme
of the URIs it is asked to operate on). Mixing schemes on one instance is a
programming error and raises ValueError.
Source code in packages/latence-core/src/latence_core/storage.py
for_uri
classmethod
¶
for_uri(
uri: str,
*,
allowed_schemes: Iterable[str] | None = None,
**storage_options: Any
) -> Storage
Build a Storage bound to the filesystem implied by uri's scheme.
Source code in packages/latence-core/src/latence_core/storage.py
write_atomic ¶
Write data to uri so a reader never sees a partial file.
The buffered face of :meth:atomic_writer — same publish-or-nothing guarantee, same
temp-file sweep on failure, for a payload that already fits in one bytes buffer.
This is the WAL discipline the Runner relies on for durable, resumable checkpoints
(ADR-0010): the destination either does not exist or is the complete previous/next
version, never a torn write, and a failure leaves no .tmp sibling behind.
See :meth:atomic_writer for what "atomic" does and does not mean per backend.
Source code in packages/latence-core/src/latence_core/storage.py
atomic_writer ¶
atomic_writer(
uri: str,
*,
destination: Callable[[], str | None] | None = None
) -> Iterator[IO[bytes]]
Stream bytes into uri: publish on clean exit, sweep the partial write on failure.
The one-call streaming write-atomic primitive (ADR-0010/0033). It opens a unique temp
sibling of uri, creates uri's parent directory, yields the open binary handle,
and on clean exit closes it and renames it onto uri. A reader never sees the temp
path under uri and never sees a torn uri — the same guarantee as
:meth:write_atomic, without materialising the payload in memory first::
with storage.atomic_writer(uri) as fh:
for batch in batches:
fh.write(batch)
The cleanup obligation lives here, not in the caller. The two-call
:meth:temp_uri / :meth:finalize_atomic protocol this replaces pushed onto every
caller a duty its signature never stated: sweep the temp path when the write dies
between the two calls. One of three callers actually did it, and the cost of the other
two was silent: each temp name carries a fresh random token, so on a flaky store every
retry stranded another full-size copy — unbounded growth of unreferenced, billable
objects that no lifecycle policy and no expire_snapshots knows about, still holding
the run's (possibly sensitive) bytes. On any exception the temp file is removed
best-effort and the original error is re-raised untouched (ADR-0034: the failure is
never masked by its own cleanup).
The sweep is reported, not silent: the propagating exception carries PEP-678 notes
(exc.__notes__) — one stating what the publication published (for the single-file face,
always nothing), and one per temp file saying either that it was removed or — when the
store refused the delete — naming the exact path that still holds the bytes so an operator
can delete it by hand. A caller that wraps the failure in its own typed error can fold
those notes into its message.
destination is for a content-addressed artifact whose final name is only knowable
once its bytes have been written (the Iceberg data file, named after the sha256 of its own
content). It is called after the handle is closed and its return value replaces uri as
the publish target; returning None publishes nothing and sweeps the temp file (the
"an identical file is already there, leave it alone" case). uri still names the temp
sibling, so the in-flight write lands next to where it is going.
What "atomic" means per backend — this is interface, not trivia:
- Local / POSIX: fsspec's rename maps to
os.replace, a genuinely atomic same-filesystem operation. - Object stores (S3, GCS, Azure): there is no rename. fsspec's
mvis a server-side copy followed by a delete — two operations, not one atomic one. What still holds is the guarantee callers actually depend on: a single-object write is atomic and read-after-write consistent on all three stores, so a reader ofurisees either the old object or the whole new one, never a torn one. What does not hold is the rest of the rename contract: a crash between the copy and the delete leaves the temp object behind (this method's sweep is best-effort and cannot run if the process is killed), and two publishers racing onto oneuriboth succeed with a last-writer-wins result rather than one losing. Sizes matter too: S3 caps a singleCopyObjectat 5 GB, so a larger artifact is copied as a multipart upload whose failure can leave an incomplete multipart upload the bucket's lifecycle policy — not this method — has to reap.
Source code in packages/latence-core/src/latence_core/storage.py
atomic_writers ¶
Stream a set of sibling files, publishing all of them only once all are complete.
The multi-file face of :meth:atomic_writer, for a deliverable that is a group of files
which must not appear one-by-one — the Export's records.jsonl + records.parquet (+
the optional multi-vector and BM25 sidecars), which a downstream reader treats as one
corpus (ADR-0033/0050). Every handle is written to its own temp sibling; not one byte is
published until every handle has closed cleanly, so a write error part-way through the
corpus can no longer leave a published records.parquet next to a missing
records.jsonl. On any exception every temp file is swept, exactly as
:meth:atomic_writer sweeps its one.
Handles are yielded in the order of uris and must all be used inside the block::
with storage.atomic_writers([jsonl_uri, parquet_uri]) as (jsonl, parquet):
...
Honest limit: the publish phase is still N renames, not one transaction. No store the framework targets offers a cross-object commit, so a failure during the publish loop leaves the already-renamed siblings in place and sweeps the rest; the exception's notes name what landed. This is strictly stronger than publishing as you go and is the most any of file/S3/GCS/Azure can actually give — a retried run rewrites the whole set.
Source code in packages/latence-core/src/latence_core/storage.py
temp_uri ¶
A unique sibling temp path for uri (the write side of :meth:finalize_atomic).
.. deprecated:: superseded by :meth:atomic_writer / :meth:atomic_writers.
This is half of a two-call protocol that leaks a cleanup obligation the interface
never states — the caller, not this module, is left holding the duty to sweep the temp
path when the write between the two calls fails. :meth:atomic_writer owns that duty.
Retained only until the last remaining caller (the Runner's checkpoint writer) moves
across; it is not part of the interface a new caller should learn.
Source code in packages/latence-core/src/latence_core/storage.py
finalize_atomic ¶
Atomically publish a fully-written temp file (from :meth:temp_uri) as uri.
The rename half of the two-call streamed write. See :meth:atomic_writer for what
"atomic" actually means on each backend (on an object store this is a copy and a
delete — two operations — not a rename).
.. deprecated:: superseded by :meth:atomic_writer / :meth:atomic_writers, which own
the cleanup obligation this pairing pushes onto its callers.
Source code in packages/latence-core/src/latence_core/storage.py
remove ¶
Physically delete a single file (the Purge / GDPR-erasure primitive, ADR-0018).
Idempotent: removing a non-existent path is a no-op, so a purge that has already erased a
file (or a file the caller only believes exists) does not raise. Unlike
:meth:write_atomic this is a destructive, non-recoverable delete — used only on the
Purge path where the S11 acceptance criterion requires that nothing derived survives
(the source file, and the derived records expunged from prior Versions and checkpoints).
Source code in packages/latence-core/src/latence_core/storage.py
remove_tree ¶
Recursively delete a directory and everything under it (the Purge erasure primitive).
Idempotent: a missing path is a no-op. Destructive and non-recoverable — used on the Purge path to wipe re-derivable run artifact trees (checkpoints, export outputs, run manifests) that would otherwise retain a purged document's raw text (the S11 "nothing derived survives" acceptance criterion).
Source code in packages/latence-core/src/latence_core/storage.py
modified ¶
The UTC last-modified time of uri, or None when the store does not expose one.
A purely read-only metadata probe (#186): the run console reads when a run's manifest
and Quality Report landed straight off Storage, so a run list carries real timestamps
without the framework persisting any new state (ADR-0010 — run state is the files that
are already there). fsspec backends spell the field differently (mtime on local,
LastModified on S3, updated on GCS, last_modified on Azure) and an in-memory
store exposes none at all, so this normalises the known spellings and returns None
rather than fabricating a time when the backend has nothing to give. A missing path is
None too — the caller renders "unknown", it is never an error.
Source code in packages/latence-core/src/latence_core/storage.py
walk_files ¶
Yield every file (not directory) under uri, recursively, sorted.
Every yielded path is a scheme-bearing URI the same Storage can read
back: fsspec's walk strips the protocol and yields bare
bucket/key (object stores) or bare filesystem paths (local), so each
result is re-decorated with this Storage's protocol via
unstrip_protocol before being yielded. Without this, feeding a
walk_files result straight back into :meth:read_bytes on an object
store raised ValueError (a bare bucket/key has no ://, so
:meth:_protocol_of mis-reads it as file and :meth:_check rejects
it against the s3/memory binding) — the H-E1 s3:// e2e blocker.
With follow_symlinks=False (the secure default) a file whose real,
symlink-resolved path escapes uri's own real path is dropped, so a
symlink planted inside the folder cannot be used to read files outside
it. fsspec's own walk follows symlinks (its ls does), so the
escape check is applied here rather than relying on a walk flag. Only the
local filesystem has symlinks; on object/in-memory stores the filter is a
no-op because os.path.realpath leaves non-local paths unchanged.
Memory characteristic — this is interface, not an implementation detail. Despite
returning an Iterator, this is NOT a streaming listing: the whole recursive listing is
accumulated and sorted before the first path is yielded, so peak memory is O(number of
files under uri) and the first result costs a full prefix walk. That is deliberate and
load-bearing rather than an oversight: the framework's determinism invariant requires a
stable document order, and fsspec's walk inherits its per-directory order from
ls (os.listdir order locally, the store's paging order remotely), which is not
stable across hosts or runs. A globally sorted order cannot be produced without seeing
every path, and a per-directory sort would yield a different (if deterministic) order
than the global one, silently changing every downstream artifact. Callers pointing this at
a bucket prefix with millions of objects should scope the prefix; a paged listing would be
a different, order-weakening method, not a change to this one.