Repository navigation
fix(openclaw): invalidate cached memories on purge, correct and branch ops - #297
Conversation
…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>
|
Reviewed head Two medium-severity gaps remain in the cache invalidation fix:
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>
|
Re-reviewed
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>
|
Re-reviewed [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: Deterministic reproduction, verified for both correction methods:
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>
|
lgtm Re-reviewed 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. |
Summary
MemoriaClient.getMemoryserved 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, somemory_getcould return deleted or superseded content, or content from the branch that was active before a checkout.purgeMemory({topic})memoryIdform deleted entries, so a topic purge left every cached record in placecorrectByIdcorrectByQuerybranchCheckout/branchMerge/rollbackSnapshotdeleteMemoryEach 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.
parsePurgedCountregexes/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.tsfirst, to confirm they actually catch the bug: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 incache-invalidation.test.ts)client.ts(above), so they are real regressions rather than tautologiesdeleteMemorycontrol, cross-user isolation, and cache retention on failureFixes #268
🤖 Generated with Claude Code