Skip to content

Orphaned index generations accumulate when the previous manifest read fails — store grows until the server pegs a core while idle #1115

Description

@KEONHEEJ

Package: @agentmemory/agentmemory 0.9.28
iii runtime: 0.11.2
Host: macOS 26.5.2, arm64, Node v24.16.0, launchd-managed (not Docker)
Store: file-based KV, ~/data/state_store.db, ~3,000 observations

Summary

IndexPersistence.save() cleans up the previous generation's shards only when it successfully read the previous manifest. When that read fails — which happens exactly when the store is under load — the cleanup is skipped entirely and that generation's shards are orphaned forever: every later save() only ever looks at its own previous, so nothing ever revisits older orphans.

Orphans are monotonically accumulating, and they feed back into the failure that creates them.

In my install this reached 8 index generations / 140 MB of shards, of which only 2 generations (37 MB) were live. The server then sat at 110–150% CPU while completely idle with every HTTP route timing out.

Evidence

Storage breakdown before cleanup (338 MB total):

Key prefix Size Kind
mem:index:* 140 MB, 77 files, 8 generations derived (only 2 gens live)
mem:graph:* 85 MB derived
mem:audit 42 MB log
mem:obs:* (2,888 observations) 66 MB real data

So 79% of the store was derived data, most of it unreachable.

The manifest names the live generations explicitly:

{"data:manifest":    {"generation": "idx_mrzxkql0_d41f7623ee4f", "shards": [...]},
 "vectors:manifest": {"generation": "idx_mrzxkrwf_8fe2c54a3dc8", "shards": [...]}}

The other six idx_* generations were referenced by nothing.

Effect of deleting the orphans + graph + audit (observations untouched):

Metric Before After
Idle CPU (no requests in flight) 110–150% 0–3%
Wrapper RSS 1.4–2.7 GB, oscillating 93–250 MB
GET /agentmemory/health 8–25 s, often timeout 6 ms
POST /agentmemory/search 20 s timeout 14 ms
POST /agentmemory/smart-search 44.5 s 0.82 s
Store size 338 MB 109 MB

The server has now been up 45+ hours with no restarts and no degradation.

Root cause

dist/index.mjs, end of IndexPersistence.save():

await this.deleteKey(KV.bm25Index, legacyKey, "legacy_cleanup");
if (previous?.v === 1 && Array.isArray(previous.shards)) {
  const currentShardIds = new Set(shards.map((shard) => `${shard.scope}\0${shard.key}`));
  for (const shard of previous.shards) {
    if (currentShardIds.has(`${shard.scope}\0${shard.key}`)) continue;
    await this.deleteShards([shard], "previous_generation_cleanup");
  }
}

previous comes from reading the manifest at the start of save(). That read fails under load — from my logs:

[agentmemory] warn index persistence: BM25 manifest read failed
  {"scope":"mem:index:bm25","key":"data:manifest","message":"Invocation timeout after 180000ms: state::get"}
[agentmemory] warn index persistence: vector manifest read failed
  {"scope":"mem:index:bm25","key":"vectors:manifest","message":"Invocation timeout after 180000ms: state::get"}

When that happens previous is nullish, the if is skipped, and the shards written by the preceding generation are never deleted by anyone. There is no reconciliation pass that would find them later.

I found zero previous_generation_cleanup audit records against 3 logged manifest-read failures, which is consistent.

Why it compounds

  1. Store grows → state::get gets slower
  2. Manifest read hits the 180 s invocation timeout
  3. Cleanup skipped → one more generation orphaned permanently
  4. Store grows further → go to 1

Each failure is permanent, so the floor only ever rises. This also explains why the symptom appears suddenly after weeks of fine operation: it is not linear, it is ratcheting.

A restart makes it worse, because a fresh save() mints yet another generation — so debugging by restarting is actively counterproductive. During one incident I restarted eight times and ended up with eight generations.

Reproduction

Any deployment where a manifest read times out at least once. To force it:

  1. Grow the store until state::get on the manifest key approaches the invocation timeout (~3,000 observations with vectors + graph did it here).
  2. Trigger an index save.
  3. Observe manifest read failed in the log.
  4. ls ~/data/state_store.db | grep 'mem%3Aindex%3A' — the previous generation's shards are still present and are not referenced by mem%3Aindex%3Abm25.bin.

Suggested fixes

  1. Reconcile instead of relying on previous. When the manifest read fails, don't silently skip. Enumerate the mem:index:bm25 scope and delete any idx_* shard whose generation is not named by the freshly published manifest. The manifest is the authority and it is written before this step, so a scan-based sweep is safe and self-correcting.
  2. Add a startup reconciliation pass. One sweep at boot would bound the damage to a single generation regardless of how many past failures occurred.
  3. Reconsider the 180 s invocation timeout for manifest reads. Three minutes of a blocked read on the single-threaded runtime is itself an availability problem; a short timeout plus explicit retry would fail faster and more visibly.
  4. Surface the skip. manifest read failed is logged at warn, but the consequence (cleanup skipped, generation orphaned) isn't stated. Making it explicit would have saved a lot of diagnosis time.

Happy to open a PR for (1) + (2) if the approach sounds right.

Workaround for anyone hitting this

Stop the server, back the store up, then keep only the generations the manifest names:

# 1. read the live generations
python3 -c "
import re;print('\n'.join(sorted(set(re.findall(r'idx_[a-z0-9]+_[a-f0-9]+',
  open('~/data/state_store.db/mem%3Aindex%3Abm25.bin',encoding='utf8',errors='replace').read())))))"
# 2. delete every mem%3Aindex%3A* shard whose generation is not in that list

⚠️ Do not delete mem%3Aindex%3A* wholesale — the index is not rebuilt automatically. I tried; the server came up healthy and fast, and every query returned 0 results while all 2,888 observations sat there intact. Keeping the manifest plus the two live generations restored search completely.

mem:graph:* and mem:audit are regenerated and can be dropped safely (dropping the 85 MB graph is what took smart-search from 44.5 s to 0.82 s).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions