* feat(storage): add a content-addressed blob store contract (#4189 item 2) Addresses the review on the previous head: * the retention/dedup claim in the doc now states the deletion rule (liveness from the durable references) instead of promising refcount-free deletion, and the sidecar records the first writer as advisory provenance only; * one validate_blob_kind() owns the kind grammar, so the message a producer sees is the pattern that is enforced; * adds the missing storage/AGENTS.md (registry entry included) that both docstrings already pointed at; * drops the dead _import_backend_module() by routing the folder scan through it; * exports BlobNotConfiguredError from the package; * renames _KIND_MAX_BYTES to _MAX_BLOB_BYTES (it caps one put, not a kind); * delete failures raise the neutral BlobStoreError instead of BlobReadError. Rebased onto current main so the guidance-budget test matches upstream. Signed-off-by: zeng-bohan <zengbh1@gmail.com> * fix(storage): restore the config module and backend packages dropped by the squash The force-push to e72b435 silently lost four items the surviving code imports, breaking `import deerflow.storage` and the test suite at collection: - deerflow/config/blob_storage_config.py (BlobStorageConfig + get/set_blob_storage_config + load_blob_storage_config_from_dict, mirroring memory_config's host-shared schema and hot-reload pattern) - app_config.py wiring: the blob_storage AppConfig field, the import, and the _apply_singleton_configs -> load_blob_storage_config_from_dict call (memory stayed wired; blob did not) - storage/backends/__init__.py (drop-in contract docstring) - storage/backends/local_fs/__init__.py (LocalFsBlobStore re-export + STORE_CLASS for _scan_backends) Verified: pytest tests/test_blob_store.py tests/test_app_config_reload.py tests/test_app_config_name_indexes.py -> 67 passed; ruff format/check clean on all four files. * fix(storage): name the failing digest check; document the 64 MiB per-put cap - get_bytes: split the corruption message into size mismatch (truncation / partial write) vs digest mismatch (same-size bit rot, reporting what the content hashes to vs what the ref names) — the combined message reported 'got 1024 bytes, expected 1024' on same-size corruption and sent the debugger in the wrong direction. - docs/blob-storage.md + storage/AGENTS.md: state the _MAX_BLOB_BYTES cap (64 MiB) and that it is backend-level defense-in-depth rather than part of the BlobStore contract, so the externalized-tool-results follow-up sees the limit before the first production put fails. * test(storage): pin both get_bytes corruption branches separately The pre-split test corrupted the blob with different-size bytes, so only the size-mismatch branch ever ran and neither new message was asserted. Now: different-size corruption asserts 'size mismatch', and a same-size reversal (bit rot) asserts 'digest mismatch' — the branch the split was actually about. * docs(config): document blob_storage in config.example.yaml Add the blob_storage section (enabled/backend/backend_config) mirroring the memory section's shape, matching the Configuration section of backend/docs/blob-storage.md, and bump config_version 46 -> 47 per the example-file convention for schema additions. Default state is unchanged: enabled: false, purely opt-in. * fix(helm): align chart config version with example * fix(storage): refresh blob store on config changes --------- Signed-off-by: zeng-bohan <zengbh1@gmail.com> Co-authored-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: zeng-bohan <310902929+zeng-bohan@users.noreply.github.com>
4.6 KiB
Blob Store (packages/harness/deerflow/storage/)
What it owns: a content-addressed store for bytes that must resolve on every
instance, not just the one that wrote them (issue #4189, item 2). The address is
the SHA-256 of the content, so writes are idempotent, deduplication is free, and
a reader can verify what it got back. Contract: storage/contract.py
(BlobRef, BlobStore, the BlobStoreError family, validate_blob_kind).
Operator-facing behaviour and the producer migration plan: docs/blob-storage.md.
Factory: storage/manager.py.
Disabled by default: blob_storage.enabled is false, and no producer has
been migrated, so an untouched deployment behaves exactly as before. Producers
call get_blob_store_if_enabled() and keep their existing local-path code for the
None case; only a caller that genuinely requires the store uses
get_blob_store(), which raises BlobNotConfiguredError when it is disabled.
Both accessors are exported from this package — prefer
from deerflow.storage import ... over reaching into submodules.
The factory compares the effective backend and backend config before reusing its
cached store, so hot-reloaded blob_storage edits take effect on new accesses.
Replaced stores stay open for callers that already hold them and are closed by
reset_blob_store(); changing a root requires preserving existing blob refs
and coordinating deployment instances.
Layout (local_fs, the default)::
<root>/<kind>/<sha256[:2]>/<sha256> the bytes
<root>/<kind>/<sha256[:2]>/<sha256>.json sidecar metadata
The sidecar holds what the bytes cannot say (content_type,
writer_thread_id, created_at). It is not a reference count and reads must
not depend on it: losing it cannot make content unreadable.
Adding a backend — the memory backends' drop-in contract, folder name ==
backend name == blob_storage.backend value:
- Create
storage/backends/<name>/with an__init__.pythat exposesSTORE_CLASS = <YourStore>; the factory discovers backends by scanning that folder, so no registry edit is needed. - Subclass
BlobStoreand implementfrom_config,put_bytes,get_bytes;exists/delete/closehave working defaults (deleteraises so a backend that cannot delete says so). Verify the digest on read — a store that silently returns wrong bytes is indistinguishable from a corrupt checkpoint. - Keep the portability rule: a backend talks to the host through the method
arguments and its
backend_configdict only. The singledeerflowimport a backend folder needs is the contract line importingBlobStore, so a third-party backend can live outside this tree and be selected with a dotted path (pkg.mod.Classorpkg.mod:Class). - Reject an unusable config by raising; a store that silently starts on a different root or backend strands previously written content.
- Add the backend's semantics tests to
backend/tests/test_blob_store.py; the suite runs every backend-agnostic scenario against a temporary root.
Durability rules that reviewers have enforced, worth keeping intact:
- Validate the
kindthroughvalidate_blob_kindinstead of re-implementing the grammar; it is also a path segment inlocal_fs, so traversal and separator surprises are ruled out once, at the contract level. - Publish atomically and tolerate a concurrent writer: two gateway instances may
put the same content at the same time (see
LocalFsBlobStore._publish, which handles the Windowsos.replace/WinError 5case by recognising that the destination already holds this content). - Fail fast on an unresolvable backend:
_resolve_store_classraises rather than substituting a default, because blobs are persistent state. - Reject oversized puts early:
local_fsenforces_MAX_BLOB_BYTES(64 MiB) per put as a backend-level defense-in-depth limit — not part of theBlobStorecontract, so a different backend sets its own policy, and the externalized-tool-results follow-up must stream or split payloads beforeput_bytes.
Garbage collection is not this module's job. A blob row may be referenced by
several threads once identical content dedupes, so a sweep must establish
liveness from the durable references (the checkpoint rows that name the blob) and
never from the sidecar's writer_thread_id. The exact rule, and how it composes
with the checkpoint retention contract (#5255), is stated in
docs/blob-storage.md.
Entry points: packages/harness/deerflow/storage/contract.py,
storage/manager.py, storage/backends/local_fs/local_fs_store.py,
config/blob_storage_config.py; tests in backend/tests/test_blob_store.py.