lacing.artifact_store#
Artifact store — a metadata catalog and a blob store behind one facade.
An Artifact describes a generated file; this module
persists artifacts. It is the storage layer that reelee (and any other
producer) builds on so artifacts survive a process restart and are reachable
from another machine — not just from the browser tab that generated them.
Design#
The store keeps three concerns separate (see
misc/docs/Artifact Store Architecture — design notes.md):
Data organization — artifacts are physically flat: a catalog (
id -> record) holds metadata, a blob store (content_hash -> bytes) holds the heavy bytes. Neither key encodes ownership or grouping.Infrastructure mapping —
ArtifactStoreis a thin facade that composes two injected key-value stores. The backing stores are ordinaryMutableMappingobjects, so the backend swaps (in-memory -> filesystem -> object store) by dependency injection, with no change to callers.Access calculus — deliberately absent here.
ArtifactStoreis an unprivileged primitive that checks nothing; a caller that needs access control wraps it in a permission-enforcing facade of its own.
Identity is an explicit string key, not assumed to be the content hash: artifacts often get a stable id before their bytes exist (and hence before they can be hashed). The catalog is therefore generic over the record type and keyed by whatever id the caller chooses; the content hash, when known, is just a field on the record and the key of the blob store.
Consistency: there is no transaction spanning the two stores, so save()
writes the blob first, then the catalog row. Because blobs are
content-addressed the blob write is idempotent; a crash in between leaves at
worst an unreferenced (orphan) blob — never a catalog row pointing at missing
bytes.
Examples
>>> from lacing import Artifact
>>> store = ArtifactStore.in_memory()
>>> art = Artifact.from_bytes(
... b"fake-png-bytes", kind="image",
... was_generated_by="agent:flux", was_attributed_to="user:thor",
... )
>>> _ = store.save(art.asset_id, art)
>>> store[art.asset_id].kind
'image'
>>> art.asset_id in store
True
>>> len(store)
1
Classes
|
Facade over an artifact |
- class lacing.artifact_store.ArtifactStore(catalog, blobs=None)[source]#
Bases:
MutableMappingFacade over an artifact
catalogand an optionalblobsstore.The object is a
MutableMapping[str, record]over the catalog —store[artifact_id], iteration,len,get,clearand the rest of the mapping surface all act on artifact metadata records. The heavier byte operations (put_blob(),get_blob(),has_blob()) are rich methods that are deliberately not squeezed into the mapping protocol.- Parameters:
catalog (
MutableMapping[str,BaseModel]) – Injectedid -> recordstore. Records are pydantic models (lacing.Artifactby default, but anyBaseModelworks — the store does not inspect the record’s shape).blobs (
MutableMapping[str,bytes] |None) – Injectedcontent_hash -> bytesstore, orNonefor a catalog-only store (Stage-1 metadata persistence). Blob methods raise / no-op when it isNone. This is the raw backend: on a filesystem backend its owndeland iteration do no containment check, so go throughdelete_blob()anditer_blobs()(and the read methods) rather than touchingstore.blobsdirectly (lacing#55).
Construct one with
in_memory()orfrom_directory()rather than wiring the backing stores by hand, unless you are injecting a custom backend.- blob_location(content_hash)[source]#
Resolve the cheapest servable location for a blob — without reading its bytes. The capability the HTTP layer probes to serve a blob the most efficient way, generalizing
blob_path()so filesystem / S3 / R2 backends all answer one probe:object-store backends (S3/R2) that expose a presigned-URL capability — a
url_for(content_hash)callable — return a **URL string**, so the caller can 302-redirect and let the object store serve the bytes (and HTTPRange) directly, off the app process;filesystem backends return a local Path (see
blob_path()) so the caller hands the OS the file (Range free);everything else (plain
dict, or a missing blob) returnsNone— the cue to fall back toiter_blob()streaming.
Callers treat
Noneas “stream it”, not as an error.
- blob_path(content_hash)[source]#
Return the local filesystem path of the blob, or
None.The store’s blob backend is opaque (any
MutableMapping), but some backends — notably the filesystem-backeddol.Filesproduced byfrom_directory()— store each blob as one file under a known root directory. This method exposes that path when available, so a caller (e.g. a FastAPI route serving video) can hand the OS the file descriptor and let it answer HTTPRangerequests directly. It returnsNonefor:blob stores without a
rootdirattribute (e.g. plaindict, object-store backends — callers should fall back toiter_blob());blobs that are not present.
a
content_hashthat does not resolve to a path insiderootdir(path traversal, e.g."../../etc/passwd"or an absolute path, or a same-named symlink pointing outside the store) — refused rather than served (lacing#50).
Callers must treat
Noneas the cue to use the streaming read path, not as an error. Deleting and listing blobs have contained counterparts too (delete_blob(),iter_blobs()), and thefrom_directory()catalog confines artifact ids the same way (lacing#55).The containment check is point-in-time: the returned path is the fully resolved, symlink-free location of a regular file that was inside
rootdirwhen checked. The caller opens it later, by name, so if untrusted parties can write intorootdiritself, open it withO_NOFOLLOW— or serve viaiter_blob(), which reads through a descriptor and has no such window.
- count_refs(content_hash)[source]#
How many catalog records point at
content_hash(a blob).The reference count that a garbage collector needs: a blob is safe to delete only when no catalog record still names its content hash. Content-addressed blobs are deduplicated, so two artifacts can share one blob — deleting the blob the moment one of them goes away would orphan the other.
This is the probe half of a capability pair (mirroring
blob_location()probing a blob store forurl_for): a catalog backend that can answer the count cheaply — the SQL catalog fromfrom_sql(), via an indexedcontent_hashcolumn — exposes arefcount_by_content_hashcallable, and this method uses it so the count is one indexed query, not a full catalog scan. Any other catalog (dict,Files) falls back to scanning every record. The facade is unchanged either way; only the speed differs.
- delete_blob(content_hash)[source]#
Delete the blob
content_hash;KeyErrorif there is none.The contained counterpart of
del store.blobs[content_hash](lacing#55). On a filesystem-backed store (one exposingrootdir) a key is deleted only ifhas_blob()would say it exists — a regular file that resolves insiderootdir(see_ContainedDirStore, the same gate the catalog offrom_directory()uses). A key that escapes (.., an absolute path, a NUL, a symlink pointing outside) raisesKeyErrorexactly like a missing blob, as does any key with a..segment, and nothing outside the root is touched. The deletion itself is delegated to the backend’s own__delitem__on the key as given (normalised), so the backend’s delete policy (e.g.dol.Filesmoving files to the trash) is unchanged, and deleting an in-root symlink removes the link, never the blob it points at.The check is point-in-time, like
blob_path(): a party that can write intorootdirand swap a directory component of a nested key for a symlink between the check and the delete can still redirect it. Flat content-hash keys, which is all this store writes, have no such component.Backends without a
rootdir(dict, object stores) have no filesystem to escape; the key passes through to theirdel.
- classmethod from_aws(bucket_name, uri, *, record_type=<class 'lacing.artifact.Artifact'>, collection_name='artifact_catalog', prefix=None, s3_kwargs=None, sql_kwargs=None)[source]#
The production pairing: S3-compatible blobs + SQL catalog.
A thin convenience over
from_s3()(blobs) andfrom_sql()(catalog) so the common cloud deployment is one call. The blob store is anS3Store(AWS S3 / Cloudflare R2 / MinIO / Supabase); the catalog is the durable, queryable SQL table — Postgres in production, SQLite for a smoke test. Vendor specifics live only in the two kwargs dicts.- Parameters:
bucket_name (
str) – Object-store bucket for the content-addressed blobs.uri (
str) – SQLAlchemy URI for the catalog (postgresql://…in prod).record_type (
type[BaseModel]) – Pydantic model the catalog (de)serializes.collection_name (
str) – SQL table name for the catalog.prefix (
str|None) – Optional key prefix within the bucket.s3_kwargs (
dict|None) – Extra kwargs forwarded tofrom_s3()(credentials,endpoint_urlfor R2/MinIO/Supabase,region_name, …).sql_kwargs (
dict|None) – Extra kwargs forwarded tofrom_sql()(content_hash_of, SQLAlchemy engine kwargs, …).
- Return type:
- classmethod from_directory(root, *, record_type=<class 'lacing.artifact.Artifact'>)[source]#
An ArtifactStore persisted under
root.Lays out two subdirectories:
catalog/(one<id>.jsonfile per record) andblobs/(one file per content hash). Both aredolfilesystem stores, so the same facade works unchanged over any otherdolbackend (object storage, etc.) when injected directly.Artifact ids are confined to
catalog/(lacing#55): an id whose<id>.jsonwould resolve outside it —..segments, an absolute path, a NUL, or a symlink pointing out — is refused withKeyErroron get, save and delete, and is simply notinthe store. Nested ids ("a/b") that stay inside keep working, and listing never follows a symlinked directory out ofcatalog/. See_ContainedDirStore.- Parameters:
- Return type:
- classmethod from_s3(bucket_name, *, catalog=None, prefix=None, **s3_kwargs)[source]#
An ArtifactStore with blobs in an S3-compatible object store (AWS S3 / Cloudflare R2 / MinIO / Supabase) via
s3dol.Content-addressed blobs are a natural fit for object storage: flat, immutable, dedup-friendly, forever-cacheable keys.
blob_location()returns a presigned GET URL (via s3dol’surl_for) so a serving layer can 302-redirect and let the store deliver the bytes (and HTTPRange) directly, off the app process.The blob and catalog backends are independent by design: the catalog defaults to an in-memory dict here — swap in a durable catalog (e.g. a Postgres-backed
MutableMapping) for production.- Parameters:
bucket_name (
str) – The object-store bucket.catalog (
MutableMapping[str,BaseModel] |None) – Theid -> recordcatalogMutableMapping. Defaults to{}(in-memory).prefix (
str|None) – Optional key prefix within the bucket (e.g."blobs").**s3_kwargs – Forwarded to
s3dol.s3_store—endpoint_url(set for R2 / MinIO / Supabase),region_name,profile,credentials,preset,anon,on_missing_bucket. The pre-v1 spellings (aws_access_key_id/aws_secret_access_key/aws_session_token/profile_name/make_bucket) are still accepted and translated, with aDeprecationWarning.
- Return type:
Requires
s3dol>=1(andboto3) — imported lazily so the dependency is only needed when this constructor is used.
- classmethod from_sql(uri, *, blobs=None, record_type=<class 'lacing.artifact.Artifact'>, collection_name='artifact_catalog', content_hash_of=None, **db_kwargs)[source]#
An ArtifactStore with a SQL-backed catalog (durable, queryable) via
sqldol— the SQL counterpart offrom_s3()’s object store.The catalog row schema is deliberately minimal and vendor-neutral: one
TEXTcolumn holds the record serialized exactly asfrom_directory()serializes it (record.model_dump_jsonout,record_type.model_validate_jsonin), and one indexedcontent_hashcolumn carries the record’s blob hash so the GC reference count (count_refs()) is a single indexed query rather than a full scan.Because the connection is just a SQLAlchemy URI, the same code runs on SQLite for tests and on Postgres in production — that is the whole point of the facade. Pick the backend with the
urialone:ArtifactStore.from_sql("sqlite:///artifacts.db") # local / tests ArtifactStore.from_sql("postgresql://u:p@host:5432/db") # production
The catalog and blob backends are independent: pass
blobsto pair a SQL catalog with any blob store (e.g.from_s3’sS3Store), or leave itNonefor a catalog-only store (Stage-1 metadata persistence; seefrom_aws()for the common S3 + SQL pairing).- Parameters:
uri (
str) – SQLAlchemy connection URI (sqlite:///…orpostgresql://…). The single knob that selects the vendor.blobs (
MutableMapping[str,bytes] |None) – Optionalcontent_hash -> bytesblob store.Nonefor a catalog-only store.record_type (
type[BaseModel]) – The pydantic model the catalog deserializes JSON into. Defaults toArtifact; callers with their own record schema pass their model here.collection_name (
str) – The SQL table name for the catalog.content_hash_of (
Callable[[BaseModel],str|None] |None) – How to read a record’s blob hash for the indexedcontent_hashcolumn. Defaults toasset_id(the canonical Artifact field), thencontent_hash. Pass a callable for a record whose hash lives elsewhere; records without a hash store an empty string.**db_kwargs – Forwarded to
sqldol.SQLAlchemyStore/ SQLAlchemy’screate_engine(e.g.connect_args,pool_size).
- Return type:
Requires
sqldol(andSQLAlchemy) — imported lazily so the dependency is only needed when this constructor is used.
- get_blob(content_hash)[source]#
Return the bytes for
content_hash, orNoneif absent.On a filesystem-backed store (one exposing
rootdir) the bytes are read through_open_within(), never by re-opening the path by name, so a key that would land outsiderootdir— by.., an absolute path, or a symlink, including one swapped in after the containment check (lacing#50) — reads as absent.
- has_blob(content_hash)[source]#
Whether the blob store holds
content_hash.On a filesystem-backed store this is exactly “would
get_blob()return bytes”:Falsefor a key that resolves outsiderootdiror names anything but a regular file — see_open_within(). Backends without arootdir(dict, object stores) have no filesystem to escape, so their keys pass through unfiltered.- Return type:
- classmethod in_memory()[source]#
An ArtifactStore backed entirely by in-memory dicts.
For tests, scratch work, and as the trivial reference backend. Nothing persists across processes.
- Return type:
- iter_blob(content_hash, *, chunk_size=65536)[source]#
Yield the blob’s bytes in
chunk_sizechunks.The streaming counterpart to
get_blob()— what an HTTP response body iterates over when serving a large blob without holding it all in process memory. The default implementation reads the whole blob viaget_blob()and re-chunks it; a filesystem-backed store can be swapped for a true streaming reader without changing this API.
- iter_blobs()[source]#
Yield the content hash (key) of every blob in the blob store.
The contained counterpart of
iter(store.blobs)(lacing#55). On a filesystem-backed store it yields exactly the keyshas_blob()accepts — regular files insiderootdir— and never descends into a symlinked directory (dol.Filesdoes, so its listing can yield files that live outside the root, or loop on a symlink cycle). Hidden entries are skipped, asdol.Filesskips them, which also keeps the in-flight.blob-*.partspool files ofput_blob_stream()out of the listing.Yields nothing when no blob store is configured; other backends are iterated as they are.
- put_blob(data)[source]#
Store
datacontent-addressed; return its content hash.Idempotent: identical bytes always map to the same hash and overwrite an identical blob. Delegates to
put_blob_stream()so there is exactly one write path — and therefore one atomicity story (lacing#25).- Raises:
RuntimeError – no blob store is configured.
- Return type:
- put_blob_stream(chunks)[source]#
Stream
chunkscontent-addressed; return their SHA-256 hash.The streaming-friendly counterpart to
put_blob()— callers hand in an iterable (e.g.requests.Response.iter_content) instead of materializing the whole bytestring upfront. The hash is computed on the fly.When the blob store is filesystem-backed (exposes
rootdir, as the default directory store does), the bytes spool straight to a same-directory tempfile and are renamed into place withos.replaceonce the hash is known (lacing#25). Consequences a caller may rely on:peak memory is one chunk, not 2× the payload — 100 MB videos flow through without a 200 MB spike;
a partial blob is never observable under its content address — the name a reader could find only exists after the rename, which is atomic on POSIX and on Windows for same-directory renames — so
has_blob(h) == Truereally does mean the full bytes are there;a failed or abandoned stream leaves nothing behind (the tempfile is unlinked).
Backends without a
rootdir(plaindict, object-store mappings) fall back to buffering the payload and assigning it whole — their__setitem__is the atomicity story there.- Raises:
RuntimeError – no blob store is configured.
- Return type:
- save(artifact_id, record, *, data=None)[source]#
Persist one artifact, optionally with its bytes.
Writes the blob (if
datais given) before the catalog row, so a crash never leaves the catalog pointing at missing bytes. Idempotent onartifact_id— and, for the blob, on content — so retries are safe.- Parameters:
artifact_id (
str) – The stable string id this artifact is filed under.record (
BaseModel) – The metadata record to store in the catalog.data (
bytes|None) – Optional raw bytes. When given, they are stored content-addressed and the content hash is returned; the caller is responsible for also recording that hash onrecord.
- Return type:
- Returns:
The blob’s content hash if
datawas written, elseNone.- Raises:
RuntimeError –
datawas given but no blob store is configured.