Repository navigation
Bug: no path to rebuild or reset a >25K legacy graph — snapshot-rebuild + graph-reset both heartbeat-crash on state::list (follow-up to #816) #825
Description
The following was generated with the help of Claude Code, based on hands-on testing against a large corpus. Apologies for any AI-slop — please push back on anything that reads wrong.
Follow-up to #814 / #816. The merged #816 fix resolves the original #814 scope completely — /graph/query and /graph/stats now degrade gracefully (snapshot-only hot path, 200 + warning envelope, ~0.002s, zero worker reconnects across 10 calls verified on a ~75K-node corpus). Thank you — that was the crash that made the daemon unusable at scale, and it's gone.
This issue tracks the one piece that #816 surfaced but doesn't resolve: there is currently no working path to rebuild OR reset a graph on a >25K legacy (pre-#814) corpus — both write-side operations crash the worker.
Repro (against 680 sessions / ~75K graph nodes / 344 MB)
Build at b494f23 (the merged head), fresh boot, no viewer, worker confirmed healthy first:
POST /agentmemory/graph/snapshot-rebuild -> 500 {"error":"Invocation stopped"} in 0.357s ; worker reconnect +1
POST /agentmemory/graph/reset -> 500 {"error":"Invocation stopped"} in 0.337s ; worker reconnect +1
Both die at ~0.35s (far under the 6s budget). The budget timer does eventually log enumeration: exceeded 6000ms budget, but only after the worker already reconnected — iii's heartbeat deadline (~0.35s observed) is much shorter than the budget, so a synchronous enumeration starves the heartbeat before Promise.race can resolve.
Root cause: one shared primitive
StateKV exposes only get / set / update / delete / list. There's no clear-scope, no cursor/pagination on state::list, and no key-only keys variant. So every operation that must touch all existing keys is forced through state::list, which returns the whole scope as one array and lands a multi-MB payload that JSON.parse chews synchronously, starving the worker heartbeat.
That makes these the same bug:
- rebuild — needs every node to compute top-degree →
state::list - reset — needs every key to delete it (no
clear-scope) →state::list - seeding an incremental key-index for a legacy corpus — can't be done without one
state::listto bootstrap it
So a pre-#814 corpus hits a chicken-and-egg: the incremental snapshot/index architecture (which is correct) can't be seeded without the one call that crashes. The hot path is safe precisely because it never seeds — it serves whatever snapshot exists. The write side can't avoid the seed. Net effect: a large legacy graph is stuck at empty-snapshot-forever — stable, but unrecoverable.
The REBUILD_SAFE_NODE_CEILING = 25000 guard doesn't catch this because it reads snapshot.totalNodes, and a legacy corpus has no snapshot yet — so totalNodes is 0/unknown, the guard passes, and the full kv.list runs anyway.
Same latent bug elsewhere
src/functions/migrate.ts does kv.list<Memory>(KV.memories). On a corpus with tens of thousands of memories, a migration would heartbeat-crash the same way. (We only have 131 memories, so it's fine for us — flagging it because it's the same primitive.)
Possible directions
- Engine-level (clean fix): a paginated/streaming
state::list, a key-onlykeys, or aclear-scopeprimitive — any of which lets rebuild/reset walk in yielding batches so the heartbeat survives. I realize this is upstream of this repo and tangled with the v0.11.2 iii pin, so flagging as context. - Graceful-refuse (band-aid): when size can't be determined cheaply (no snapshot, no counter), refuse rebuild/reset with a clean 400 instead of attempting the crash-prone enumeration — converts a daemon-killing footgun into an actionable error. Doesn't restore the graph, but stops the crash.
- Off-worker migration: a standalone client-mode script that does the one expensive enumeration outside the worker-heartbeat path (unproven — no client-mode pattern exists in the repo today).
Standing offer
We have the >50K repro that's hard to synthesize (680 sessions / ~75K nodes / 344 MB), a backup, and a one-command rebuild-and-test loop pointed at it. Push any branch and we'll report rebuild/reset/hot-path behavior + worker-reconnect deltas against the real corpus, usually within the hour.
Environment
- agentmemory
b494f23(merged fix(graph): top-degree snapshot cache for /graph/query + /graph/stats (#814) #816 head), run vianode dist/cli.mjs - macOS Darwin 25.2.0 arm64, Node 26 (Homebrew), iii-engine pinned v0.11.2
- LLM/embeddings: OpenRouter (
gemini-2.5-flash-lite+openai/text-embedding-3-small) - Data fully intact throughout testing (131/133 memories, 685 sessions, recall + capture working)
Activity
fix(graph,search): cr followups for #843/#825/#817 wave
fix(release): v0.9.28 hotfix wave (#843, #825, #817, iii pin) (#849)
allandelmare commentedon Jun 9, 2026
Upgraded to v0.9.27 (the released build, not a branch) and re-ran against the 680-session / ~75K-node / 344 MB corpus. Fresh backup first; data intact throughout (131/133 memories, 686 sessions).
✅
graph/reset— fixed, confirmed enumeration-freeFast, no
kv.list, no worker death. Exactly as intended.Also confirmed while I was here: the #816 hot path holds under a real browser — left the viewer dashboard open and idle for 12s, 0 worker reconnects. The continuous churn that originally made the daemon unusable is gone. And #843 (data loss on stop/restart) verified fixed — clean
stopshowed[flushing state], restart came back with identical counts. Nice.❌
snapshot-rebuild— graceful-refusal guard doesn't fire on a legacy corpusThe release notes say rebuild "refuses pre-flight when no prior snapshot exists." On a real >25K legacy corpus it still hard-crashes the worker:
Root cause, from the installed
dist/src-MOg2zKJs.mjs: the pre-flight refusal keys off the existing snapshot's recordedtotalNodes— but a legacy corpus has an empty snapshot stub (graph/statsreportsfromSnapshot: true, totalNodes: 0). So:"no prior snapshot"→ false (a stub exists), refusal skippedtotalNodes > REBUILD_SAFE_NODE_CEILING (25000)→0 > 25000→ false, ceiling guard skipped…and it falls through to the real
totalNodes = nodes.lengthfullkv.listof the 75K on-disk rows → heartbeat starvation →Invocation stoppedat ~0.35s (the 6s budget timer fires too late, after the worker has already reconnected). The guard protects a pristine corpus, but the legacy corpora it was written for carry exactly the 0-count stub that defeats it.Possible fix directions: gate on the actual on-disk row count rather than the snapshot's self-reported count (a cheap
state::count-style probe, if one can be added), or treat atotalNodes: 0snapshot on a corpus withresetAtunset as "unknown size → refuse unless force." Either way, calling rebuild shouldn't take the worker down — even a no-op refusal would be strictly better than the crash.Related: #828 (read path) — same race, confirmed still live on 0.9.27
Flagging because it's the same mechanism:
graph-querywith aquerystring still hard-crashes ({"query":"stripe"}→Invocation stopped0.35s on 0.9.27), andstartNodeIdBFS likewise. ThewithTimeout→snapshot fallback intended to make these degrade gracefully never fires — the heartbeat dies at ~0.35s, well before the 6s budget — so it's a worker crash, not a degrade. Tracked under #828 (per-node adjacency index); noting here only so the "graceful degradation" assumption isn't carried forward as true at scale.Standing offer
Same as before — we've got the >50K repro (680 sessions / ~75K nodes / 344 MB), backups, and a one-command rebuild-and-test loop. Push a branch touching either the rebuild guard or the adjacency index and we'll report behavior + reconnect deltas against the real corpus, usually within the hour.
fix(release): v0.9.28 hotfix wave (rohitg00#843, rohitg00#825, rohitg…
allandelmare commentedon Aug 14, 2026
Re-tested on released v0.9.28 (npm global, not a branch) against the same corpus. Fresh backup first; data intact throughout (131/133 memories, 689 sessions, snapshot unchanged).
Short version:
snapshot-rebuildstill hard-crashes the worker, with and withoutforce.graph/resetremains fixed and the #816 hot path remains clean.Results
Note
graph.tsis byte-identical between v0.9.27 and v0.9.28 (997 lines, cleandiff), so this is the expected outcome — recording it so the issue has a datapoint against a shipped release rather than a branch.The guard hole is now sharper than in my June report
In June I reported that the pre-flight keyed off a snapshot recording
totalNodes: 0. That framing was incomplete. On this corpus today:The snapshot is non-empty and perfectly valid — 108 nodes accumulated by incremental extraction after the June
graph/reset. But the rawKV.graphNodes/KV.graphEdgesscopes still hold the pre-reset rows (~75K; I can't re-count, since enumerating them is the thing that crashes).So the pre-flight at
graph.ts:875:…sees a healthy snapshot,
!existingis false, refusal skipped. Execution falls tograph.ts:900whichkv.lists both raw scopes, hits ~75K rows, starves the heartbeat, worker dies at ~0.6s. TheREBUILD_SAFE_NODE_CEILINGcheck at line 909 is downstream of the enumeration it exists to prevent, so it can never fire.The generalisable statement: the guard gates on the snapshot's self-reported size, while the crash is caused by the on-disk row count — and after a reset those two numbers are unrelated by design. A
totalNodes: 0stub was just one way to hit it; a legitimate 108-node snapshot sitting on 75K orphans hits it just as hard, andforce: trueis not an escape hatch since it skips straight to the same enumeration.Log evidence for the unwinnable race
That line lands after the worker has already reconnected. The heartbeat deadline (~0.4–0.6s observed) is an order of magnitude shorter than
LIVE_ENUMERATION_BUDGET_MS = 6000, and the synchronousJSON.parseblocks the timer as well — sowithTimeoutcannot convert this into a graceful degrade. Any fix that relies on racing the enumeration will have this same problem.Related
#1189, filed independently today, is the other half of this:
graph-resetwrites an empty snapshot but deliberately leavesKV.graphNodes/KV.graphEdgeson disk. That is precisely what manufactures the "small snapshot, huge disk" state above — so #1189's orphan-retention and this guard hole compound. Worth fixing together: if reset actually cleared the rows (or reads/guards filtered onresetAt), this corpus would stop being a landmine.I also built and tested PR #893 against this corpus — it inherits the same snapshot-self-report gating pattern at boot. Details on the PR.
Possible directions (unchanged, plus one)
state::count-style probe, which may not exist.resetAtis set and orphan rows may remain" as unknown size → refuse unless force, and makeforcedo a bounded/paginated walk rather than a singlekv.list.Standing offer
Still open, and I'll keep honouring it: >50K repro (691 sessions / ~75K pre-reset rows / 335 MB), backups, one-command build-and-test loop. Push any branch and I'll report rebuild/reset/hot-path behaviour plus worker-reconnect deltas, usually within the hour.
Environment
gemini-2.5-flash-lite+openai/text-embedding-3-small)cbsincool commentedon Sep 21, 2026
Adding a data point on how corpora reach the no-path-to-rebuild state, and one thing the ceiling check itself does that isn't covered above.
The state this issue describes is also reachable without a legacy corpus, via the write side.
persistGraphDeltareads the snapshot through the fail-openreadSnapshot(graph.ts:80-93, catch →null) and coalesces it (graph.ts:557):A transient
state::getfailure therefore reads as "no snapshot yet" and the next extraction writes back a snapshot derived fromemptySnapshot()— counters at or near zero, whileKV.graphNodes/KV.graphEdgesstill hold the full corpus. Reported separately as #1381.How the two guards then behave — they disagree, and neither offers a non-destructive exit.
The rebuild preflight (
graph.ts:990-1014) reads through the lenientreadSnapshot, so:existingisnull→ refuses withlegacyCorpus, and the message is correct about the cause but wrong about the corpus (it is not legacy — it is the same corpus that was fine a moment ago).tooLargeinstead. Two different errors for one underlying fault, decided by whether a read happened to time out.Both refusals name
POST /agentmemory/graph/resetas the remedy (graph.ts:1003andgraph.ts:1038). So on a corpus with live data and a corrupted snapshot, the safe path refuses and the destructive path is the one both error messages recommend — and it is one call away. That asymmetry is the part I'd flag:graph/resetis the only branch either guard offers, and it discards the last good copy of the graph.One detail in the ceiling check itself: the comparison at
graph.ts:1026runs on the raw enumeration, and the!n.stalefilter is atgraph.ts:1049— after it. Sonodes.lengthcounts stale rows too. A corpus that is mostly stale, and whose snapshot could be rebuilt cheaply from the small live remainder, still refuses on the raw count.A data point on the ceiling as a proxy. Measured on a docker-hosted iii state adapter,
kv.listover both scopes with 22504 nodes / 51082 edges completed in ~800ms, well insideLIVE_ENUMERATION_BUDGET_MS(6000). The 25K guard is protecting against payload-size/JSON.parsestarvation, which is real on the 344MB corpus you tested, but the node count is a proxy for that rather than the quantity itself — so the threshold is conservative on a fast local store and, as you note, still not safe at 75K. Not a suggested fix; the cursor/pagination onstate::listyou identify is. I raised it to 1e5 in a build-time patch locally purely to get one rebuild through, and that is explicitly not a fix — it moves the failure rather than removing it.Two things that would break the dead-end without needing pagination:
graph/reset, and offer a snapshot-only rebuild that recomputesstatsfrom the live enumeration without the index-rewrite loops (the badge only needs the snapshot write; theBATCH_SIZE=100index backfill is ~N/100 sequential round-trips against an already-struggling store).docs(triage): keep five iii-symptom rows as candidates
docs(triage): keep five iii-symptom rows as candidates
fix(graph): rebuild and reset graphs past 25,000 nodes in pages
rohitg00 commentedon Oct 8, 2026
@allandelmare v0.9.30 adds
POST /agentmemory/graph/compact, which trims a legacy graph in slices without blocking the worker, and runs it once on first start. Please try it on your >25K graph; if snapshot-rebuild or graph-reset still crash afterwards, keep this open with the log. https://github.com/rohitg00/agentmemory/releases/tag/v0.9.30