mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
fix(memory): reject duplicate facts inside the create critical section (#4599)
* fix(memory): reject duplicate facts inside the create critical section
memory_add's duplicate check ran outside the storage critical section
(the tools.py comment called this out): two concurrent tool calls for
the same user could both pass the check and both store the same fact.
Move authoritative duplicate rejection into
MemoryUpdater.create_memory_fact: the candidate's normalized content
key is checked against the fresh memory snapshot on every
revision-conflict retry, so the loser of a concurrent create reloads,
sees the winner's fact, and is rejected with ValueError("Duplicate
fact"). The tool-layer pre-check stays as a fast path.
The REST router now maps the duplicate ValueError to 409 with a clear
detail instead of the misleading "content cannot be empty" 400.
Tests: backend-level duplicate rejection, a simulated
concurrent-commit-during-conflict-retry race, and the router 409
mapping.
* fix(memory): retry legacy-path fact creation on save conflict
Wrap the legacy single-file save in the same bounded conflict-retry
loop as the apply_changes path: reload the fresh snapshot and re-run
_raise_if_duplicate_fact_content on every retry, so a concurrent
duplicate commit is rejected with ValueError("Duplicate fact") (tool
error / REST 409) instead of the generic OSError save failure
(REST 500). No-duplicate conflicts now retry and store instead of
failing the create.
Addresses review feedback on bytedance/deer-flow#4599.
This commit is contained in:
parent
f2e832330e
commit
72c9701410
@ -116,6 +116,8 @@ def _map_memory_fact_value_error(exc: ValueError) -> HTTPException:
|
|||||||
detail = "Invalid confidence value; must be between 0 and 1."
|
detail = "Invalid confidence value; must be between 0 and 1."
|
||||||
elif exc.args and exc.args[0] == "agent_name":
|
elif exc.args and exc.args[0] == "agent_name":
|
||||||
detail = "An agent name is required for fact operations; user-global memory stores summaries only."
|
detail = "An agent name is required for fact operations; user-global memory stores summaries only."
|
||||||
|
elif exc.args and exc.args[0] == "Duplicate fact":
|
||||||
|
return HTTPException(status_code=409, detail="A fact with the same content already exists.")
|
||||||
else:
|
else:
|
||||||
detail = "Memory fact content cannot be empty."
|
detail = "Memory fact content cannot be empty."
|
||||||
return HTTPException(status_code=400, detail=detail)
|
return HTTPException(status_code=400, detail=detail)
|
||||||
|
|||||||
@ -372,6 +372,20 @@ def _fact_content_key(content: Any) -> str | None:
|
|||||||
return stripped.casefold()
|
return stripped.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_duplicate_fact_content(memory_data: dict[str, Any], content_key: str | None) -> None:
|
||||||
|
"""Reject a candidate fact whose normalized content already exists.
|
||||||
|
|
||||||
|
Callers must invoke this against the freshest snapshot available inside
|
||||||
|
their read-check-write critical section (i.e. on every revision-conflict
|
||||||
|
retry), so two concurrent creators of the same content cannot both pass
|
||||||
|
the check and store duplicate facts."""
|
||||||
|
if content_key is None:
|
||||||
|
return
|
||||||
|
for fact in memory_data.get("facts", []):
|
||||||
|
if isinstance(fact, dict) and _fact_content_key(fact.get("content")) == content_key:
|
||||||
|
raise ValueError("Duplicate fact")
|
||||||
|
|
||||||
|
|
||||||
# ── Staleness review helpers ──────────────────────────────────────────────
|
# ── Staleness review helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@ -815,6 +829,13 @@ class MemoryUpdater:
|
|||||||
"added" status. This restores both the max_facts cap and the post-trim
|
"added" status. This restores both the max_facts cap and the post-trim
|
||||||
existence check (upstream's ``create_memory_fact_with_created_fact``),
|
existence check (upstream's ``create_memory_fact_with_created_fact``),
|
||||||
which the vendored copy had dropped together to avoid the dangling id.
|
which the vendored copy had dropped together to avoid the dangling id.
|
||||||
|
|
||||||
|
Duplicate rejection is enforced here (not only by callers): the
|
||||||
|
candidate's normalized content key is checked against the fresh
|
||||||
|
memory snapshot inside the revision-conflict retry loop of both
|
||||||
|
storage paths (apply_changes and legacy single-file save), so
|
||||||
|
concurrent creators cannot both store the same content. Raises
|
||||||
|
``ValueError("Duplicate fact")`` on a normalized-content match.
|
||||||
"""
|
"""
|
||||||
if agent_name is None:
|
if agent_name is None:
|
||||||
raise ValueError("agent_name")
|
raise ValueError("agent_name")
|
||||||
@ -823,6 +844,7 @@ class MemoryUpdater:
|
|||||||
raise ValueError("content")
|
raise ValueError("content")
|
||||||
normalized_category = category.strip() or "context"
|
normalized_category = category.strip() or "context"
|
||||||
validated_confidence = _validate_confidence(confidence)
|
validated_confidence = _validate_confidence(confidence)
|
||||||
|
candidate_key = _fact_content_key(normalized_content)
|
||||||
now = utc_now_iso_z()
|
now = utc_now_iso_z()
|
||||||
fact_id = f"fact_{uuid.uuid4().hex[:8]}"
|
fact_id = f"fact_{uuid.uuid4().hex[:8]}"
|
||||||
candidate = {
|
candidate = {
|
||||||
@ -836,6 +858,12 @@ class MemoryUpdater:
|
|||||||
if getattr(type(self._storage), "apply_changes", None) is not MemoryStorage.apply_changes:
|
if getattr(type(self._storage), "apply_changes", None) is not MemoryStorage.apply_changes:
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
memory_data = self.get_memory_data(agent_name, user_id=user_id) if attempt == 0 else self.reload_memory_data(agent_name, user_id=user_id)
|
memory_data = self.get_memory_data(agent_name, user_id=user_id) if attempt == 0 else self.reload_memory_data(agent_name, user_id=user_id)
|
||||||
|
# Duplicate rejection lives inside the conflict-retry loop so
|
||||||
|
# it is re-evaluated against the fresh snapshot after every
|
||||||
|
# revision conflict: two concurrent creators of the same
|
||||||
|
# content cannot both store it (the loser reloads, sees the
|
||||||
|
# winner's fact, and is rejected here).
|
||||||
|
_raise_if_duplicate_fact_content(memory_data, candidate_key)
|
||||||
updated_memory = dict(memory_data)
|
updated_memory = dict(memory_data)
|
||||||
updated_memory["facts"] = _trim_facts_to_max([*memory_data.get("facts", []), copy.deepcopy(candidate)], self._config.max_facts)
|
updated_memory["facts"] = _trim_facts_to_max([*memory_data.get("facts", []), copy.deepcopy(candidate)], self._config.max_facts)
|
||||||
kept_ids = {str(fact.get("id")) for fact in updated_memory["facts"]}
|
kept_ids = {str(fact.get("id")) for fact in updated_memory["facts"]}
|
||||||
@ -860,15 +888,24 @@ class MemoryUpdater:
|
|||||||
raise
|
raise
|
||||||
logger.info("Retrying capped fact creation from a fresh snapshot after a revision conflict")
|
logger.info("Retrying capped fact creation from a fresh snapshot after a revision conflict")
|
||||||
raise AssertionError("bounded create retry did not return or raise")
|
raise AssertionError("bounded create retry did not return or raise")
|
||||||
memory_data = self.get_memory_data(agent_name, user_id=user_id)
|
# Legacy single-file path: same duplicate-rejection contract as the
|
||||||
updated_memory = dict(memory_data)
|
# apply_changes path above. A revision-conflicted save (False) reloads
|
||||||
updated_memory["facts"] = _trim_facts_to_max([*memory_data.get("facts", []), candidate], self._config.max_facts)
|
# the fresh snapshot and re-runs the duplicate check, so a concurrent
|
||||||
if not self._save_memory_to_file(updated_memory, agent_name, user_id=user_id, expected_revision=int(memory_data.get("revision") or 0)):
|
# creator's commit is rejected with ValueError("Duplicate fact")
|
||||||
raise OSError("Failed to save memory data after creating fact")
|
# instead of surfacing as a generic save failure.
|
||||||
# If the cap evicted the just-added (lower-confidence) fact, signal via
|
for attempt in range(3):
|
||||||
# None so callers don't report a dangling id as "added".
|
memory_data = self.get_memory_data(agent_name, user_id=user_id) if attempt == 0 else self.reload_memory_data(agent_name, user_id=user_id)
|
||||||
stored = any(f.get("id") == fact_id for f in updated_memory["facts"])
|
_raise_if_duplicate_fact_content(memory_data, candidate_key)
|
||||||
return updated_memory, (fact_id if stored else None)
|
updated_memory = dict(memory_data)
|
||||||
|
updated_memory["facts"] = _trim_facts_to_max([*memory_data.get("facts", []), copy.deepcopy(candidate)], self._config.max_facts)
|
||||||
|
if self._save_memory_to_file(updated_memory, agent_name, user_id=user_id, expected_revision=int(memory_data.get("revision") or 0)):
|
||||||
|
# If the cap evicted the just-added (lower-confidence) fact,
|
||||||
|
# signal via None so callers don't report a dangling id as
|
||||||
|
# "added".
|
||||||
|
stored = any(f.get("id") == fact_id for f in updated_memory["facts"])
|
||||||
|
return updated_memory, (fact_id if stored else None)
|
||||||
|
logger.info("Retrying capped fact creation from a fresh snapshot after a revision conflict")
|
||||||
|
raise OSError("Failed to save memory data after creating fact")
|
||||||
|
|
||||||
def delete_memory_fact(self, fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def delete_memory_fact(self, fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Delete a fact by its id and persist the updated memory data."""
|
"""Delete a fact by its id and persist the updated memory data."""
|
||||||
|
|||||||
@ -118,9 +118,11 @@ def memory_add_tool(
|
|||||||
content_key = _memory_content_key(normalized_content)
|
content_key = _memory_content_key(normalized_content)
|
||||||
manager = get_memory_manager()
|
manager = get_memory_manager()
|
||||||
existing_facts = manager.get_memory(agent_name=agent_name, user_id=user_id).get("facts", [])
|
existing_facts = manager.get_memory(agent_name=agent_name, user_id=user_id).get("facts", [])
|
||||||
# Tool calls normally run one-at-a-time per user turn. If tool-mode
|
# Fast-path duplicate rejection to spare a write attempt in the common
|
||||||
# writing broadens to multiple concurrent calls for the same user,
|
# case. The authoritative check lives in the backend's create critical
|
||||||
# move duplicate rejection into the storage/update critical section.
|
# section (DeerMem re-checks against a fresh snapshot on every
|
||||||
|
# revision-conflict retry in create_memory_fact), so concurrent tool
|
||||||
|
# calls for the same user cannot both store the same content.
|
||||||
if any(_memory_content_key(str(fact.get("content", ""))) == content_key for fact in existing_facts):
|
if any(_memory_content_key(str(fact.get("content", ""))) == content_key for fact in existing_facts):
|
||||||
return json.dumps({"error": "Duplicate fact"})
|
return json.dumps({"error": "Duplicate fact"})
|
||||||
|
|
||||||
|
|||||||
@ -213,6 +213,20 @@ def test_create_memory_fact_route_maps_conflict_to_409() -> None:
|
|||||||
assert response.json()["detail"] == "Memory changed concurrently; reload and retry."
|
assert response.json()["detail"] == "Memory changed concurrently; reload and retry."
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_route_maps_duplicate_to_409() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
mock_mgr = MagicMock()
|
||||||
|
mock_mgr.create_fact.side_effect = ValueError("Duplicate fact")
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post("/api/memory/facts", json={"content": "fact"})
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "A fact with the same content already exists."
|
||||||
|
|
||||||
|
|
||||||
def test_get_memory_route_maps_corruption_to_stable_500() -> None:
|
def test_get_memory_route_maps_corruption_to_stable_500() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
|
|||||||
@ -5,7 +5,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
||||||
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import format_conversation_for_update
|
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import format_conversation_for_update
|
||||||
from deerflow.agents.memory.backends.deermem.deermem.core.storage import MemoryStorage
|
from deerflow.agents.memory.backends.deermem.deermem.core.storage import (
|
||||||
|
MemoryManifestRevisionConflict,
|
||||||
|
MemoryStorage,
|
||||||
|
)
|
||||||
from deerflow.agents.memory.backends.deermem.deermem.core.updater import (
|
from deerflow.agents.memory.backends.deermem.deermem.core.updater import (
|
||||||
MemoryUpdater,
|
MemoryUpdater,
|
||||||
_build_staleness_section,
|
_build_staleness_section,
|
||||||
@ -444,6 +447,154 @@ def test_create_memory_fact_rejects_invalid_confidence() -> None:
|
|||||||
raise AssertionError("Expected ValueError for invalid fact confidence")
|
raise AssertionError("Expected ValueError for invalid fact confidence")
|
||||||
|
|
||||||
|
|
||||||
|
class _ConcurrentCommitStorage(_MemoryStorage):
|
||||||
|
"""apply_changes stand-in that simulates a concurrent writer committing a
|
||||||
|
duplicate fact between the caller's snapshot read and its first apply.
|
||||||
|
|
||||||
|
The first apply commits ``concurrent_fact`` (as a winning concurrent
|
||||||
|
writer would) and then raises a manifest revision conflict, forcing the
|
||||||
|
caller into its conflict-retry path with a fresh snapshot. Later applies
|
||||||
|
succeed and persist the upserts so test assertions can observe what the
|
||||||
|
caller actually stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, concurrent_fact: dict[str, object], memory: dict[str, object] | None = None):
|
||||||
|
super().__init__(memory)
|
||||||
|
self._concurrent_fact = concurrent_fact
|
||||||
|
self._conflict_injected = False
|
||||||
|
|
||||||
|
def apply_changes(self, change_set, **scope): # noqa: ANN001, ANN201, ANN202 - test fake
|
||||||
|
if not self._conflict_injected:
|
||||||
|
self._conflict_injected = True
|
||||||
|
self.memory["facts"] = [*self.memory.get("facts", []), copy.deepcopy(self._concurrent_fact)]
|
||||||
|
self.memory["revision"] = int(self.memory.get("revision") or 0) + 1
|
||||||
|
raise MemoryManifestRevisionConflict("simulated concurrent commit")
|
||||||
|
self.memory["facts"] = [*self.memory.get("facts", []), *change_set.get("upserts", [])]
|
||||||
|
self.memory["revision"] = int(self.memory.get("revision") or 0) + 1
|
||||||
|
return {"complete": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_rejects_duplicate_content() -> None:
|
||||||
|
"""Backend-level dedup: create_memory_fact itself must reject content that
|
||||||
|
already exists (normalized), not just the tool layer's pre-check."""
|
||||||
|
existing = _make_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_existing",
|
||||||
|
"content": "User prefers dark mode",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
updater = _make_updater(memory=existing)
|
||||||
|
try:
|
||||||
|
updater.create_memory_fact(content=" user prefers DARK mode ", agent_name="researcher")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("Duplicate fact",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for duplicate fact content")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_rejects_duplicate_committed_during_conflict_retry() -> None:
|
||||||
|
"""Race regression: a concurrent writer commits the same content between
|
||||||
|
this caller's snapshot read and its first apply. After the revision
|
||||||
|
conflict, the retry must detect the duplicate from the fresh snapshot
|
||||||
|
instead of storing a second copy."""
|
||||||
|
concurrent_fact = {
|
||||||
|
"id": "fact_concurrent",
|
||||||
|
"content": "User prefers dark mode",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
storage = _ConcurrentCommitStorage(concurrent_fact=concurrent_fact)
|
||||||
|
updater = _make_updater(storage=storage)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updater.create_memory_fact(content="user prefers DARK mode", agent_name="researcher")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("Duplicate fact",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for duplicate fact content")
|
||||||
|
|
||||||
|
assert [fact["id"] for fact in storage.memory["facts"]] == ["fact_concurrent"]
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyConcurrentCommitStorage(_MemoryStorage):
|
||||||
|
"""Legacy-path stand-in (no apply_changes override) that simulates a
|
||||||
|
concurrent writer committing a fact between the caller's snapshot read
|
||||||
|
and its first save: the first save commits ``concurrent_fact`` and
|
||||||
|
returns False (revision conflict), later saves persist normally."""
|
||||||
|
|
||||||
|
def __init__(self, concurrent_fact: dict[str, object], memory: dict[str, object] | None = None):
|
||||||
|
super().__init__(memory)
|
||||||
|
self._concurrent_fact = concurrent_fact
|
||||||
|
self._conflict_injected = False
|
||||||
|
|
||||||
|
def save(self, memory_data, agent_name=None, *, user_id=None, expected_revision=None): # noqa: ANN001, ANN201, ANN202 - test fake
|
||||||
|
self.save_calls.append((agent_name, user_id, expected_revision))
|
||||||
|
if not self._conflict_injected:
|
||||||
|
self._conflict_injected = True
|
||||||
|
self.memory["facts"] = [*self.memory.get("facts", []), copy.deepcopy(self._concurrent_fact)]
|
||||||
|
self.memory["revision"] = int(self.memory.get("revision") or 0) + 1
|
||||||
|
return False
|
||||||
|
self.memory = memory_data
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_legacy_path_rejects_duplicate_committed_during_save_conflict() -> None:
|
||||||
|
"""Legacy single-file path race regression: a concurrent writer commits
|
||||||
|
the same content between this caller's snapshot read and its first save.
|
||||||
|
After the revision conflict, the retry must detect the duplicate from the
|
||||||
|
fresh snapshot and raise ValueError("Duplicate fact") instead of the
|
||||||
|
generic OSError save failure."""
|
||||||
|
concurrent_fact = {
|
||||||
|
"id": "fact_concurrent",
|
||||||
|
"content": "User prefers dark mode",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
storage = _LegacyConcurrentCommitStorage(concurrent_fact=concurrent_fact)
|
||||||
|
updater = _make_updater(storage=storage)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updater.create_memory_fact(content="user prefers DARK mode", agent_name="researcher")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("Duplicate fact",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for duplicate fact content")
|
||||||
|
|
||||||
|
assert [fact["id"] for fact in storage.memory["facts"]] == ["fact_concurrent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_legacy_path_retries_save_conflict_and_stores() -> None:
|
||||||
|
"""Legacy single-file path: a save conflict without a duplicate reloads
|
||||||
|
the fresh snapshot and retries instead of failing the create."""
|
||||||
|
concurrent_fact = {
|
||||||
|
"id": "fact_concurrent",
|
||||||
|
"content": "An unrelated concurrent fact",
|
||||||
|
"category": "context",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
storage = _LegacyConcurrentCommitStorage(concurrent_fact=concurrent_fact)
|
||||||
|
updater = _make_updater(storage=storage)
|
||||||
|
|
||||||
|
_, fact_id = updater.create_memory_fact(content="Brand new fact", agent_name="researcher")
|
||||||
|
|
||||||
|
assert fact_id is not None
|
||||||
|
assert len(storage.save_calls) == 2
|
||||||
|
assert [fact["id"] for fact in storage.memory["facts"]] == ["fact_concurrent", fact_id]
|
||||||
|
|
||||||
|
|
||||||
def test_delete_memory_fact_raises_for_unknown_id() -> None:
|
def test_delete_memory_fact_raises_for_unknown_id() -> None:
|
||||||
updater = _make_updater()
|
updater = _make_updater()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user