deer-flow/backend/docs/blob-storage.md
zeng-bohan 82ac1612e3
feat(storage): add a content-addressed blob store contract (issue #4189 item 2) (#5361)
* 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>
2026-09-24 09:35:03 +08:00

113 lines
8.4 KiB
Markdown

# Blob storage (content-addressed, cross-instance)
Resolves the multi-instance half of [#4189](https://github.com/bytedance/deer-flow/issues/4189) item 2: two producers persist blob-shaped data outside the checkpoint payload and address it with a **server-local filesystem path**, which only resolves on the instance that wrote it.
| Producer | Write | Reads |
|---|---|---|
| Viewed images | `view_image_tool` → `ViewedImageData.actual_path` (`deerflow/agents/thread_state.py:52`) | `ViewImageMiddleware._read_image_as_data_url`, gateway artifact routes, IM channels, `present_file_tool` |
| Externalized tool results | `ToolOutputBudgetMiddleware._externalize` → virtual path under the thread `outputs` tree | model `read_file` via the thread-data mount |
On a single gateway both are correct. Behind a load balancer, the instance handling the read is frequently not the instance that wrote the file. The blob store replaces **"where on this machine"** with **"which content"**, so every instance that can reach the backing store resolves the same bytes.
## Contract
`deerflow/storage/contract.py`
- `BlobRef` — `{sha256, size, kind, content_type}`. The digest is the address, so writes are idempotent and dedup is free.
- `BlobStore` — plain ABC, tiered like `MemoryStorage`:
- **abstract** — `put_bytes(data, *, kind, content_type=None, thread_id=None) -> BlobRef` (`thread_id` is advisory provenance, see the deletion rule below), `get_bytes(ref) -> bytes`
- **default** — `exists` (probes via `get_bytes`), `delete` (raises; deleting an absent blob is not an error), `close`
- Errors — `BlobStoreError` → `BlobNotConfiguredError` / `BlobWriteError` / `BlobReadError` → `BlobNotFoundError`. A failed `delete` raises the neutral base `BlobStoreError`, not a read error.
Reads **verify the digest**. A content-addressed store that silently returns wrong bytes is indistinguishable from a corrupt checkpoint, so it fails loudly instead.
`kind` is validated by `validate_blob_kind` against `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` (1-64 chars, no leading or trailing hyphen) at the contract level because it is also a path segment in the `local_fs` backend — traversal and separator surprises are ruled out once, not per backend, and the single validator is what a rejected producer sees.
## Backend
`deerflow/storage/backends/local_fs/` (default)
```
<root>/<kind>/<sha256[:2]>/<sha256> the bytes
<root>/<kind>/<sha256[:2]>/<sha256>.json sidecar: content_type, writer_thread_id, created_at
```
- Writes go to a unique temp file in the destination directory then `os.replace` — atomic within a volume, so a concurrent reader never sees a partial file and two instances racing the same blob converge on identical content.
- Single-put size cap: `_MAX_BLOB_BYTES` (64 MiB) in `local_fs_store.py` rejects larger puts with `BlobWriteError`. This is a backend-level defense-in-depth limit, **not** part of the `BlobStore` contract — other backends set their own policy — but the externalized-tool-results follow-up should assume it and stream or split oversized payloads before they reach `put_bytes`.
- The sidecar is GC metadata, **not a read dependency and not a reference count**: losing it must not make content unreadable, and `writer_thread_id` is advisory provenance that cannot stand in for liveness (see the deletion rule above).
This backend already delivers multi-instance resolution when `root` points at a shared volume (NFS / EFS / a `ReadWriteMany` PVC). The S3/MinIO backend is a later, optional extra implementing the same contract — it is deliberately not part of this change, because it needs a dependency decision (`[tool.uv.sources]`, optional extra) that belongs in its own PR.
## Configuration
```yaml
blob_storage:
enabled: true # default false
backend: local_fs # folder name under storage/backends/, or a dotted import path
backend_config:
root: /mnt/shared/deerflow-blobs # default: {runtime_home}/blobs, absolute
```
Fail-fast on an unresolvable backend (`ValueError`), mirroring `MemoryConfig.manager_class`: blobs are persistent state, so silently substituting a different backend would strand previously written content.
The factory checks the effective backend and backend configuration on each access. After a `config.yaml` edit, new accesses use the new store; disabling storage makes the optional accessor return `None` and the required accessor raise `BlobNotConfiguredError`. A store already handed to an in-flight caller remains usable and is closed by `reset_blob_store()` rather than during the switch. When changing a storage root in a multi-instance deployment, coordinate the rollout and keep previously written blobs available until existing references have been migrated.
## What this PR deliberately does not do
**No producer is migrated.** `blob_storage.enabled` defaults to `false`, no existing call site changed, and the diff is purely additive — a deployment that never sets the key behaves exactly as before.
Migration happens in two follow-up PRs, each independently revertible:
1. **Viewed images.** `ViewedImageData` gains an optional `blob_ref` (`actual_path` is kept); `view_image_tool` writes the blob when the store is enabled; `ViewImageMiddleware._read_image_as_data_url` resolves blob-first, path-second. The gateway artifact routes and IM channels keep their local-path reads until they can be exercised against a multi-instance deployment.
2. **Externalized tool results.** `ToolOutputBudgetMiddleware._externalize` records a ref alongside the virtual path; the sandbox variant (`_externalize_to_sandbox`, issue #3416) stays as-is — sandbox-resident content is a different failure mode one layer down.
## Interaction with checkpoint retention (#5255)
Blobs are content-addressed, and `kind` plus the sidecar's `writer_thread_id`
make a sweep *addressable* by kind and thread. That is the whole claim this seam
supports — it is **not** that retention can stop reasoning about references:
> **Deletion rule.** A blob may be unlinked only after confirming that no
> surviving durable reference names it. `kind` / `writer_thread_id` narrow the
> candidate set; on their own they are never sufficient.
The narrower rule is the honest one, because identical content collapses to one
object. Two threads that externalize the same bytes share a single file, and the
sidecar can only record whichever writer landed first (`writer_thread_id` is set
by the first write; the idempotent path does not rewrite it). Concretely: thread
A and thread B both view the same uploaded image — same bytes, same sha256, one
file under `viewed-image/`. A sweep for A that trusted the sidecar would delete
bytes B still references, and B's own sweep would never see the blob at all,
because the sidecar names A.
Recording *every* writer thread id would not repair that. Appending to one
sidecar from two gateway instances is a read-modify-write race with lost
updates, so the list could be missing precisely the writer that still needs the
content — and a reference count that is wrong under concurrency is worse than
none. Hence the contract states the liveness rule instead of a refcount.
What this means for the two migration PRs:
- **Viewed images** — an image is shareable by construction (any two threads can
upload the same bytes), so the sweep must check liveness against the
checkpoint references before unlinking; `kind` and `writer_thread_id` bound
the search.
- **Externalized tool results** — per-thread by construction, so a thread-scoped
sweep is safe while that remains true; `kind` is what makes the "while"
checkable.
This is also the seam [#5188](https://github.com/bytedance/deer-flow/issues/5188) needs: a thread-scoped blob sweep keyed by thread incarnation, rather than by the reusable thread id.
## Adding a backend
`packages/harness/deerflow/storage/AGENTS.md` owns the depth — the drop-in
folder contract, the portability rule and the durability rules a new backend has
to keep. In short:
1. Copy `backends/local_fs/` to `backends/<name>/`.
2. Implement `from_config` + `put_bytes` + `get_bytes`; override `delete` if you can support it.
3. Export `STORE_CLASS = <YourStore>` from `backends/<name>/__init__.py`.
4. Set `blob_storage.backend: <name>`; backend knobs go under `blob_storage.backend_config`.
If the backend needs external libs (boto3, minio), declare them in `packages/harness/pyproject.toml` with `[tool.uv.sources]` — otherwise `uv sync` purges them.