mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(memory): keep the retrieval index Pod-local and refresh scopes written by peers
DeerMem's derived SQLite FTS5 index for every user was one WAL database pinned
to {storage_path}/.retrieval. With several Gateway Pods sharing one home volume
each Pod opened that file over a network filesystem (unsupported by SQLite),
every Pod start emptied and refilled it under its peers, one Pod's corruption
recovery deleted files the others held open, and a Pod never learned that a
peer had written a user's facts, so memory_search served stale results.
- New backend-private key memory.backend_config.retrieval_index_path: empty
keeps {storage_path}/.retrieval, a relative path resolves against
storage_path, an absolute path is used as-is. The full rebuild and the
corruption recovery act only on that configured index.
- rebuild_index records each agent scope's manifest signature
(mtime_ns, size, revision) before reading its facts; search_facts rebuilds a
scope whose live signature differs because another process wrote the user's
memory. An own commit advances the recorded signatures of that user's scopes
only when they were in sync at the previous revision, so own writes never
rebuild while an interleaved peer write still does.
- Gateway startup warns, in the style of _validate_agent_storage, when a
declared multi-instance deployment keeps the DeerMem index inside
storage_path.
- config.example.yaml documents the key; config_version 55 -> 56, mirrored in
the Helm chart and its README.
- Helm mounts an emptyDir at /var/lib/deerflow/memory-index, points the key at
it, drops the legacy memory.storage_path: memory.json line, and documents the
memory layout next to the other multi-replica prerequisites.
* docs(changelog): link the memory retrieval index entry to #6494
* fix(memory): never advance the index signature on a no-op commit and resync whole scopes
Two review findings on the cross-replica retrieval re-sync:
- save() and apply_changes() inferred the replaced manifest revision as
"returned revision - 1". The commit helper returns the existing manifest
unchanged for a no-op (unchanged-value or empty change set), so after a peer
bumped the manifest an own no-op write advanced this process's stale index
signature to the peer's revision without carrying the peer's delta: a fact the
peer deleted stayed searchable until the manifest changed again. Both paths
now read the real pre-commit revision under the user lock and pass
committed=(pre_revision, signature) only when the commit produced a new
revision; a no-op passes None so the next search still rebuilds the scope.
- The scoped rebuild (signature mismatch and the pre-existing dirty-scope path)
listed facts through list_facts() with its default limit of 100 and then
replaced every row of the scope, so a scope with more than 100 facts lost its
tail on the first re-sync. It now reads the scope's complete fact list.
* fix(memory): fence index signature promotion against a concurrent scope rebuild
Review finding on the cross-replica re-sync: a successful adapter notification
does not prove its delta is still in the index when the signature is promoted.
The SQLite lock covers single adapter calls only, and neither the scope lock
nor DeerMem's warm-up lock spans "notify, then promote", so rebuild_index can
replace a scope's rows in between. After a peer summary-only commit (r+1), A's
search starts a scoped refresh that captures the r+1 signature and reads f1/f2;
A's own deletion of f2 commits r+2 and removes the row; the refresh then
installs its stale f1/f2 and marks the scope synced at r+1; the deletion
resumes and promoted that signature to r+2, so later searches saw the live
signature as equal and kept returning the resurrected f2 until another manifest
change.
Mechanism: a generation fence rather than a lock. rebuild_index bumps a
per-scope generation (scoped rebuild) or a storage-wide generation (full
rebuild) under _cache_lock whenever it publishes or forgets a scope's rows, in
both mark_synced and forget_synced. _dispatch_retrieval_notifications snapshots
the generations under _cache_lock before its first adapter call and, at
promotion time, skips every scope whose generation moved (and everything when
the storage-wide generation moved), leaving the rebuild's own captured
signature in place; that signature predates the commit, so the next search's
comparison rebuilds the scope. A lock would have had to serialize notification
plus promotion with bulk install plus publication while search_facts ->
rebuild_index -> load() already takes the scope, file and cache locks; the
fence adds no lock-order obligations and only costs a redundant rebuild in the
rare race. Every interleaving is covered: a rebuild that completes before the
snapshot is reflected in the captured generation and promotion proceeds on an
index that carries both the rebuild's rows and this write's delta; one that
publishes between snapshot and promotion is skipped; one that publishes after
the promotion records its stale captured signature, which the next search
catches.
Tests reproduce both orders deterministically with threading.Event ordering
(adapter wrapper that holds the bulk install and the remove notification):
test_promotion_is_fenced_against_a_refresh_that_installs_in_between (red before
the fix) and
test_stale_refresh_installing_after_a_promotion_is_caught_by_the_next_search
(regression guard for the mirrored order).
* fix(memory): publish a rebuild's signature under the lock that replaced its rows
Review finding on the cross-replica re-sync: two peer-triggered refreshes of
one scope could replace rows in one order and publish their captured
signatures in the other. On a warmed Pod A, refresh R1 captures r+1 with f1/f2
and waits before installing; a peer deletes f2 at r+2; refresh R2 captures
r+2, installs f1 only and pauses after bulk_rebuild returns, before
mark_synced; R1 installs its stale f1/f2 and publishes r+1; R2 then publishes
r+2 over R1's rows. Later searches saw the live signature as equal and kept
returning the deleted fact. The generation fence only guards own-write
promotion, and the adapter's SQLite lock ends when the install returns.
Mechanism: a per-storage _retrieval_publish_lock held across the row
replacement and the signature publication, for the bulk path (bulk_rebuild +
mark_synced / forget_synced) and the clear+upsert fallback, scoped and full
alike. Overlapping refreshes of a scope therefore serialize their
install+publish pairs: whichever installs last also publishes last, and a stale
captured signature published last differs from the live manifest, so the next
search's compare rebuilds the scope. Fact reading (load() / the glob) stays
outside the lock so a slow read never blocks refreshes of other scopes. The
startup full rebuild holds the lock for its single bulk install; scoped
refreshes wait for it, which is acceptable because before warm-up completes a
search would be rebuilding its scope anyway (DeerMem.warm_retrieval takes its
own _retrieval_lock only after rebuild() returns, so the two never nest).
Lock order: the publish lock is acquired with no storage lock held --
search_facts releases _cache_lock before calling rebuild_index, reload() leaves
its scope/file-lock block before rebuilding, and DeerMem._ensure_retrieval_scopes
holds only its outer RLock, which nothing inside rebuild_index acquires. Inside
the lock only the adapter's own lock and _cache_lock are taken, and both are
leaf locks that never call back into storage, so no cycle is possible.
Test: test_rebuild_publication_stays_ordered_with_row_replacement drives the
reviewer's interleaving with threading.Event ordering (red before the fix). The
pause harness now holds a refresh after its fact read, outside the publish
lock, instead of inside the adapter's rebuild, so the earlier fence tests keep
their semantics without holding the new lock while paused.
* fix(memory): read each manifest once per full rebuild and serve the local index on a compare read error
Two suggestion-level review items on the cross-replica retrieval re-sync.
- The full rebuild cached manifest signatures keyed by (manifest path, agent
name), but _scope_signature reads only the manifest file -- every agent
bucket of a user shares one memory.json -- so the startup scan stat+read+
parsed the same file once per bucket that has facts. The cache is now keyed
by the manifest path alone while synced signatures are still recorded per
(user, agent) scope: users manifest reads instead of users x agents.
- The per-search freshness compare put a shared-volume manifest read on every
search_facts call, and _load_memory_file raises MemoryStorageCorruption on
OSError and malformed JSON, so a transient network-filesystem read error or a
corrupt memory.json failed a search the Pod-local index could still answer.
The compare now catches OSError, ValueError and MemoryStorageCorruption: it
logs a warning naming the scope (traceback at debug), serves the current
local index for that scope and leaves the recorded synced signature
untouched so the next search compares again. It deliberately does not mark
the scope dirty: a scoped rebuild would hit the same failing read, forget the
scope's signature and fail the search path too.
* docs(changelog): resolve merge markers left in the Unreleased section
* fix(memory): forget a scope's index signature when an own delta cannot be proved compatible
Review finding on the cross-replica re-sync: skipping promotion left a newer
rebuild's signature trusted even after this dispatcher had overwritten its rows
with an older notification. On a warmed Pod A, an own PATCH of f2 commits r+1
and pauses after releasing the user lock, before its adapter upsert; Pod B
deletes f2 at r+2; a concurrent search on A detects the peer change, rebuilds
the scope without f2 and publishes r+2; the pending r+1 upsert then reinserts
the deleted fact, and the revision check merely skipped because the synced
revision was r+2 rather than the PATCH's previous r. The signature stayed equal
to the live manifest, so searches returned the deleted fact indefinitely. The
generation fence could not help: it only suppresses promotion, the stale
adapter mutation still happens.
Rule: after an own commit's notifications are applied, the mutated scope's
recorded signature may stay only when the delta is provably compatible with
the published snapshot -- the scope is synced at exactly previous_revision and
no rebuild published rows for it (or storage-wide) since the fence snapshot --
in which case it is promoted as before. In every other case where this
dispatcher mutated the adapter for the scope (synced at another revision,
newer or older; generation moved; not synced at all; or notifications without
a committed revision, i.e. migration and clear paths) the scope's signature is
forgotten so the next search's compare rebuilds it. The user's other scopes
were not mutated by this delta: they are promoted when provable and otherwise
left untouched. The decision runs under _cache_lock together with the
generation reads, so a rebuild publishing concurrently is either observed or
observes the forget; the failure path (dirty + forget) is unchanged.
Orders covered: the reviewer's (publish r+2, then the stale upsert) forgets and
the next search rebuilds without f2; the earlier fence order (publish between
adapter op and promotion) now forgets too, one redundant rebuild; no concurrent
rebuild promotes exactly as before.
Test: test_stale_own_delta_replayed_over_a_newer_snapshot_forgets_the_scope
holds the PATCH's adapter upsert after the user lock is released (new
pause_next_upsert hook on the test adapter), Event-ordered, red before the fix.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
581 lines
27 KiB
Python
581 lines
27 KiB
Python
"""DeerMem retrieval-index placement and cross-instance freshness.
|
|
|
|
Several Gateway instances (Kubernetes Pods) can share one ``storage_path`` on a
|
|
ReadWriteMany home volume. The derived SQLite FTS5 index used to be pinned to
|
|
``{storage_path}/.retrieval``: one WAL database that every instance opened over
|
|
the network filesystem, emptied and refilled at every start, and deleted from
|
|
under its peers on any instance's corruption recovery. An instance also never
|
|
learned that a peer had written a user's facts, so its index served stale
|
|
results. These tests pin ``memory.backend_config.retrieval_index_path`` and the
|
|
manifest-signature re-sync that makes a peer's write visible on the next search
|
|
without rebuilding a scope this process wrote itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
|
|
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
|
from deerflow.agents.memory.backends.deermem.deermem.core.retrieval import FTS5RetrievalAdapter, create_fts5_retrieval
|
|
from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage, MemoryStorageCorruption, create_empty_memory
|
|
|
|
INDEX_FILENAME = "memory-fts5.sqlite3"
|
|
SCOPE = {"userId": "alice", "agentName": "agent-a"}
|
|
|
|
|
|
def _fact(fact_id: str, content: str) -> dict:
|
|
return {
|
|
"id": fact_id,
|
|
"content": content,
|
|
"category": "context",
|
|
"confidence": 0.8,
|
|
"createdAt": "2026-10-01T00:00:00Z",
|
|
"source": {"type": "test", "threadId": None},
|
|
}
|
|
|
|
|
|
def _ids(results: list[dict]) -> list[str]:
|
|
return [item["fact"]["id"] for item in results]
|
|
|
|
|
|
def _instance(storage_root: Path, index_dir: Path) -> FileMemoryStorage:
|
|
"""One Gateway instance: shared canonical storage, its own derived index."""
|
|
index_dir.mkdir(parents=True, exist_ok=True)
|
|
return FileMemoryStorage(DeerMemConfig(storage_path=str(storage_root)), retrieval=FTS5RetrievalAdapter(index_dir / INDEX_FILENAME))
|
|
|
|
|
|
# ── retrieval_index_path ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_absolute_retrieval_index_path_places_the_index_outside_storage_path(tmp_path: Path) -> None:
|
|
storage_root = tmp_path / "home"
|
|
index_dir = tmp_path / "pod-local-index"
|
|
adapter = create_fts5_retrieval(DeerMemConfig(storage_path=str(storage_root), retrieval_index_path=str(index_dir)))
|
|
assert adapter is not None
|
|
try:
|
|
assert (index_dir / INDEX_FILENAME).is_file()
|
|
assert not (storage_root / ".retrieval").exists()
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_relative_retrieval_index_path_resolves_against_storage_path(tmp_path: Path) -> None:
|
|
adapter = create_fts5_retrieval(DeerMemConfig(storage_path=str(tmp_path), retrieval_index_path="index/local"))
|
|
assert adapter is not None
|
|
try:
|
|
assert (tmp_path / "index" / "local" / INDEX_FILENAME).is_file()
|
|
assert not (tmp_path / ".retrieval").exists()
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_default_retrieval_index_path_is_unchanged(tmp_path: Path) -> None:
|
|
adapter = create_fts5_retrieval(DeerMemConfig(storage_path=str(tmp_path)))
|
|
assert adapter is not None
|
|
try:
|
|
assert (tmp_path / ".retrieval" / INDEX_FILENAME).is_file()
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_corruption_recovery_touches_only_the_configured_index(tmp_path: Path) -> None:
|
|
"""A Pod recreating its own corrupt index must not delete files a peer holds open."""
|
|
storage_root = tmp_path / "home"
|
|
index_dir = tmp_path / "pod-local-index"
|
|
index_dir.mkdir()
|
|
(index_dir / INDEX_FILENAME).write_bytes(b"not a sqlite database")
|
|
shared_index = storage_root / ".retrieval" / INDEX_FILENAME
|
|
shared_index.parent.mkdir(parents=True)
|
|
shared_index.write_bytes(b"a peer's file, must survive")
|
|
|
|
adapter = create_fts5_retrieval(DeerMemConfig(storage_path=str(storage_root), retrieval_index_path=str(index_dir)))
|
|
|
|
assert adapter is not None
|
|
try:
|
|
assert shared_index.read_bytes() == b"a peer's file, must survive"
|
|
adapter.upsert(_fact("recovered", "recreated local index"), scope=SCOPE, path="")
|
|
assert _ids(adapter.search("recreated", scopes=[SCOPE], top_k=5, mode="fts5", filters=None)) == ["recovered"]
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
# ── cross-instance freshness ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_search_sees_facts_a_peer_instance_wrote(tmp_path: Path) -> None:
|
|
"""Two Pods, one storage_path, one Pod-local index each: a write lands in the other Pod's next search."""
|
|
storage_root = tmp_path / "home"
|
|
pod_a = _instance(storage_root, tmp_path / "index-a")
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
try:
|
|
assert pod_a.rebuild_index()["failed"] == 0 # startup warm-up on both Pods
|
|
assert pod_b.rebuild_index()["failed"] == 0
|
|
|
|
pod_a.upsert_fact(_fact("one", "alpha written on pod a"), user_id="alice", agent_name="agent-a")
|
|
assert _ids(pod_b.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
|
|
pod_b.upsert_fact(_fact("two", "beta written on pod b"), user_id="alice", agent_name="agent-a")
|
|
assert _ids(pod_a.search_facts("beta", scopes=[SCOPE])) == ["two"]
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_search_drops_facts_a_peer_instance_deleted(tmp_path: Path) -> None:
|
|
storage_root = tmp_path / "home"
|
|
pod_a = _instance(storage_root, tmp_path / "index-a")
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
try:
|
|
pod_a.upsert_fact(_fact("one", "alpha written on pod a"), user_id="alice", agent_name="agent-a")
|
|
assert pod_b.rebuild_index()["failed"] == 0
|
|
assert _ids(pod_b.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
|
|
pod_a.delete_fact("one", user_id="alice", agent_name="agent-a")
|
|
|
|
assert pod_b.search_facts("alpha", scopes=[SCOPE]) == []
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_own_writes_do_not_rebuild_a_scope_this_instance_indexed(tmp_path: Path) -> None:
|
|
"""Incremental notifications already cover this process's writes; only a peer's write costs a rebuild."""
|
|
storage_root = tmp_path / "home"
|
|
pod = _instance(storage_root, tmp_path / "index-a")
|
|
try:
|
|
pod.upsert_fact(_fact("one", "alpha indexed before warm-up"), user_id="alice", agent_name="agent-a")
|
|
assert pod.rebuild_index()["failed"] == 0
|
|
|
|
with patch.object(pod, "rebuild_index", wraps=pod.rebuild_index) as rebuild:
|
|
assert _ids(pod.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
pod.upsert_fact(_fact("two", "beta written here"), user_id="alice", agent_name="agent-a")
|
|
assert _ids(pod.search_facts("beta", scopes=[SCOPE])) == ["two"]
|
|
# Summary updates and writes to another agent bump the shared user manifest too.
|
|
summaries = create_empty_memory()
|
|
summaries["user"]["workContext"]["summary"] = "works on deployments"
|
|
assert pod.save(summaries, user_id="alice")
|
|
pod.upsert_fact(_fact("three", "gamma for another agent"), user_id="alice", agent_name="agent-b")
|
|
assert _ids(pod.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
pod.delete_fact("two", user_id="alice", agent_name="agent-a")
|
|
assert pod.search_facts("beta", scopes=[SCOPE]) == []
|
|
|
|
assert rebuild.call_count == 0
|
|
finally:
|
|
pod.close()
|
|
|
|
|
|
def test_peer_write_between_own_sync_and_own_write_still_rebuilds(tmp_path: Path) -> None:
|
|
"""An own write must not paper over a peer write that landed since this instance last synced."""
|
|
storage_root = tmp_path / "home"
|
|
pod_a = _instance(storage_root, tmp_path / "index-a")
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
try:
|
|
assert pod_a.rebuild_index()["failed"] == 0
|
|
assert pod_b.rebuild_index()["failed"] == 0
|
|
pod_a.upsert_fact(_fact("one", "alpha written on pod a"), user_id="alice", agent_name="agent-a")
|
|
pod_b.upsert_fact(_fact("two", "beta written on pod b"), user_id="alice", agent_name="agent-a")
|
|
|
|
assert set(_ids(pod_b.search_facts("alpha OR beta", scopes=[SCOPE], mode="fts5"))) == {"one", "two"}
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_deermem_instances_with_pod_local_indexes_see_each_other(tmp_path: Path) -> None:
|
|
storage_root = tmp_path / "home"
|
|
|
|
def pod(index_name: str) -> DeerMem:
|
|
return DeerMem(backend_config={"storage_path": str(storage_root), "retrieval_index_path": str(tmp_path / index_name), "token_counting": "char"})
|
|
|
|
pod_a = pod("index-a")
|
|
pod_b = pod("index-b")
|
|
try:
|
|
assert pod_a.warm_retrieval()
|
|
assert pod_b.warm_retrieval()
|
|
|
|
_, fact_id = pod_a.create_fact("pod a remembers the deployment region", user_id="alice")
|
|
assert [fact["id"] for fact in pod_b.search("deployment region", user_id="alice")] == [fact_id]
|
|
|
|
assert (tmp_path / "index-a" / INDEX_FILENAME).is_file()
|
|
assert (tmp_path / "index-b" / INDEX_FILENAME).is_file()
|
|
assert not (storage_root / ".retrieval").exists()
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
@pytest.mark.parametrize("no_op", ["unchanged_patch", "identical_save"])
|
|
def test_no_op_commit_after_a_peer_delete_does_not_mask_the_deletion(tmp_path: Path, no_op: str) -> None:
|
|
"""A commit that changes nothing must not advance the synced signature past a peer's write.
|
|
|
|
Pod A indexed f1/f2 at revision r; Pod B deleted f2 (r+1); A then applies a
|
|
supported update to f1 that turns out to be a no-op. The commit helper hands
|
|
back the unchanged manifest, so inferring "previous revision = r" from it
|
|
would mark A's stale index as in sync at r+1 and keep returning f2 forever.
|
|
"""
|
|
storage_root = tmp_path / "home"
|
|
pod_a = _instance(storage_root, tmp_path / "index-a")
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
try:
|
|
pod_a.apply_changes({"upserts": [_fact("f1", "alpha stays"), _fact("f2", "beta goes away")]}, user_id="alice", agent_name="agent-a")
|
|
assert pod_a.rebuild_index()["failed"] == 0
|
|
assert _ids(pod_a.search_facts("beta", scopes=[SCOPE])) == ["f2"]
|
|
|
|
pod_b.delete_fact("f2", user_id="alice", agent_name="agent-a")
|
|
|
|
if no_op == "unchanged_patch":
|
|
stored = pod_a.get_fact("f1", user_id="alice", agent_name="agent-a")
|
|
assert stored is not None
|
|
result = pod_a.upsert_fact(stored, user_id="alice", agent_name="agent-a", expected_fact_revision=stored["revision"])
|
|
assert result["upsertedFacts"] == [], "the unchanged-value patch must be a no-op commit"
|
|
else:
|
|
assert pod_a.save(pod_a.load("agent-a", user_id="alice"), "agent-a", user_id="alice")
|
|
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["f1"]
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_scoped_resync_indexes_every_fact_of_a_large_scope(tmp_path: Path) -> None:
|
|
"""The re-sync must read the whole scope, not the first page of 100 facts.
|
|
|
|
With 150 facts (max_facts allows up to 500) both Pods start complete. A
|
|
peer's summary-only save changes the manifest signature; A's next search
|
|
rebuilds the scope and must still hold all 150 facts afterwards.
|
|
"""
|
|
storage_root = tmp_path / "home"
|
|
config = DeerMemConfig(storage_path=str(storage_root), max_facts=200)
|
|
(tmp_path / "index-a").mkdir()
|
|
(tmp_path / "index-b").mkdir()
|
|
pod_a = FileMemoryStorage(config, retrieval=FTS5RetrievalAdapter(tmp_path / "index-a" / INDEX_FILENAME))
|
|
pod_b = FileMemoryStorage(config, retrieval=FTS5RetrievalAdapter(tmp_path / "index-b" / INDEX_FILENAME))
|
|
try:
|
|
facts = [_fact(f"fact-{index:03d}", f"zeta common memory number {index:03d} unique{index:03d}") for index in range(1, 151)]
|
|
pod_a.apply_changes({"upserts": facts}, user_id="alice", agent_name="agent-a")
|
|
assert pod_a.rebuild_index()["failed"] == 0
|
|
assert pod_b.rebuild_index()["failed"] == 0
|
|
assert _ids(pod_a.search_facts("unique150", scopes=[SCOPE])) == ["fact-150"]
|
|
|
|
summaries = create_empty_memory()
|
|
summaries["user"]["workContext"]["summary"] = "peer summary refresh"
|
|
assert pod_b.save(summaries, user_id="alice")
|
|
|
|
assert _ids(pod_a.search_facts("unique150", scopes=[SCOPE])) == ["fact-150"]
|
|
assert _ids(pod_a.search_facts("unique001", scopes=[SCOPE])) == ["fact-001"]
|
|
assert len(pod_a.search_facts("zeta", scopes=[SCOPE], top_k=200)) == 150
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
class _PausableAdapter:
|
|
"""FTS5 adapter whose next bulk install, next upsert and every remove can be held at a chosen point.
|
|
|
|
With ``pause_after_next_install`` armed, ``rebuild`` performs the real
|
|
install, signals ``installed`` and waits for ``allow_publish`` before
|
|
returning, which holds the owning rebuild between its row replacement and
|
|
its signature publication (one call; later rebuilds run unpaused).
|
|
With ``pause_next_upsert`` armed, ``upsert`` signals ``at_upsert`` and
|
|
waits for ``allow_upsert`` before the real upsert, which holds the owning
|
|
write after it released the user lock and before its adapter mutation.
|
|
``remove`` performs the real removal, signals ``removed`` and waits for
|
|
``allow_promote`` before returning, which holds the owning write after its
|
|
adapter notification and before its signature promotion.
|
|
"""
|
|
|
|
def __init__(self, inner: FTS5RetrievalAdapter) -> None:
|
|
self._inner = inner
|
|
self.pause_after_next_install = False
|
|
self.installed = threading.Event()
|
|
self.allow_publish = threading.Event()
|
|
self.pause_next_upsert = False
|
|
self.at_upsert = threading.Event()
|
|
self.allow_upsert = threading.Event()
|
|
self.removed = threading.Event()
|
|
self.allow_promote = threading.Event()
|
|
|
|
def rebuild(self, records, *, scopes):
|
|
self._inner.rebuild(records, scopes=scopes)
|
|
if self.pause_after_next_install:
|
|
self.pause_after_next_install = False
|
|
self.installed.set()
|
|
assert self.allow_publish.wait(10), "test orchestration stalled before the signature publication"
|
|
|
|
def upsert(self, fact, *, scope, path):
|
|
if self.pause_next_upsert:
|
|
self.pause_next_upsert = False
|
|
self.at_upsert.set()
|
|
assert self.allow_upsert.wait(10), "test orchestration stalled before the adapter upsert"
|
|
self._inner.upsert(fact, scope=scope, path=path)
|
|
|
|
def remove(self, fact_id, *, scope):
|
|
self._inner.remove(fact_id, scope=scope)
|
|
self.removed.set()
|
|
assert self.allow_promote.wait(10), "test orchestration stalled before the signature promotion"
|
|
|
|
def release(self) -> None:
|
|
self.allow_publish.set()
|
|
self.allow_upsert.set()
|
|
self.allow_promote.set()
|
|
|
|
def __getattr__(self, name: str):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
class _ReadHold:
|
|
"""Hold one scoped refresh after it has read the scope's facts and before it installs them.
|
|
|
|
The scoped ``rebuild_index`` captures the manifest signature, reads the
|
|
facts through ``load()`` and only then replaces the index rows, so holding
|
|
here models a refresh that carries a snapshot taken before later writes.
|
|
"""
|
|
|
|
def __init__(self, storage: FileMemoryStorage) -> None:
|
|
self.at_read = threading.Event()
|
|
self.allow_install = threading.Event()
|
|
self.armed = True
|
|
real_load = storage.load
|
|
|
|
def load(*args, **kwargs):
|
|
document = real_load(*args, **kwargs)
|
|
if self.armed:
|
|
self.armed = False
|
|
self.at_read.set()
|
|
assert self.allow_install.wait(10), "test orchestration stalled before the index install"
|
|
return document
|
|
|
|
storage.load = load # type: ignore[method-assign]
|
|
|
|
|
|
def _run(target: Callable[[], object], errors: list[BaseException]) -> threading.Thread:
|
|
def body() -> None:
|
|
try:
|
|
target()
|
|
except BaseException as exc: # noqa: BLE001 - surfaced through the errors list
|
|
errors.append(exc)
|
|
|
|
thread = threading.Thread(target=body, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def _join(thread: threading.Thread, errors: list[BaseException]) -> None:
|
|
thread.join(10)
|
|
assert not thread.is_alive(), "worker thread did not finish"
|
|
assert errors == []
|
|
|
|
|
|
def _racing_pods(tmp_path: Path) -> tuple[FileMemoryStorage, _PausableAdapter, FileMemoryStorage, _ReadHold]:
|
|
"""Warmed Pod A with a pausable index, peer Pod B, and A's next scoped refresh held after its fact read."""
|
|
storage_root = tmp_path / "home"
|
|
(tmp_path / "index-a").mkdir()
|
|
adapter = _PausableAdapter(FTS5RetrievalAdapter(tmp_path / "index-a" / INDEX_FILENAME))
|
|
pod_a = FileMemoryStorage(DeerMemConfig(storage_path=str(storage_root)), retrieval=adapter) # type: ignore[arg-type]
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
pod_a.apply_changes({"upserts": [_fact("f1", "alpha stays"), _fact("f2", "beta goes away")]}, user_id="alice", agent_name="agent-a")
|
|
assert pod_a.rebuild_index()["failed"] == 0
|
|
summaries = create_empty_memory()
|
|
summaries["user"]["workContext"]["summary"] = "peer summary refresh"
|
|
assert pod_b.save(summaries, user_id="alice") # r+1 on the shared manifest: A's next search re-syncs
|
|
return pod_a, adapter, pod_b, _ReadHold(pod_a)
|
|
|
|
|
|
def test_promotion_is_fenced_against_a_refresh_that_installs_in_between(tmp_path: Path) -> None:
|
|
"""Reviewer interleaving: refresh reads f1/f2, own delete removes f2, refresh installs, delete promotes.
|
|
|
|
The refresh captured the r+1 signature and the facts before the deletion
|
|
committed r+2. Its install resurrects f2; promoting the deletion's r+2
|
|
signature afterwards would declare that stale index in sync with the live
|
|
manifest and keep returning f2 until the next manifest change.
|
|
"""
|
|
pod_a, adapter, pod_b, hold = _racing_pods(tmp_path)
|
|
errors: list[BaseException] = []
|
|
try:
|
|
refresh = _run(lambda: pod_a.search_facts("alpha", scopes=[SCOPE]), errors)
|
|
assert hold.at_read.wait(10) # refresh holds f1/f2 read at r+1, not yet installed
|
|
|
|
deletion = _run(lambda: pod_a.delete_fact("f2", user_id="alice", agent_name="agent-a"), errors)
|
|
assert adapter.removed.wait(10) # r+2 committed, index row removed, promotion pending
|
|
|
|
hold.allow_install.set() # stale install resurrects f2 and records the r+1 signature
|
|
_join(refresh, errors)
|
|
adapter.allow_promote.set() # the deletion must not stamp that index as r+2
|
|
_join(deletion, errors)
|
|
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["f1"]
|
|
finally:
|
|
hold.allow_install.set()
|
|
adapter.release()
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_stale_refresh_installing_after_a_promotion_is_caught_by_the_next_search(tmp_path: Path) -> None:
|
|
"""Mirrored order: the promotion lands first, then a stale refresh installs f1/f2.
|
|
|
|
The stale refresh records the r+1 signature it captured, so the next search
|
|
sees it differ from the live r+2 manifest and rebuilds the scope.
|
|
"""
|
|
pod_a, adapter, pod_b, hold = _racing_pods(tmp_path)
|
|
errors: list[BaseException] = []
|
|
try:
|
|
stale_refresh = _run(lambda: pod_a.search_facts("alpha", scopes=[SCOPE]), errors)
|
|
assert hold.at_read.wait(10)
|
|
assert pod_a.rebuild_index([SCOPE])["failed"] == 0 # a second, unpaused refresh syncs the scope at r+1
|
|
|
|
adapter.allow_promote.set()
|
|
pod_a.delete_fact("f2", user_id="alice", agent_name="agent-a") # r+2: removes the row and promotes r+1 -> r+2
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
|
|
hold.allow_install.set() # the stale install resurrects f2 under its own r+1 signature
|
|
_join(stale_refresh, errors)
|
|
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["f1"]
|
|
finally:
|
|
hold.allow_install.set()
|
|
adapter.release()
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_rebuild_publication_stays_ordered_with_row_replacement(tmp_path: Path) -> None:
|
|
"""Reviewer interleaving: two refreshes install in one order and would publish in the other.
|
|
|
|
R1 captures r+1 with f1/f2 and is held before installing. A peer deletes f2
|
|
(r+2). R2 captures r+2, installs f1 only and is held before publishing. R1
|
|
then installs its stale f1/f2 and publishes r+1; R2 publishes r+2 last.
|
|
Without serializing install+publish, R2's live-matching signature certifies
|
|
R1's stale rows and the deleted f2 is returned until the manifest changes.
|
|
"""
|
|
pod_a, adapter, pod_b, hold = _racing_pods(tmp_path)
|
|
errors: list[BaseException] = []
|
|
try:
|
|
first = _run(lambda: pod_a.search_facts("alpha", scopes=[SCOPE]), errors)
|
|
assert hold.at_read.wait(10) # R1 holds f1/f2 read at r+1
|
|
|
|
pod_b.delete_fact("f2", user_id="alice", agent_name="agent-a") # r+2
|
|
|
|
adapter.pause_after_next_install = True
|
|
second = _run(lambda: pod_a.search_facts("alpha", scopes=[SCOPE]), errors)
|
|
assert adapter.installed.wait(10) # R2 installed f1 at r+2, publication pending
|
|
|
|
hold.allow_install.set() # R1 proceeds: with install+publish serialized it waits for R2; unfixed, it installs f1/f2 and publishes r+1 now
|
|
first.join(1.5)
|
|
adapter.allow_publish.set() # R2 publishes r+2
|
|
_join(second, errors)
|
|
_join(first, errors)
|
|
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["f1"]
|
|
finally:
|
|
hold.allow_install.set()
|
|
adapter.release()
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_full_rebuild_reads_each_manifest_once(tmp_path: Path) -> None:
|
|
"""Every agent bucket of a user shares one memory.json; the startup scan must not re-read it per bucket."""
|
|
pod = _instance(tmp_path / "home", tmp_path / "index-a")
|
|
try:
|
|
for agent in ("agent-a", "agent-b", "agent-c"):
|
|
pod.upsert_fact(_fact(f"{agent}-fact", f"fact for {agent}"), user_id="alice", agent_name=agent)
|
|
pod.upsert_fact(_fact("bob-fact", "fact for bob"), user_id="bob", agent_name="agent-a")
|
|
|
|
with patch.object(pod, "_load_memory_file", wraps=pod._load_memory_file) as manifest_reads:
|
|
result = pod.rebuild_index()
|
|
|
|
assert result == {"supported": True, "indexed": 4, "failed": 0}
|
|
read_paths = sorted({call.args[0] for call in manifest_reads.call_args_list})
|
|
assert read_paths == [pod._get_memory_file_path(user_id="alice"), pod._get_memory_file_path(user_id="bob")]
|
|
assert manifest_reads.call_count == 2, "one manifest read per user, not per agent bucket"
|
|
for agent in ("agent-a", "agent-b", "agent-c"):
|
|
assert _ids(pod.search_facts(f"{agent}", scopes=[{"userId": "alice", "agentName": agent}])) == [f"{agent}-fact"]
|
|
finally:
|
|
pod.close()
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [OSError("stale NFS handle"), MemoryStorageCorruption("malformed memory.json")])
|
|
def test_manifest_read_failure_during_the_freshness_compare_serves_the_local_index(tmp_path: Path, caplog: pytest.LogCaptureFixture, failure: Exception) -> None:
|
|
"""The Pod-local index exists to decouple search from the shared volume.
|
|
|
|
A transient read error or a corrupt manifest during the per-search compare
|
|
must log and serve the current index, leave the recorded signature alone
|
|
(no dirty mark: a rebuild would hit the same read), and let the next search
|
|
compare normally.
|
|
"""
|
|
storage_root = tmp_path / "home"
|
|
pod_a = _instance(storage_root, tmp_path / "index-a")
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
key = ("alice", "agent-a")
|
|
try:
|
|
pod_a.upsert_fact(_fact("one", "alpha indexed locally"), user_id="alice", agent_name="agent-a")
|
|
assert pod_a.rebuild_index()["failed"] == 0
|
|
synced_before = pod_a._retrieval_synced_signatures[key]
|
|
|
|
with patch.object(pod_a, "_load_memory_file", side_effect=failure), patch.object(pod_a, "rebuild_index", wraps=pod_a.rebuild_index) as rebuild, caplog.at_level(logging.WARNING):
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["one"]
|
|
|
|
assert rebuild.call_count == 0
|
|
warnings = [record for record in caplog.records if record.levelno == logging.WARNING and "agent-a" in record.getMessage()]
|
|
assert warnings, "the skipped compare must be visible to operators"
|
|
assert pod_a._retrieval_synced_signatures[key] == synced_before
|
|
assert key not in pod_a._retrieval_dirty_scopes
|
|
|
|
pod_b.upsert_fact(_fact("two", "beta written on pod b"), user_id="alice", agent_name="agent-a")
|
|
assert _ids(pod_a.search_facts("beta", scopes=[SCOPE])) == ["two"] # the compare works again and re-syncs
|
|
finally:
|
|
pod_a.close()
|
|
pod_b.close()
|
|
|
|
|
|
def test_stale_own_delta_replayed_over_a_newer_snapshot_forgets_the_scope(tmp_path: Path) -> None:
|
|
"""Reviewer interleaving: own PATCH commits r+1 and is held before its adapter upsert; peer deletes;
|
|
a search rebuilds without the fact and publishes r+2; the held upsert then reinserts the deleted fact.
|
|
|
|
The promotion rule cannot promote (the scope is synced at r+2, not at the
|
|
PATCH's previous r), but merely skipping would leave the r+2 signature
|
|
trusted over rows this delta just mutated. The scope's signature must be
|
|
forgotten so the next search rebuilds without the deleted fact.
|
|
"""
|
|
storage_root = tmp_path / "home"
|
|
(tmp_path / "index-a").mkdir()
|
|
adapter = _PausableAdapter(FTS5RetrievalAdapter(tmp_path / "index-a" / INDEX_FILENAME))
|
|
pod_a = FileMemoryStorage(DeerMemConfig(storage_path=str(storage_root)), retrieval=adapter) # type: ignore[arg-type]
|
|
pod_b = _instance(storage_root, tmp_path / "index-b")
|
|
errors: list[BaseException] = []
|
|
try:
|
|
pod_a.apply_changes({"upserts": [_fact("f1", "alpha stays"), _fact("f2", "beta version one")]}, user_id="alice", agent_name="agent-a")
|
|
assert pod_a.rebuild_index()["failed"] == 0 # warmed at r
|
|
stored = pod_a.get_fact("f2", user_id="alice", agent_name="agent-a")
|
|
assert stored is not None
|
|
|
|
adapter.pause_next_upsert = True
|
|
patch_thread = _run(lambda: pod_a.upsert_fact({**stored, "content": "beta version two"}, user_id="alice", agent_name="agent-a", expected_fact_revision=stored["revision"]), errors)
|
|
assert adapter.at_upsert.wait(10) # r+1 committed, user lock released, adapter upsert pending
|
|
|
|
pod_b.delete_fact("f2", user_id="alice", agent_name="agent-a") # r+2 on the shared manifest
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == [] # A re-syncs without f2 and publishes r+2
|
|
|
|
adapter.allow_upsert.set() # the stale r+1 delta reinserts the deleted fact
|
|
_join(patch_thread, errors)
|
|
|
|
assert pod_a.search_facts("beta", scopes=[SCOPE]) == []
|
|
assert _ids(pod_a.search_facts("alpha", scopes=[SCOPE])) == ["f1"]
|
|
finally:
|
|
adapter.release()
|
|
pod_a.close()
|
|
pod_b.close()
|