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

199 lines
8.0 KiB
Python

"""Content-addressed blob store contract.
A :class:`BlobRef` addresses **content**, not a location. That is the whole
point of the abstraction: on a multi-gateway deployment a server-local
filesystem path is only meaningful on the instance that wrote the file,
whereas a content digest is meaningful everywhere -- every instance that can
reach the backing store resolves the same bytes.
Producers (issue #4189, item 2):
* ``ViewedImageData.actual_path`` -- written by ``view_image_tool``, read by
``ViewImageMiddleware``, the gateway artifact routes and the IM channels.
* Oversized tool results externalized by ``ToolOutputBudgetMiddleware``.
Both keep their existing local path when the store is disabled, so the seam is
purely additive.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Blob kinds are also filesystem path segments in the local_fs backend, so the
# grammar is deliberately narrow: lowercase, digits and hyphens, no leading or
# trailing hyphen, 1-64 chars. That rules out traversal (".."), separators and
# case-sensitivity surprises on NTFS/APFS at the contract level instead of
# leaving every backend to re-check it.
_KIND_PATTERN = re.compile(r"^(?=.{1,64}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
# The grammar as a caller sees it. One text, one place: this message is the only
# thing a producer gets when a kind is rejected, and a second copy of the
# pattern (the local_fs backend had one) drifts from the enforced one.
_KIND_GRAMMAR = "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ (1-64 chars, no leading or trailing hyphen)"
def is_valid_blob_kind(kind: str) -> bool:
"""Return whether *kind* is a safe blob-kind identifier."""
return isinstance(kind, str) and bool(_KIND_PATTERN.match(kind))
def validate_blob_kind(kind: str) -> str:
"""Return *kind* unchanged, or raise ``ValueError`` quoting the real grammar.
Backends must not re-implement the pattern: a store that rejects a kind the
contract accepts -- or accepts one it rejects -- makes content addressable
on one backend and not another.
"""
if not is_valid_blob_kind(kind):
raise ValueError(f"kind must match {_KIND_GRAMMAR} (it is used as a path segment)")
return kind
class BlobStoreError(RuntimeError):
"""Backend-neutral base error exposed at the blob store boundary."""
class BlobNotConfiguredError(BlobStoreError):
"""A caller required a blob store but ``blob_storage.enabled`` is False."""
class BlobWriteError(BlobStoreError):
"""A write failed after retries; the content was not persisted."""
class BlobReadError(BlobStoreError):
"""A read failed for a reason other than the blob being absent."""
class BlobNotFoundError(BlobReadError):
"""The referenced content is not present in the backing store."""
class BlobRef(BaseModel):
"""A content-addressed reference to blob bytes.
``sha256`` is the address: writes are idempotent (putting the same bytes
twice yields the same ref) and deduplication is free. ``size`` is carried
so a reader can sanity-check what it got back without trusting the
backend, and ``kind`` scopes garbage collection -- retention sweeps can
reason about a kind ("externalized tool outputs for thread X") without
parsing paths.
"""
model_config = ConfigDict(frozen=True)
sha256: str = Field(description="Lowercase hex SHA-256 of the blob content (64 chars).")
size: int = Field(ge=0, description="Content length in bytes.")
kind: str = Field(description="Content class, e.g. 'viewed-image' or 'tool-output'.")
content_type: str | None = Field(default=None, description="MIME type when the producer knows it.")
@field_validator("sha256")
@classmethod
def _sha256_is_hex64(cls, value: str) -> str:
value = (value or "").strip().lower()
if len(value) != 64 or not re.fullmatch(r"[0-9a-f]{64}", value):
raise ValueError("sha256 must be 64 lowercase hex characters")
return value
@field_validator("kind")
@classmethod
def _kind_is_safe(cls, value: str) -> str:
return validate_blob_kind(value)
def matches(self, data: bytes) -> bool:
"""Return whether *data* really is this blob's content."""
import hashlib
return len(data) == self.size and hashlib.sha256(data).hexdigest() == self.sha256
class BlobStore(ABC):
"""Backend-neutral blob store contract.
A plain ABC (like ``MemoryStorage``, not the pydantic ``MemoryManager``):
the contract carries no config fields, so there is nothing for pydantic to
validate and no ``PrivateAttr`` ceremony. Backends are constructed by the
factory through :meth:`from_config`.
Portability rule (mirrors the memory backends' golden rule): a backend
talks to the host through exactly two channels -- the method arguments and
the ``backend_config`` dict. The only ``deerflow`` import a backend folder
needs is the contract line importing :class:`BlobStore`.
"""
#: Sentinel attribute each backend package's ``__init__`` exposes so the
#: folder-scan factory can find it (mirrors memory's ``MANAGER_CLASS``).
STORE_CLASS_ATTR: ClassVar[str] = "STORE_CLASS"
@classmethod
@abstractmethod
def from_config(cls, backend_config: dict[str, Any]) -> BlobStore:
"""Build a store from backend-private config.
``backend_config`` is the dict from ``blob_storage.backend_config``,
with ``root`` injected by the factory when the host did not set one.
Must raise on an unusable config -- a blob store that silently starts
on the wrong root is worse than one that refuses to start.
"""
@abstractmethod
def put_bytes(
self,
data: bytes,
*,
kind: str,
content_type: str | None = None,
thread_id: str | None = None,
) -> BlobRef:
"""Persist *data* and return its :class:`BlobRef`.
Idempotent: putting identical bytes twice returns the same ref and must
not duplicate storage.
``thread_id`` is **advisory provenance, never a reference count.**
Content addressing means identical bytes collapse to one object, so two
threads that externalize the same content share it, and a backend can
only record whichever writer reached it first -- on a multi-instance
deployment it cannot even do that reliably, since two gateway instances
appending to one sidecar is a read-modify-write race that loses entries.
Garbage collection must therefore establish liveness from the durable
references (the checkpoint rows that name the blob), not from this
field; see ``docs/blob-storage.md`` for the exact rule.
"""
@abstractmethod
def get_bytes(self, ref: BlobRef) -> bytes:
"""Return the content addressed by *ref*.
Raises :class:`BlobNotFoundError` when the content is absent and
:class:`BlobReadError` when it is present but unreadable. Backends
SHOULD verify the digest on read: a content-addressed store that
silently returns wrong bytes is indistinguishable from a corrupt
checkpoint.
"""
def exists(self, ref: BlobRef) -> bool:
"""Return whether *ref* is present. Default probes via :meth:`get_bytes`."""
try:
self.get_bytes(ref)
except BlobNotFoundError:
return False
return True
def delete(self, ref: BlobRef) -> None:
"""Remove the content addressed by *ref*.
Default raises so a backend that cannot delete says so instead of
leaving a caller to assume a sweep succeeded. Deleting an absent blob
is not an error (idempotent), matching how the retention contract
treats orphans.
"""
raise NotImplementedError(f"{type(self).__name__} does not support delete")
def close(self) -> None: # noqa: B027 - intentional no-op default
"""Release backend resources. Default: nothing to release."""