Skip to content

fix(openclaw): invalidate cached memories on purge, correct and branch ops - #297

Merged
loveRhythm1990 merged 4 commits into
matrixorigin:mainfrom
loveRhythm1990:fix/openclaw-cache-invalidation
Oct 10, 2026
Merged

loveRhythm1990 merged 4 commits into
matrixorigin:mainfrom
loveRhythm1990:fix/openclaw-cache-invalidation

Conversation

@loveRhythm1990

Copy link
Copy Markdown
Collaborator

Summary

MemoriaClient.getMemory served cached records straight back with no revalidation, and the cache key is ${userId}::${memoryId} — no branch, no version. Operations that delete, supersede, or change which memories are visible never dropped the entries they invalidated, so memory_get could return deleted or superseded content, or content from the branch that was active before a checkout.

Path Before After
purgeMemory({topic}) only the memoryId form deleted entries, so a topic purge left every cached record in place drops the user's entries
correctById cached the new record but left the superseded one serving old content under its old id deletes the old id, caches the new record
correctByQuery superseded id not knowable from the response drops the user's entries
branchCheckout / branchMerge / rollbackSnapshot no invalidation at all drops the user's entries
deleteMemory already deleted its entry (the issue's control) unchanged

Each operation drops exactly what it can no longer vouch for: a single id where the id is known, otherwise every entry for that user. Other users' entries are untouched, and a failed operation throws before reaching invalidation, so the cache survives failures.

One deliberate choice: invalidation on topic purge is not gated on the purged count. parsePurgedCount regexes /Purged (\d+) memory/ over human-readable text and returns 0 for a JSON body — including the {purged: 1} body in the issue's own repro — so gating on it would have left the reported case broken. Over-invalidating costs a cache miss; under-invalidating is the bug.

Verification

The new tests were run against the unfixed client.ts first, to confirm they actually catch the bug:

× topic purge invalidates cached memories
× correctById stops serving the superseded record
× correctByQuery invalidates the user's cache
× branch checkout invalidates cached memories
× branch merge invalidates cached memories
× snapshot rollback invalidates cached memories
× invalidation does not leak across users
  Tests  7 failed | 3 passed (10)

The 3 that pass without the fix are the intended controls — deleteMemory (the issue's own passing control), purge-by-id leaving other ids cached, and a failed purge retaining the cache. With the fix, all 10 pass.

Test plan

  • npm test → 129 passed (119 baseline + 10 new in cache-invalidation.test.ts)
  • New tests confirmed failing on unmodified client.ts (above), so they are real regressions rather than tautologies
  • Covers all four paths from the issue, the deleteMemory control, cross-user isolation, and cache retention on failure

Fixes #268

🤖 Generated with Claude Code

…h ops

MemoriaClient.getMemory served cached records straight back with no
revalidation, and the cache key is `${userId}::${memoryId}` — no branch,
no version. Operations that delete, supersede or change which memories
are visible never dropped the entries they invalidated, so memory_get
could return deleted or superseded content, or content from the branch
that was active before a checkout:

- topic purge: only the memory_id form deleted cache entries, so a topic
  purge left every cached record in place
- correctById: cached the new record but left the superseded one serving
  its old content under its old id
- correctByQuery: the superseded id is not knowable from the response
- branchCheckout / branchMerge / rollbackSnapshot: no invalidation at all,
  and the key cannot distinguish branches or versions

Each of these now drops exactly what it can no longer vouch for: a single
id where the id is known, otherwise every entry for that user. Other
users' entries are untouched, and a failed operation throws before
reaching invalidation, so the cache survives failures. Invalidation on
topic purge is deliberately not gated on the purged count — that count
comes from a regex over human-readable text and reads 0 for a JSON body,
and over-invalidating only costs a cache miss.

Fixes matrixorigin#268

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

Reviewed head 3ab32d69d95c53216642958445057dadd7d0ea7e.

Two medium-severity gaps remain in the cache invalidation fix:

  1. [P2] Invalidate after deleting the active branch. branchDelete still leaves the cache untouched. The backend automatically switches the active branch to main when deleting it, so this has the same cache implications as checkout. Reproduction: cache memory m1 with branch-specific content, successfully delete that active branch, then call getMemory(m1). The client returns the old branch's content without fetching the main-branch record. Invalidate the user's cache on successful branch deletion and add a test covering the implicit switch to main.

  2. [P2] Prevent an in-flight read from repopulating the cache after invalidation. invalidateUserCache only removes entries already present, while asynchronous reads can subsequently call cacheMemories with an older response. I reproduced this by starting a retrieval, holding its response, completing a topic purge, and then releasing the pre-purge retrieval result. A later getMemory returns the purged memory from cache. The same ordering can restore records from a previous branch after checkout/rollback. Track a per-user cache generation: capture it before a cache-producing read, advance it when invalidating, and cache the response only if its generation is still current (or otherwise serialize these operations).

Validation: all 129 existing OpenClaw tests passed. Two additional deterministic regression tests failed on this head and reproduced the cases above using mocked backend responses, including a deferred response for the race. No live database was used.

Coverage: 2/2 changed files reviewed, including the complete new invalidation test file; 0 skipped (100%).

…population

Follow-up to review on this PR; both gaps reproduced before fixing.

branchDelete left the cache untouched, but the backend resets the active
branch to main when the deleted branch was the active one (covered by
test_active_branch_resets_on_delete in branch_e2e.rs), so it carries the
same cache implications as a checkout. The response does not say whether
the deleted branch was active, so it invalidates either way.

invalidateUserCache only removed entries that were already present, so a
read still in flight could call cacheMemories afterwards and write
pre-purge — or pre-checkout — records back in. Each user now has a cache
generation: a cache-producing read captures it before issuing its
request, invalidation advances it, and the response is only cached if the
generation is unchanged. Corrections still cache their replacement record
unconditionally, since they invalidate and re-cache synchronously around
their own await.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

Re-reviewed 3df6c64d51d276d4b3fef057a839e776b8db4498. The active-branch deletion fix and the original in-flight retrieval/topic-purge case now pass. The generation fix still has two medium-severity gaps:

  1. [P2] Advance the generation for ID-based invalidation too. In correctById, deleteMemory, and the memoryId purge path, entries are deleted directly without advancing cacheGeneration. A retrieval started before the mutation still has the current generation and can restore the superseded/deleted record. I reproduced this independently for all three operations: hold retrieval of m1 → complete correction/deletion/purge of m1 → release the old retrieval → getMemory(m1) returns the old active record from cache. Advance the user's generation after these successful mutations while retaining unrelated cached IDs, or use equivalent per-ID versioning.

  2. [P2] Apply the generation check to correction responses before caching them. Both correction methods still invoke cacheMemories without a generation, bypassing the new optional guard. Reproduction for both correctById and correctByQuery: a correction on the old branch creates m2, its response is delayed, checkout to main completes, then the old correction response arrives. getMemory(m2) serves the old-branch replacement even when main has no such record. Capture the generation before dispatching correction; invalidate appropriately on completion, but only cache its returned replacement if no intervening invalidation occurred. For correctByQuery, assess that condition before incrementing the generation for its own invalidation.

Validation: all 131 existing OpenClaw tests passed on this head. Five additional deterministic tests using deferred mocked HTTP responses failed: three ID-invalidation cases and two late-correction cases described above. No live database was used.

Coverage: 2/2 changed files reviewed, including all added tests; 0 skipped (100%).

Follow-up to the second review on this PR; all five reported cases
reproduced before fixing.

correctById, deleteMemory and the memoryId purge path deleted their
entries directly without advancing cacheGeneration, so a retrieval
dispatched before the mutation still held the current generation and
restored exactly the superseded or deleted record. They now go through
invalidateMemoryIds, which drops the named ids and bumps the generation
while leaving unrelated cached ids in place.

Both correction methods called cacheMemories with no generation, which
opted them out of the guard added in the previous commit. The reasoning
there — that corrections invalidate and re-cache synchronously — was
wrong: the await itself is the window, so a correction issued on one
branch could land after a checkout and cache its old-branch replacement.
Each now captures the generation before dispatching and only invalidates
and caches if no intervening invalidation occurred, assessed before the
method's own bump so it does not mistake that for an intervening one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

Re-reviewed 64ad2f88b5b31211b8b0a1b3fd35b528d673e228. The previous five concurrent-operation regressions now pass, but there is one remaining medium-severity finding introduced by the new correction guard:

[P2] Do not skip correction invalidation when an unrelated operation changes the generation. In correctById and correctByQuery, the generation check now gates both invalidation and caching. A generation change does not imply this user's cache is empty: invalidateMemoryIds deliberately preserves unrelated IDs.

Deterministic reproduction, verified for both correction methods:

  1. Retrieve/cache m1 with its old active content.
  2. Start a correction of m1 and hold its successful replacement response.
  3. Delete unrelated m9. This advances the user's generation but leaves cached m1 intact.
  4. Release the correction response. Its generation check fails, so it never invalidates m1.
  5. getMemory(m1) returns the superseded old active record from cache without consulting the backend.

Please keep the generation check around caching the returned replacement, while still invalidating the affected original ID (or user cache for query-based correction) and advancing the generation after every successful correction. Assess whether the response can be cached before that operation's own generation bump. This preserves the previous checkout-race fix while preventing unrelated mutations from suppressing necessary invalidation.

Validation: 136 PR tests plus the five independent regressions from my previous review passed (141 total). Two additional deferred-HTTP regression tests, one per correction method, failed and reproduced the ordering above. No live database was used.

Coverage: 2/2 changed files reviewed, including the complete added test file; 0 skipped (100%).

…validation

Follow-up to the third review on this PR; both reported orderings
reproduced before fixing.

The guard added in the previous commit gated a correction's invalidation
*and* its caching on the generation captured before the request. That
assumed a changed generation meant the user's cache had been emptied,
which is only true for a full invalidateUserCache — invalidateMemoryIds
deliberately keeps unrelated ids. So: cache m1, start correcting m1, hold
the response, delete unrelated m9 (bumps the generation, leaves m1
cached), release the correction — its check failed, it skipped
invalidation entirely, and getMemory(m1) kept serving the superseded
record.

A correction now always invalidates what it superseded (the original id,
or the user cache for the query form) and advances the generation. Only
caching the returned replacement stays conditional, still assessed before
the method's own bump. That keeps the checkout-race fix — a replacement
that lands after a checkout is still not cached — while an unrelated
mutation can no longer suppress a necessary invalidation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

lgtm

Re-reviewed bf687d2ca97c2258685f62ec7b9d66249cc8900c. The remaining correction invalidation finding is resolved: both correction methods now invalidate after every successful correction, while the captured-generation check gates only caching of the returned replacement. An unrelated ID deletion can no longer leave the superseded original cached, and delayed old-branch replacements are still prevented from entering the cache.

Validation: reran all 138 PR tests plus the seven independent concurrent-operation regressions from my previous reviews; all 145 passed. This includes both correction/unrelated-delete cases, all three ID-invalidation/read races, and both correction/checkout races. The concurrency checks use mocked deferred HTTP responses; no live database was used.

Coverage: 2/2 changed files reviewed, including the full added test file; 0 skipped (100%). No further blocking findings.

@loveRhythm1990
loveRhythm1990 merged commit 20fd3ca into matrixorigin:main Oct 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenClaw memory cache survives topic purge, correction, branch checkout and snapshot rollback

1 participant