Skip to content

fix(memory): harden builtin sync and reindex semantics#55497

Closed
TSHOGX wants to merge 2 commits into
openclaw:mainfrom
TSHOGX:fix/memory-sync-reindex-hardening
Closed

fix(memory): harden builtin sync and reindex semantics#55497
TSHOGX wants to merge 2 commits into
openclaw:mainfrom
TSHOGX:fix/memory-sync-reindex-hardening

Conversation

@TSHOGX

@TSHOGX TSHOGX commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: builtin memory sync and reindex currently mix three different contracts behind the same fail-fast control flow: background incremental sync, targeted post-compaction refresh, and full reindex. That makes transient provider failures, per-file local failures, stale-row pruning, and rollback state restoration interact incorrectly, so one failure can either discard useful progress or leave retry/state semantics inconsistent.
  • Why it matters: background sync should preserve the last good searchable state and retry later, targeted refresh should act as a freshness barrier instead of silently succeeding, and full reindex must remain atomic even when some work completed before the run ultimately rolls back.
  • What changed: this PR splits those semantics explicitly in builtin memory indexing. It adds typed provider-vs-local sync errors, per-file SQLite savepoints for multi-table writes, immediate per-batch embedding-cache persistence, rollback state restoration for safe/unsafe full reindex, and stable file identity tracking so failed refreshes/renames do not prune the last good row by mistake.
  • What did NOT change (scope boundary): no new config surface for retry/rate-limit tuning, no checkpoint/resume design, no changes to QMD behavior, and no changes to remote async batch API contracts beyond preserving existing fallback/retry behavior.
  • AI-assisted: fully tested locally for the touched builtin memory paths, and verified against the current src/memory/* implementation plus targeted/manual repros.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Root Cause / Regression History (if applicable)

  • Root cause: builtin memory sync was relying on exception propagation to define behavior across multiple modes that actually need different contracts. syncMemoryFiles() / syncSessionFiles() decide not only whether a file is indexed, but also whether stale rows are pruned, whether targeted session refresh counts as complete, whether a full reindex may commit, and which dirty/session-dirty work must be restored after rollback. At the same time, indexFile() performs multi-table delete/rewrite work (files, chunks, vector rows, FTS rows) without a per-file atomic boundary, so "continue on error" is only safe if partial writes cannot leak through.
  • Missing detection / guardrail: there was no regression coverage locking in the distinction between background best-effort sync, targeted refresh, and atomic full reindex. There was also no regression test asserting that successful earlier embedding batches must survive a later failure, and no test covering rollback-state restoration for work that completed inside a reindex that later aborts.
  • Prior context (git blame, prior PR, issue, or refactor if known): this follows the same reliability thread as #1151 (atomic indexing), #10324 (multi-write paths lacking transaction wrappers), #10863 (dirty-state semantics), #25561 (mode-aware post-compaction session reindexing), #31961 (continue on embedding errors), and #42578 (per-batch cache persistence). It also extends the provider error taxonomy direction already accepted in #39377.
  • Why this regressed now: once per-file failures stop being immediate process-level failures, the implicit state machine becomes visible. Catching and recording failures changes pruning, commit eligibility, rollback, and retry semantics unless those rules are made explicit.
  • If unknown, what was ruled out: this is not limited to remote async batch mode; the reproduced failure happens on direct embedding with Batch: disabled. It is also not just a retry-pattern issue, because retry-only changes do not fix partial-write hazards, rollback-state loss, or targeted refresh false positives.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file:
    • src/memory/index.test.ts
    • src/memory/manager.atomic-reindex.test.ts
    • src/memory/manager.embedding-batches.test.ts
  • Scenario the test should lock in:
    • Background incremental sync keeps the last good memory/session rows when a single file hits a retryable local failure, marks that work dirty again, and continues indexing healthy files.
    • Targeted post-compaction session refresh fails explicitly when a requested file cannot be refreshed, instead of silently succeeding.
    • Safe and unsafe full reindex either commit completely or roll back completely, while restoring dirty/session-dirty retry state for work lost in the rollback.
    • Successful earlier embedding batches are written to cache before a later batch fails, so a retry reuses already-computed embeddings.
    • Failed refreshes caused by rename/replacement scenarios do not prune the last good indexed row.
  • Why this is the smallest reliable guardrail: the bug is in builtin memory sync control flow, SQLite write atomicity, and retry-state restoration. These are deterministic local behaviors that can be exercised with targeted tests without live providers, gateway orchestration, or full CLI E2E runs.
  • Existing test that already covers this (if any):
    • N/A. Existing atomic-reindex coverage only protected the old "keep the previous index on failure" invariant; it did not cover mode-aware sync semantics, rollback retry restoration, or per-batch cache durability.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

  • Background builtin memory sync is now safe best-effort for retryable single-file local failures: healthy files continue indexing, the last good searchable state is preserved for failed files, and failed work stays dirty for retry.
  • Targeted post-compaction session refresh now behaves like a freshness barrier: if the requested transcript refresh cannot complete cleanly, the caller gets an explicit failure instead of a false-success partial refresh.
  • Full builtin reindex remains atomic in both safe and test-only unsafe paths: either the rebuild commits completely, or it rolls back completely and re-queues rolled-back work for retry.
  • Successful embedding batches are now persisted to the embedding cache immediately after each batch succeeds, reducing repeated provider work after transient failures.
  • Failed refreshes caused by moved/renamed files now preserve the last good indexed row instead of pruning it as stale during the same sync.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22 + pnpm
  • Model/provider: OpenAI-compatible remote embeddings (provider: openai, model: BAAI/bge-m3)
  • Integration/channel (if any): CLI / builtin memory index
  • Relevant config (redacted):
    • memorySearch.provider: "openai"
    • memorySearch.model: "BAAI/bge-m3"
    • memorySearch.extraPaths: [<Obsidian vault>]
    • memorySearch.cache.enabled: true
    • memorySearch.remote.batch.enabled: false

Steps

  1. Use a large memorySearch.extraPaths corpus and a rate-limited remote embedding provider.
  2. Run openclaw memory index --force.
  3. Trigger a transient provider-side 429 during indexing.
  4. Check openclaw memory status.
  5. Re-run openclaw memory index.

Expected

  • A transient provider failure should not permanently waste successful earlier batch work.
  • Retryable per-file failures should not destroy the last good indexed state for unrelated healthy files.
  • Full reindex should only commit if the rebuild reaches a coherent final state; otherwise the previous committed index should remain available.
  • Follow-up indexing should converge instead of repeatedly restarting from zero useful progress.

Actual

Before this change:

  • openclaw memory index --force failed with a provider 429.
  • openclaw memory status showed Indexed: 0/2343 files · 0 chunks and an empty embedding cache.

After this change and rebuild:

  • openclaw memory index --force completed substantial work instead of failing the whole run at the first provider interruption.
  • openclaw memory status showed Indexed: 2331/2343 files · 9726 chunks and Embedding cache: enabled (8913 entries).
  • A follow-up openclaw memory index completed the remaining files to Indexed: 2343/2343 files · 10033 chunks.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

CLI before

❯ openclaw memory index --force

🦞 OpenClaw 2026.3.24 (4cb8dde)
   Powered by open source, sustained by spite and good documentation.

│
...
13:32:05+08:00 [memory] embeddings rate limited; retrying in 524ms
◇
Memory index failed (main): openai embeddings failed: 429 {"message":"Request was rejected due to rate limiting. Details: TPM limit reached.","data":null}

❯ openclaw memory status

🦞 OpenClaw 2026.3.24 (4cb8dde)
   Your personal assistant, minus the passive-aggressive calendar reminders.

Memory Search (main)
Provider: openai (requested: openai)
Model: BAAI/bge-m3
Sources: memory
Extra paths: ~/.../Notebook
Indexed: 0/2343 files · 0 chunks
Dirty: yes
Store: ~/.openclaw/memory/main.sqlite
Workspace: ~/.openclaw/workspace
By source:
  memory · 0/2343 files · 0 chunks
Vector: ready
Vector path: ~/openclaw/node_modules/sqlite-vec-darwin-arm64/vec0.dylib
FTS: ready
Embedding cache: enabled (0 entries)
Cache cap: 500000
Batch: disabled (failures 0/2)

CLI after rebuild with this patch

❯ openclaw memory index --force

🦞 OpenClaw 2026.3.24 (4cb8dde)
   I'm not saying your workflow is chaotic... I'm just bringing a linter and a helmet.

│
...
◇
Memory index updated (main).

❯ openclaw memory status

🦞 OpenClaw 2026.3.24 (4cb8dde)
   Your personal assistant, minus the passive-aggressive calendar reminders.

Memory Search (main)
Provider: openai (requested: openai)
Model: BAAI/bge-m3
Sources: memory
Extra paths: ~/.../Notebook
Indexed: 2331/2343 files · 9726 chunks
Dirty: no
Store: ~/.openclaw/memory/main.sqlite
Workspace: ~/.openclaw/workspace
By source:
  memory · 2331/2343 files · 9726 chunks
Vector: ready
Vector dims: 1024
Vector path: ~/openclaw/node_modules/sqlite-vec-darwin-arm64/vec0.dylib
FTS: ready
Embedding cache: enabled (8913 entries)
Cache cap: 500000
Batch: disabled (failures 0/2)

❯ openclaw memory index

Indexed: 2343/2343 files · 10033 chunks

Local targeted tests

pnpm test -- src/memory/manager.atomic-reindex.test.ts src/memory/index.test.ts src/memory/manager.embedding-batches.test.ts

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified openclaw memory index --force previously failed on a real rate-limited corpus with Indexed: 0/....

  • Verified the patched build completed substantial force-reindex work without discarding healthy-file progress.

  • Verified openclaw memory status showed substantial indexed file/chunk counts and a populated embedding cache after the patched run.

  • Verified a follow-up incremental openclaw memory index completed the remaining files.

  • Verified the new focused regression tests pass locally.

  • Edge cases checked:

    • retryable single-file local failures keep old searchable rows and remain dirty for retry
    • targeted post-compaction session refresh surfaces failure instead of silently partial-refreshing
    • safe full reindex rollback preserves the previous committed index and restores retry state
    • successful early embedding batches are reused after a later batch failure
    • failed rename/replacement refreshes do not prune the last good row
  • What you did not verify:

    • live provider matrix across Gemini / Voyage / local embeddings
    • async provider Batch API flows
    • multi-process concurrent indexing behavior

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly:
    • revert this PR
  • Files/config to restore:
    • src/memory/manager-sync-ops.ts
    • src/memory/manager-embedding-ops.ts
    • src/memory/internal.ts
    • src/memory/session-files.ts
    • src/memory/memory-schema.ts
  • Known bad symptoms reviewers should watch for:
    • silently skipped files without enough warning context
    • targeted session refreshes succeeding when they should fail
    • rolled-back reindex work not returning to dirty/session-dirty retry state
    • renamed memory/session files disappearing from search after a failed refresh

Risks and Mitigations

  • Risk: background sync now intentionally completes with some retryable local file failures instead of failing the whole run.
    • Mitigation: only retryable single-file local failures are skipped; provider failures still surface, DB/disk class failures still abort, and failed files remain dirty for retry with warning logs.
  • Risk: the new rollback-state merge could accidentally lose or duplicate dirty/session-dirty work across aborted reindexes.
    • Mitigation: focused atomic-reindex tests cover rolled-back memory and session work plus merge-with-live-state behavior.
  • Risk: adding identity_key expands the builtin memory schema and stale-prune logic.
    • Mitigation: the schema change is additive, existing rows backfill lazily, and tests lock in the specific rename/replacement failure case that motivated the change.
  • Risk: per-file savepoints add some SQLite overhead on heavy indexing runs.
    • Mitigation: savepoints are narrowly scoped to single-file write boundaries and are only used to guarantee consistency when best-effort continuation is allowed.

@TSHOGX
TSHOGX marked this pull request as ready for review March 27, 2026 02:40
@greptile-apps

greptile-apps Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens the three distinct memory-sync contracts (background best-effort incremental sync, targeted post-compaction session refresh, and atomic full reindex) that were previously sharing the same fail-fast control flow. The key behavioural improvements are: background sync now keeps the last good searchable state for retryable per-file local failures and marks only those files dirty for retry; targeted session refresh now surfaces an explicit failure instead of silently partial-succeeding; full reindex is now fully atomic via a typed MemorySyncError / ReindexVerdict pipeline with snapshotSyncState + restoreSyncState + restoreRetryStateAfterReindexRollback to recover retry state on rollback; per-file withIndexWriteSavepoint SQLite savepoints guard multi-table chunk/file writes; per-batch embedding cache persistence is now flushed immediately after each batch rather than at end-of-file; and a new identity_key column (dev+ino) prevents failed refreshes / renames from pruning the last good indexed row.\n\nKey changes by file:\n- manager-sync-ops.ts: Exports MemorySyncError, adds MemoryFileSyncResult / SessionFileSyncResult / ReindexVerdict types, refactors syncMemoryFiles and syncSessionFiles to return structured results instead of implicitly mutating state and throwing, adds rollback-state snapshot/restore helpers, and changes shouldSyncSessions to return this.sessionsDirty (was && sessionsDirtyFiles.size > 0) to enable retrying session-dirty state even when no specific files are tracked after a rollback.\n- manager-embedding-ops.ts: Wraps all DB write paths in withIndexWriteSavepoint; moves upsertEmbeddingCache inside the batch loop for per-batch durability; classifies all embedding errors as MemorySyncError with the appropriate kind.\n- internal.ts / session-files.ts / memory-schema.ts: Additive identity_key column and buildFileIdentityKey helper.\n- Tests: Replaces one regression test with five targeted atomic-reindex tests, adds a per-batch cache durability test, and splits the targeted-sync test into dirty-preservation and failure-surfacing variants.\n\nTwo issues were found:\n- withIndexWriteSavepoint has no type guard against async lambdas; passing one silently breaks savepoint semantics (P2 style risk, no current caller is affected).\n- After a provider error truncates a full session reindex mid-way, only the tasks that completed before runWithConcurrency stopped populate sessionsDirtyFiles; unprocessed files can be skipped on the next incremental sync if a session transcript changed during the failed reindex window (P2 edge case).

Confidence Score: 4/5

Safe to merge — the PR is a clear net improvement over the previous fail-fast behaviour, all changed paths have new targeted tests, and the two issues found are both P2 edge cases that do not affect the primary sync/reindex paths under normal operation.

The logic is well-reasoned, the new invariants are all covered by tests, and the schema change is additive with lazy backfill. The one real logic concern (unprocessed session files not entering sessionsDirtyFiles after a provider-error mid-reindex) requires a very specific concurrent timing window (session transcript written during a failed force-reindex), and the impact is limited to stale index entries until the user's next force reindex. The withIndexWriteSavepoint async risk has no current callers. Neither issue is a regression relative to the old behaviour. Score is 4 rather than 5 because the provider-error dirty-set gap is a concrete (if unlikely) correctness hole that is straightforward to close.

src/memory/manager-sync-ops.ts — specifically restoreRetryStateAfterReindexRollback and withIndexWriteSavepoint.

Important Files Changed

Filename Overview
src/memory/manager-sync-ops.ts Core of this PR — adds typed MemorySyncError, per-file savepoints, rollback-state restoration, identity-key-based stale-prune guards, and separates incremental vs. full-reindex commit semantics. Two issues found: withIndexWriteSavepoint has no type guard against accidental async callers, and partially-processed session files after a provider-error-interrupted reindex can escape the dirty-file retry set.
src/memory/manager-embedding-ops.ts Wraps all embedding-cache DB operations in asLocalSyncError, moves per-batch upsertEmbeddingCache inside the loop (with toCache.length = 0 flush) so successful batches are durable before the next batch runs, and wraps multi-table chunk/file writes in withIndexWriteSavepoint. Straightforward and correct.
src/memory/internal.ts Adds identityKey field to MemoryFileEntry and exports buildFileIdentityKey (dev:ino string). Additive, backward-compatible change; existing rows back-fill lazily via the schema migration path.
src/memory/memory-schema.ts Adds identity_key TEXT column to the files table (new schema + ensureColumn backfill) and a corresponding index. Additive and non-breaking for existing databases.
src/memory/session-files.ts Adds identityKey field to SessionFileEntry using the same buildFileIdentityKey helper. Minimal and consistent with the memory-file change.
src/memory/index.test.ts Splits the single targeted-sync test into two focused tests: one for dirty-session preservation and one for explicit failure surfacing on targeted refresh failures. Imports MemorySyncError and adds embedBatchOverride hook for future fault-injection.
src/memory/manager.atomic-reindex.test.ts Replaces the single regression test with five focused tests covering: safe reindex failure preservation, unsafe fast-mode rollback, memory dirty state after session-caused rollback, all session files re-queued after a partial rollback, and live dirty-state updates surviving a rollback. Good coverage of the new invariants.
src/memory/manager.embedding-batches.test.ts Adds a test verifying that successful batch embeddings cached before a later batch fails are reused on retry (only 1 extra provider call after the initial 2). Directly locks in the per-batch persistence fix.
src/memory/test-embeddings-mock.ts Adds ssrfPolicy to the OpenAI mock using buildRemoteBaseUrlPolicy, aligning the mock with the shape expected by the real remote HTTP layer.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/memory/manager-sync-ops.ts
Line: 340-356

Comment:
**`withIndexWriteSavepoint` silently breaks with async actions**

The method signature `protected withIndexWriteSavepoint<T>(action: () => T): T` accepts async lambdas without a type error — TypeScript infers `T = Promise<void>` — but the savepoint semantics are synchronous-only. When an async lambda is passed, `RELEASE SAVEPOINT` fires as soon as the `Promise` is returned (before any async writes complete), making the savepoint a no-op as a write boundary.

All current callers pass synchronous lambdas, so there is no current bug. But the absence of a compile-time guard means a future caller could accidentally pass `async () => { ... }` and get silently incorrect behaviour without any warning.

A minimal safeguard is a JSDoc comment. A stronger approach is a type-level constraint:

```ts
protected withIndexWriteSavepoint<T>(action: () => Exclude<T, Promise<unknown>>): T {
```

or at minimum:

```ts
/**
 * SYNC-ONLY: `action` must be a synchronous callback.
 * Passing an async lambda will release the savepoint before writes complete.
 */
protected withIndexWriteSavepoint<T>(action: () => T): T {
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/memory/manager-sync-ops.ts
Line: 948-966

Comment:
**Unprocessed session files may be silently skipped on the next incremental sync after a provider-error-interrupted full reindex**

When `runWithConcurrency` terminates early because a provider error is thrown mid-way through session sync, `syncedFiles` captures only the tasks that completed before the concurrency slot stopped. Session files that were never dispatched are in neither `syncedFiles` nor `failedFiles`.

In `restoreRetryStateAfterReindexRollback`, only `syncedFiles` and `failedFiles` are added to `sessionsDirtyFiles`:

```ts
this.markSessionFilesDirty(params.sessionResult.syncedFiles);
this.markSessionFilesDirty(params.sessionResult.failedFiles);
```

This leaves the unprocessed files absent from `sessionsDirtyFiles`. On the next incremental (non-forced) sync, `indexAll` is `false` (because `sessionsDirtyFiles.size > 0`), so only files present in `sessionsDirtyFiles` are processed — the unprocessed files are silently skipped.

For most cases this is harmless because the DB was rolled back and hash comparison would skip unchanged files anyway. However, if a session transcript was appended to (by a concurrent agent session) during the failed reindex window and was not among the processed files, it will remain stale in the index until the user runs another force reindex.

A conservative fix would be to clear `sessionsDirtyFiles` (letting `indexAll = true`) instead of populating it with partially-complete work when the reindex was aborted due to a provider error:

```ts
private restoreRetryStateAfterReindexRollback(params: {
  memoryReindexStarted?: boolean;
  sessionReindexStarted?: boolean;
  sessionResult?: SessionFileSyncResult;
}) {
  if (params.memoryReindexStarted) {
    this.dirty = true;
  }
  if (params.sessionReindexStarted) {
    this.sessionsDirty = true;
    // If reindex was aborted by a provider error, sessionResult.fatalProviderError is set.
    // In that case, we cannot trust the partial syncedFiles set to cover all dirty work,
    // so clear sessionsDirtyFiles so the next sync uses indexAll=true.
    if (params.sessionResult?.fatalProviderError) {
      this.sessionsDirtyFiles.clear();
      return;
    }
    this.markSessionFilesDirty(params.sessionResult?.syncedFiles);
    this.markSessionFilesDirty(params.sessionResult?.failedFiles);
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(memory): harden builtin sync and rei..." | Re-trigger Greptile

Comment thread src/memory/manager-sync-ops.ts
Comment thread src/memory/manager-sync-ops.ts
@TSHOGX

TSHOGX commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #59137.

The original branch targeted the pre-plugin memory layout under src/memory/*.
I rebuilt the remaining fix against the current extensions/memory-core/src/memory/*
implementation and carried forward the rollback/retry-state and embedding-cache
recovery work there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant