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
- Store grows →
state::get gets slower
- Manifest read hits the 180 s invocation timeout
- Cleanup skipped → one more generation orphaned permanently
- 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:
- Grow the store until
state::get on the manifest key approaches the invocation timeout (~3,000 observations with vectors + graph did it here).
- Trigger an index save.
- Observe
manifest read failed in the log.
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
- 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.
- Add a startup reconciliation pass. One sweep at boot would bound the damage to a single generation regardless of how many past failures occurred.
- 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.
- 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
Package:
@agentmemory/agentmemory0.9.28iii 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 observationsSummary
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 latersave()only ever looks at its ownprevious, 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):
mem:index:*mem:graph:*mem:auditmem:obs:*(2,888 observations)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):
GET /agentmemory/healthPOST /agentmemory/searchPOST /agentmemory/smart-searchThe server has now been up 45+ hours with no restarts and no degradation.
Root cause
dist/index.mjs, end ofIndexPersistence.save():previouscomes from reading the manifest at the start ofsave(). That read fails under load — from my logs:When that happens
previousis nullish, theifis 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_cleanupaudit records against 3 logged manifest-read failures, which is consistent.Why it compounds
state::getgets slowerEach 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:
state::geton the manifest key approaches the invocation timeout (~3,000 observations with vectors + graph did it here).manifest read failedin the log.ls ~/data/state_store.db | grep 'mem%3Aindex%3A'— the previous generation's shards are still present and are not referenced bymem%3Aindex%3Abm25.bin.Suggested fixes
previous. When the manifest read fails, don't silently skip. Enumerate themem:index:bm25scope and delete anyidx_*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.manifest read failedis logged atwarn, 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:
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:*andmem:auditare regenerated and can be dropped safely (dropping the 85 MB graph is what tooksmart-searchfrom 44.5 s to 0.82 s).Related
iiiworker threads busy-wait, growing to a full core over ~40h uptime #1093 (open) — idle CPU growth over ~40 h. Looks similar from the outside but is a different layer: that one isiiiworker threads busy-waiting and clears on restart, whereas this one is the Node wrapper and survives restarts because the orphans are on disk.