bytedance/deer-flow · error · MemoryFactRevisionConflict

Expected fact {normalized['id']!r} revision {revision}, foun

Error message

Expected fact {normalized['id']!r} revision {revision}, found {existing_revision}

What it means

The classic optimistic-concurrency failure: the incoming fact carries revision R, but the stored fact is already at a different revision (another writer updated it first). Raised as MemoryFactRevisionConflict - the stored state is valid, your copy is just stale. The backend deliberately refuses blind last-writer-wins for facts.

Source

Thrown at backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py:238

        normalized["source"] = {"type": "unknown", "threadId": None}
    else:
        normalized["source"].setdefault("type", "unknown")
        if not isinstance(normalized["source"].get("type"), str):
            raise ValueError("fact.source.type must be a string")
        if normalized["source"].get("threadId") is not None and not isinstance(normalized["source"].get("threadId"), str):
            raise ValueError("fact.source.threadId must be a string or null")
    normalized["title"] = _fact_title(normalized)
    now = utc_now_iso_z()
    if existing is None:
        normalized.setdefault("createdAt", now)
        normalized.setdefault("updatedAt", normalized["createdAt"])
        normalized["revision"] = revision
    else:
        existing_revision = existing.get("revision")
        if not isinstance(existing_revision, int) or existing_revision < 1:
            raise MemoryStorageCorruption(f"Stored fact {normalized['id']!r} has an invalid revision")
        if revision != existing_revision:
            raise MemoryFactRevisionConflict(f"Expected fact {normalized['id']!r} revision {revision}, found {existing_revision}")
        normalized["createdAt"] = existing.get("createdAt") or normalized.get("createdAt") or now
        comparison_keys = {"revision", "updatedAt"}
        incoming_material = {key: value for key, value in normalized.items() if key not in comparison_keys}
        existing_material = {key: value for key, value in existing.items() if key not in comparison_keys}
        if incoming_material == existing_material:
            normalized["revision"] = existing_revision
            normalized["updatedAt"] = existing.get("updatedAt") or normalized["createdAt"]
        else:
            normalized["revision"] = existing_revision + 1
            normalized["updatedAt"] = now
    if not isinstance(normalized.get("createdAt"), str) or not isinstance(normalized.get("updatedAt"), str):
        raise ValueError("fact.createdAt and fact.updatedAt must be strings")
    if normalized["consolidatedFrom"]:
        normalized.setdefault("consolidatedAt", normalized["updatedAt"])
    return normalized


def _safe_relative_path(root: Path, relative: str, *, label: str) -> Path:

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Catch the conflict, re-read the fresh fact, re-apply your intended change onto it, and save with the new revision (read-modify-write retry).
  2. If the change is idempotent and the stored version already includes it (the backend no-ops when material is equal), treat the conflict as done.
  3. Serialize memory writes per fact (single writer or queue) if contention is hot.

Example fix

# before
fact = memory.get_fact(fid); fact["content"] = new_text; memory.save_fact(fact)  # raises on stale revision
# after
for _ in range(3):
    fact = memory.get_fact(fid)          # fresh copy, fresh revision
    fact["content"] = new_text
    try:
        memory.save_fact(fact); break
    except MemoryFactRevisionConflict:
        continue
Defensive patterns

Strategy: retry

Validate before calling

# before saving an update, confirm your copy is current
fresh = store.get(fact["id"])
if fresh is not None and fresh.get("revision") != fact.get("revision"):
    fact = {**fresh, **your_changes, "revision": fresh["revision"]}  # rebase before save

Try / catch

for attempt in range(3):
    try:
        store.save(fact)
        break
    except MemoryFactRevisionConflict:
        fresh = store.get(fact["id"])
        if fresh is None:
            break  # deleted elsewhere; nothing to update
        fact = {**fresh, **changes, "revision": fresh["revision"]}

Prevention

When it happens

Trigger: Two agents/processes read the same fact (both see revision 3), both modify it, both save: the second save sends revision 3 but the store is now at 4 and fails. Long-lived in-memory copies of a fact get saved after another session touched it.

Common situations: Concurrent sessions or subagents updating the same memory; retry logic that re-sends the original payload after an intervening successful write; caching fact objects across requests.

Related errors


AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14). Data as JSON: /api/errors/d2121a798e33672f. Report an issue: GitHub.