crewAIInc / crewAIInc/crewAI

Memory.forget() races pending background saves from remember_many(): forgotten content is resurrected after drain_writes()

Open
#7,290 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
58.8k
Forks
8.5k
Avg merge
1d 15h
Merged PRs (30d)
109

Description

Description

Memory.forget() can return while a pending background save (submitted by remember_many()) still holds the very content being forgotten. When the background save lands afterwards, the "forgotten" content is resurrected and recall() returns it. forget() neither drains the pending-save queue nor takes _reset_lock, although the sibling destructive methods already do exactly that:

  • recall() documents a read barrier and calls drain_writes() (unified_memory.py, recall — "Read barrier: wait for any pending background saves to finish").
  • reset() / reset_all() hold _reset_lock and call drain_writes() before deleting.
  • forget() (unified_memory.py:818) does neither; update() doesn't either.

The write path is genuinely asynchronous: remember_many() submits _background_encode_batch to a single-worker executor (_submit_save), and the docstring only promises a barrier for subsequent recall() calls. A caller that calls forget() immediately after remember_many() — the natural "correct the mistake" pattern — gets a return value of 0 ("nothing deleted") while the doomed write commits later, with no error and no way to observe the race.

Environment
  • crewAI 1.15.20, pinned revision 143e902178a07d0f13f9db2308f983aaacaaf0f8 (editable install of lib/crewai)
  • Memory + LanceDBStorage in a temporary directory; deterministic embedder fixture; no LLM is constructed (shallow recall, explicit scope/categories/metadata). No network.
Steps to Reproduce

Deterministic reproduction: the embedder blocks on a threading.Event while the background save is between submit and storage-write, so the interleaving is forced rather than lucky. Core schedule (full runnable script inline below):

from crewai.memory.storage.lancedb_storage import LanceDBStorage
from crewai.memory.unified_memory import Memory

memory = Memory(storage=LanceDBStorage(path=..., vector_dim=16), embedder=gated_embedder, llm="unused")

# 1. Background save submitted; the gated embedder holds it before the storage write.
memory.remember_many(
    ["Project Falcon ships on Friday. VG_FORGOTTEN_CANARY"],
    scope="/crew", categories=["projects"], metadata={"marker": "canary"},
    importance=0.5, source="s1",
)

# 2. Forget the same content while the save is still pending.
print(memory.forget(scope="/crew", metadata_filter={"marker": "canary"}))  # -> 0

# 3. Release the embedder; the background save now commits the "forgotten" content.
gated_embedder.gate.set()
memory.drain_writes()

# 4. Recall returns the forgotten content.
matches = memory.recall("Falcon", scope="/crew", depth="shallow", source="s1")
print([m.record.content for m in matches])
# ['Project Falcon ships on Friday. VG_FORGOTTEN_CANARY']

Observed output (pinned revision):

forget() returned:              0
recall after drain_writes():    ['Project Falcon ships on Friday. VG_FORGOTTEN_CANARY']

control A (sync remember -> forget):  forget returns 1; content is gone          # forget itself works
control B (remember_many -> reset):   recall returns []                          # reset() drains the queue
Expected Behavior

A destructive operation should observe the same write-queue discipline reset() already implements: forget() (and update()) should drain pending saves — or at least delete-then-drain-then-re-delete — before returning, so that a completed forget() call means the content can no longer appear in any later recall().

Suggested Fix

Add self.drain_writes() (and the _reset_lock mutual exclusion used by reset()) at the start of forget() and update(), mirroring the reset() implementation. Cost is bounded by the existing single-worker save pool.

Found during a correctness study of agent-memory lifecycle operations (delete/update vs. async derivation ordering). Happy to provide the full two-document schedule with per-step assertions.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/crewai/memory/unified_memory.py, especially forget(), update(), reset(), reset_all(), recall(), and the remember_many() save path. Run the deterministic gated-embedder reproduction from the issue, then verify that destructive operations synchronize pending saves and that recall after drain_writes() cannot return forgotten content.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.