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

224 lines
9.1 KiB
Python

"""Blob store factory: folder-scan backend resolution + process singleton.
Mirrors ``deerflow/agents/memory/manager.py`` (the pattern maintainers pointed
at for issue #4189 item 2): backends live in ``storage/backends/<name>/``, each
package's ``__init__`` exposes ``STORE_CLASS``, and ``blob_storage.backend``
names one of them -- or a dotted import path for a backend that lives elsewhere.
Two accessors, so call sites cannot drift:
* :func:`get_blob_store_if_enabled` -- the one producers should use. Returns
``None`` when ``blob_storage.enabled`` is False, which is the default, so an
unmigrated call site is a no-op rather than an accident.
* :func:`get_blob_store` -- for callers that require a store; raises
:class:`BlobNotConfiguredError` when the store is disabled.
Both fail fast on an unresolvable backend. A blob store that silently starts on
a different backend than the operator configured would strand previously
written content, which is worse than refusing to start.
"""
from __future__ import annotations
import importlib
import logging
import threading
from copy import deepcopy
from pathlib import Path
from types import ModuleType
from typing import Any
from deerflow.config.blob_storage_config import BlobStorageConfig, get_blob_storage_config
from deerflow.storage.contract import BlobNotConfiguredError, BlobStore, BlobStoreError
logger = logging.getLogger(__name__)
# Backend packages live in <this dir>/backends/<name>/.
_BACKENDS_DIR = Path(__file__).parent / "backends"
# Sentinel attribute each backend's __init__ exposes (a BlobStore subclass).
_STORE_CLASS_ATTR = "STORE_CLASS"
# Singleton instance + the effective settings it was built with. Keep replaced
# stores alive until reset so callers already holding one can finish using it.
_blob_store: BlobStore | None = None
_blob_store_settings: tuple[str, dict[str, Any]] | None = None
_retired_blob_stores: list[BlobStore] = []
_backends_cache: dict[str, type[BlobStore]] | None = None
_store_lock = threading.Lock()
def _import_backend_module(name: str) -> ModuleType:
"""Import ``backends/<name>`` and return its package module."""
package_dir = _BACKENDS_DIR / name
if not package_dir.is_dir() or not (package_dir / "__init__.py").is_file():
raise ValueError(f"Unknown blob store backend {name!r}: no storage/backends/{name}/ package")
return importlib.import_module(f"{__package__}.backends.{name}")
def _resolve_store_class(backend: str) -> type[BlobStore]:
"""Resolve ``blob_storage.backend`` to a :class:`BlobStore` subclass.
Two forms are accepted, matching the memory backends' contract:
* a registered backend name -- a folder under ``storage/backends/`` whose
``__init__`` exposes ``STORE_CLASS``;
* a dotted import path -- ``pkg.mod.Class`` for a backend that lives
outside this package (an extension or a deployment-local backend).
"""
global _backends_cache
if "." not in backend:
if _backends_cache is None:
_backends_cache = _scan_backends()
cls = _backends_cache.get(backend)
if cls is None:
available = ", ".join(sorted(_backends_cache)) or "(none)"
raise ValueError(f"Unknown blob store backend {backend!r}. Registered backends: {available}. Alternatively pass a dotted import path to a BlobStore subclass.")
return cls
module_path, _, class_name = backend.partition(":") if ":" in backend else backend.rpartition(".")
if not module_path or not class_name:
raise ValueError(f"Invalid blob storage backend {backend!r}: needs 'pkg.mod.Class' or 'pkg.mod:Class'")
try:
module = importlib.import_module(module_path)
candidate = getattr(module, class_name)
except (ImportError, AttributeError) as exc:
raise ValueError(f"Cannot import blob storage backend {backend!r}: {exc}") from exc
if not (isinstance(candidate, type) and issubclass(candidate, BlobStore)):
raise ValueError(f"Blob storage backend {backend!r} is not a BlobStore subclass")
return candidate
def _scan_backends() -> dict[str, type[BlobStore]]:
"""Return ``{name: BlobStore subclass}`` for every backend folder.
Imports through :func:`_import_backend_module` so the "is this a backend
package?" check lives in exactly one place -- a folder that survives the
scan is a folder the factory can resolve by name.
"""
registry: dict[str, type[BlobStore]] = {}
if not _BACKENDS_DIR.is_dir():
return registry
for entry in sorted(_BACKENDS_DIR.iterdir()):
if not entry.is_dir() or entry.name.startswith("_"):
continue
try:
module = _import_backend_module(entry.name)
except ValueError as exc:
# Not a backend package (no __init__.py); nothing to register.
logger.debug("Skipping %s: %s", entry.name, exc)
continue
except Exception:
# A broken optional backend must not take the whole store down:
# log it and leave it out of the registry so the factory can still
# resolve the default.
logger.exception("Skipping blob store backend %r: import failed", entry.name)
continue
cls = getattr(module, _STORE_CLASS_ATTR, None)
if isinstance(cls, type) and issubclass(cls, BlobStore):
registry[entry.name] = cls
else:
logger.warning(
"Blob store backend folder %r exposes no %s; skipping",
entry.name,
_STORE_CLASS_ATTR,
)
return registry
def _default_backend_config() -> dict[str, Any]:
"""Zero-config UX: default the backend root to deer-flow's state dir.
Absolute and CWD-independent, so every gateway instance in a deployment
that has not configured a shared root still lands in the same logical
place on its own node -- which is exactly the single-host behaviour we
must not change.
"""
from deerflow.config.runtime_paths import runtime_home
return {"root": str((Path(runtime_home()) / "blobs").resolve())}
def _resolve_backend_config(cfg: BlobStorageConfig) -> dict[str, Any]:
backend_config = dict(cfg.backend_config or {})
root = backend_config.get("root")
if not root:
backend_config.update(_default_backend_config())
elif not Path(str(root)).is_absolute():
# A relative root is resolved against runtime_home() (base_dir-relative,
# CWD-independent) in host code, so backends stay free of any
# runtime_home dependency -- same reason the memory factory does it.
from deerflow.config.runtime_paths import runtime_home
backend_config["root"] = str((Path(runtime_home()) / str(root)).resolve())
return backend_config
def get_blob_store_if_enabled() -> BlobStore | None:
"""Return the singleton store, or ``None`` when the store is disabled.
This is the accessor producers should call: ``None`` is an explicit,
checkable signal that the deployment did not opt in, and the producer then
keeps its existing server-local path.
"""
return _get_blob_store_if_enabled()
def get_blob_store() -> BlobStore:
"""Return the singleton :class:`BlobStore` for the active config.
Raises :class:`BlobNotConfiguredError` when ``blob_storage.enabled`` is
False, and ``ValueError`` when the configured backend cannot be resolved.
"""
store = _get_blob_store_if_enabled()
if store is None:
raise BlobNotConfiguredError("blob_storage.enabled is False; no blob store is available. Use get_blob_store_if_enabled() at optional call sites.")
return store
def _get_blob_store_if_enabled() -> BlobStore | None:
"""Select a store against one config snapshot under the singleton lock."""
global _blob_store, _blob_store_settings
with _store_lock:
cfg = get_blob_storage_config()
if not cfg.enabled:
return None
backend_config = _resolve_backend_config(cfg)
settings = (cfg.backend, deepcopy(backend_config))
if _blob_store is not None and _blob_store_settings == settings:
return _blob_store
cls = _resolve_store_class(cfg.backend)
new_store = cls.from_config(backend_config)
if _blob_store is not None:
_retired_blob_stores.append(_blob_store)
_blob_store = new_store
_blob_store_settings = settings
logger.info("Blob store resolved: %s (backend=%r)", type(_blob_store).__name__, cfg.backend)
return _blob_store
def reset_blob_store() -> None:
"""Close current and replaced stores; clear the singleton and registry."""
global _blob_store, _blob_store_settings, _backends_cache
with _store_lock:
for store in [*_retired_blob_stores, *([_blob_store] if _blob_store is not None else [])]:
try:
store.close()
except Exception:
logger.warning("Blob store close() raised during reset", exc_info=True)
_retired_blob_stores.clear()
_blob_store = None
_blob_store_settings = None
_backends_cache = None
__all__ = [
"BlobStoreError",
"get_blob_store",
"get_blob_store_if_enabled",
"reset_blob_store",
]