Skip to content

deploy: incremental graph-extract (upstream #1268) - #1

Merged
inix-x merged 15 commits into
productionfrom
deploy/incremental-graph-extract
Aug 27, 2026
Merged

deploy: incremental graph-extract (upstream #1268)#1
inix-x merged 15 commits into
productionfrom
deploy/incremental-graph-extract

Conversation

@inix-x

@inix-x inix-x commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Deploy PR. Railway builds production on push, so this is the review surface before the fix reaches the live service.

Mirrors upstream rohitg00#1268 — read that for the full mechanism and limitations.

What ships

Thirteen commits cherry-picked from fix/incremental-graph-extract, plus two doc corrections already on local production but never pushed.

event::session::stopped fires on every agent turn (the per-turn Stop hook POSTs /agentmemory/session/end) and currently re-sends the entire session's observations to mem::graph-extract. persistGraphDelta then re-merges every accumulated node and edge at 3 engine round-trips each, so cumulative engine invocations are quadratic in turn count.

Measured, 8 turns of one observation each:

observations dispatched to graph-extract:  36 before  ->  8 after

Per rohitg00#843, data written via kv.set stays resident in the engine's in-memory state and the 0.11.x iii-state worker has no TTL, no eviction, no max-entries. Quadratic writes into a store that never evicts is quadratic permanent heap.

This branch was wrong until now — worth stating

It was originally cut from the first three commits, before two rounds of review. In that state it carried a watermark fingerprint built from a size-seeded sum of timestamps, which collided in exactly the case it existed to catch: evict deletes an observation while a late compression lands another in the same millisecond, leaving both the set size and the sum unchanged, so the new observation was never extracted.

Merging this branch as it stood would have deployed that bug. It has been re-cherry-picked from the reviewed tip; src/triggers/events.ts and the test file are now byte-identical to the reviewed branch, and src/types.ts differs only by production's own heapSizeLimit field and upstream's viewerPort, neither related to this change.

Pre-deploy baseline — the number this has to beat

Captured 2026-08-27 10:23Z, engine pid 75:

engine RSS 10,279 MB at age 371.7 min
sustained ratchet 8.24 MB/min over a 188.7 min window
time to the 24 GB cap 28.9 h

Gates, run against production not just the fix branch

  • npm test — 161 files, 1771 passed, 1 skipped
  • npx tsc --noEmit30 errors, exactly the pre-existing baseline
  • Cherry-picks clean; no conflicts

How this gets judged after merge

Rate against rate, not absolute memory. A deploy restarts the engine and resets RSS to near zero, so before/after on absolute is meaningless. Two timestamped points 60+ minutes apart minimum, under comparable traffic — the 8.24 MB/min baseline was taken under a live 20+ concurrent-session load, and measuring during a quiet period would produce a better number that proves nothing.

The 36-to-8 figure is invocation count, not heap. Whether the memory curve actually flattens is what the post-deploy measurement decides.

Rollback

Deployment 2268c51d-cf35-4ba1-89e6-2278351cc7c2, commit a9f0288d7, 2026-08-27T04:11:19Z, status SUCCESS.

Railway's auto-redeploy replays the last stopped deployment, not the branch tip, so a rollback has to name that deployment explicitly.

Known cost of merging

Volume-backed Railway services cannot overlap deployments (documented: "we prevent multiple deployments from being active and mounted to the same service"), so the cutover drops the service for roughly 63 seconds. Hooks fail soft and nothing replays, so every observation posted during that window is permanently lost. Accepted deliberately — measuring under real load is what makes the result valid.

Review disclosure

Two rounds, both lenses. Round 1 found the fingerprint collision. Round 2 removed a derived set provably equal to its source and a comment describing code that was not there. Round 2's correctness pass had no independent reviewer — its findings were lost twice to tool failures, so its five open questions were verified against the code by the same person who wrote the fixes. Individually code-verified, weaker than an independent pass.

inix-x added 15 commits August 27, 2026 14:16
Two pushed commits on this branch state that the deployment carries "1798
files and roughly 350 MB" under /data:

  54225a0 build(railway): give a cold engine room to accept the worker
  960c8ad build(railway): raise the healthcheck timeout for a cold store

That is wrong by about 7x. Measured from the container on 2026-08-27 at
05:39Z, /data held 2,421 files across 2,363 MB. A pushed commit message
cannot be edited, so the corrected figure goes into deploy/README.md,
which both of those bodies already send the reader to for the fresh-store
baseline.

The figure was already refutable from this branch's own log when it was
written. 6a27bca, a day earlier, records the same store at 1013 MB.

Nothing else in those two bodies depended on it. The 63 to 67 seconds
from container start to "Worker registered" against a 60 second window
was timed from the container logs, not derived from store size, and the
conclusion (raise healthcheckTimeout to Railway's default of 300) is
unchanged. The cold read argument is stronger at 2,363 MB than at 350 MB,
but this commit claims only what was measured.

The same paragraph in deploy/README.md still described healthcheckTimeout
as 60 s, which 54225a0 had already changed to 300 in
deploy/railway/railway.json. Correcting one wrong number there while
leaving a second one two lines below would defeat the point, so both land
together.
3005b67 wrote the two figures as one observation: "On a store of 2,363 MB
across 2,421 files, the worker took 63 to 67 s to register." That pairing
was never measured.

The timing was taken during the healthcheck failures behind 960c8ad and
54225a0, in the early hours of 08-27. The store figure was measured at
05:39Z, hours after the last of those failures. 6a27bca records the same
store at 1013 MB the previous day, so it more than doubled across that
window. Both numbers are point samples of a quantity that was moving
between them, and the store size at the moment of the timing was never
read.

State them as two separate observations and say the store was still
growing, so a reader cannot take the size as the condition the timing was
measured under.

This is the defect the correction in 3005b67 exists to prevent, reached by
a different route: a composite claim is as discountable as a wrong one.
Claude Code's per-turn Stop hook POSTs /agentmemory/session/end, so
event::session::stopped runs on every agent turn, not once per session. It
handed mem::graph-extract the session's entire compressed observation list
each time. Downstream, persistGraphDelta takes the MERGE path for every node
and edge it has already seen (kv.get name-index + kv.get nodes + kv.set per
node, and the same three per edge), so turn N re-merged everything from turns
1..N-1.

Measured on the new harness: eight turns of one observation each dispatched
36 observations before this change (1+2+...+8) and 8 after. The growth was
quadratic in turn count, and per rohitg00#843 nothing written through kv.set is ever
evicted from the iii engine, so that was quadratic permanent heap walking the
engine toward its cap.

The session record now carries a watermark. Two optional fields rather than
one, because a bare max-timestamp watermark is not safe here: observe.ts
dispatches mem::compress fire-and-forget and compress.ts stamps the capture
time, not the write time, so a slow compression lands an older timestamp
after a newer one was already extracted. The count is the tripwire. When the
number of observations newer than the watermark does not exactly account for
the growth since the watermark was recorded, something arrived out of order
(or was evicted) and the whole session is re-sent. Losing a memory is worse
than re-merging one. Both fields are optional, so existing session records
stay valid and simply take the full-extract path once.

The watermark advances only after mem::graph-extract's dispatch is accepted,
so a hand-off that never left is retried on the next turn. fireVoid now
resolves to a dispatch-success boolean to make that observable; the other
callers ignore the value exactly as before.

Limitations:

- Completion is not observable through TriggerAction.Void(). An extract whose
  dispatch is accepted but which then fails downstream leaves its delta out
  of the graph until someone POSTs /agentmemory/graph/build. Advancing on
  completion would mean awaiting LLM work inside the stop handler, which is
  the latency this trigger exists to avoid.
- How often the out-of-order fallback fires in a real deployment is not
  measured. Compression latency scales with payload size, so timestamp
  inversions may be common rather than rare. When the fallback fires it does
  exactly what the pre-fix code did on every turn, so this is never worse
  than the status quo, but the saving on a busy session is unquantified.
- Interleaving is handled by construction, not by a lock: the (timestamp,
  count) pair is only ever written as a matched snapshot from a single run,
  and a pair that does not reconcile degrades to a full extract. A stale pair
  costs a re-merge; it cannot skip an observation.

Ten tests added. Five fail against unmodified source, including the 36-vs-8
dispatch count. The five that pass by construction cover the fallback branch,
whose correct behaviour is the pre-fix behaviour; they exist as mutation
coverage and kill removing the count tripwire, making the comparison
inclusive, dropping the empty-batch guard, and skipping instead of falling
back.
…servation

Review of the previous commit found a hole in its tripwire. Comparing the
number of observations at or below the watermark proves the cardinality is
unchanged, not that they are the same observations. A deletion and a late
arrival in the same window cancel out:

  snapshot {o1(T1)..o5(T5)} extracted, watermark (T5, 5); then evict removes
  o3 and a slow compression lands o2b(T2b<T5). compressed.length is still 5,
  nothing is above T5, so 5-5=0 reconciles and the whole turn dispatches
  nothing. o2b never reaches the graph, and the corrupted count is now
  self-consistent, so every later turn reconciles too and it stays missing
  until someone POSTs /agentmemory/graph/build.

That is reachable on the live path. evict.ts:246-260 evicts by per-project cap
(default 10,000) sorted on importance, with no age filter and no session-status
filter, so it can delete an observation from the session that is still being
appended to. It is reachable precisely in the situation this work targets: a
large store with an operator running eviction while an agent is connected.
The previous commit's claim that "a stale pair costs a re-merge; it cannot skip
an observation" was too strong, and this is the case it missed.

No cardinality-only scheme can see a 1-for-1 swap, so `graphExtractedCount` is
replaced by `graphExtractedDigest`: an order-independent sum of the parsed
timestamps of the observations at or below the watermark, seeded with the set
size and reduced mod 2^32. Same one number on the session record, same two
engine calls per turn, but any change to the already-extracted set moves it —
a swap, a deletion, a late arrival, or a tie on the watermark timestamp — and
a mismatch falls back to a full extract. Storing the ids themselves would be
exact but grows without bound in a record rewritten every turn, which is the
rohitg00#843 heap behaviour this work exists to stop.

Measured: the new swap test dispatches nothing at all on the second stop
against the previous commit ("expected undefined to deeply equal
['a','e','b']"), which is the loss made visible, and dispatches all three
after this change.

Residual, unchanged from the previous commit: the digest is a sum mod 2^32, so
two observations whose epoch milliseconds are congruent mod 2^32 (49.7 days
apart to the millisecond) could still cancel. An observation stamped exactly at
the epoch contributes zero to the sum, which the set-size seed covers and a
test pins.
…change

Review asked the question the batch-content tests cannot answer: the tests mock
sdk.trigger, so mem::graph-extract never runs and they prove only that the
payload shrank, not that the resulting graph is the same. Traced it.

extractGraphHeuristics (graph.ts:457) loops per observation and every link()
call joins nodes built from that one observation's own files and concepts. Its
nodeByKey and edgeByPair maps are batch-scoped dedup, and persistGraphDelta
performs the same dedup across batches through graphNameIndex and
graphEdgeKey, with mergeNode and mergeEdge unioning sourceObservationIds. So
on the default path the delta produces the identical node and edge set. That
is what makes the previous "perf" framing accurate rather than a quiet
behaviour change.

The opt-in LLM pass is different and worth naming. isGraphExtractionEnabled
requires GRAPH_EXTRACTION_ENABLED=true and is off by default; when on,
buildGraphExtractionPrompt builds ONE prompt from the whole array, so a
per-turn delta of a few observations gives the model less co-occurrence to
work with than a whole-session batch did. Cross-turn relations it used to
propose will not be proposed. Bounded batches are already the norm on that
path, api::graph-build feeds it 25 observations at a time (api.ts:1616), so
this narrows an existing property rather than introducing a new limit.

Also corrects the residual stated in the previous commit, which was narrower
than a sum actually guarantees. The digest is blind to any change to the
already-extracted set whose timestamp total is congruent mod 2^32, not only to
a single swap 49.7 days apart to the millisecond — deleting two and adding two
with a coinciding total would also pass. Still an arithmetic coincidence at
millisecond resolution, but the bound is a coinciding sum, not a coinciding
pair.

One further effect worth knowing, in the direction of better: mergeNode and
mergeEdge union the WHOLE batch's obsIds into every node and edge they touch.
Sending the entire session every turn therefore accumulated every observation
id in the session into sourceObservationIds on every node it merged. The delta
attributes only the observations that were actually in the batch, so
provenance gets narrower and those arrays stop growing per turn.

Comment only, no behaviour change.
The digest that guards the incremental watermark summed Date.parse of each
timestamp, seeded with the set size. That collides on exactly the case it
was written to catch: evict drops one observation while a late compression
lands another carrying the SAME millisecond. Size is unchanged, the sum is
unchanged, the digest matches, the incremental path is taken, and the late
observation is never handed to mem::graph-extract. On a review of the
watermark logic a skip is otherwise impossible, so the whole correctness
argument rested on this digest.

Fingerprint the sorted observation ids through the existing
fingerprintId() helper instead. Ids differ whatever the timestamps do, and
order-independence is now structural rather than an arithmetic property a
test has to assert. graphExtractedDigest becomes a string.

The pinned test previously used two different milliseconds, which is why it
passed. Moved to the same millisecond; it fails against the arithmetic
digest with the second stop dispatching nothing at all.
`newest` reduced over the full compressed list and the digest fingerprinted
the full compressed list, while the batch handed to mem::graph-extract was
only the tail. Those are equal today because the batch is always
`timestamp > at`, so max(batch) === max(compressed).

They stop being equal the moment anyone caps this batch, and the file's own
comment cites api::graph-build capping at 25 as precedent. A cap would make
`newest` advance past observations that were never dispatched, which is the
exact silent skip the digest exists to prevent, and would leave the digest
covering observations above the watermark so the pair could never match
again.

Reduce over `batch` and fingerprint everything at or below the resulting
watermark. Both fields now come off one predicate, so the invariant holds
locally instead of resting on a max-equality argument in prose.
Neither branch logged anything, so a session whose watermark keeps going
stale re-sends its whole observation list every turn and looks identical to
a healthy one from the outside. One line on the mismatch path, carrying the
two set sizes so the cause (a deletion, a late arrival, a half-written pair)
is readable from the log rather than guessed.
The comment claimed the heuristic graph is "identical either way" and hung
that on persistGraphDelta's dedupe. The dedupe argument is right about the
node and edge SETS and wrong about provenance: mergeNode and mergeEdge
union the whole batch's obsIds (graph.ts), so a whole-session batch stamped
every node and edge with every observation id in the session. The union
cited as proof of equivalence is where the divergence lives.

Post-change provenance is per-batch, which is narrower and more accurate,
and it is read by graph retrieval and by supersede-staling. Net an
improvement, but it ships on the default path and the comment should say
so rather than assert equivalence.

Also drops the GRAPH_EXTRACTION_ENABLED framing. That flag gates only the
LLM pass; the heuristic pass that produces this behaviour always runs.
Three things that carried no weight:

`typeof mark === "string"` was a no-op. The comparison on the next line
already yields false for an absent or legacy-numeric digest, which is the
same fallback the guard produced.

fireVoid grew a boolean return for one of its four callers. Calling
sdk.trigger directly for mem::graph-extract lets a rejected dispatch throw
into the try/catch that was already there, which logs the same message and
skips the watermark write the same way, and restores fireVoid to what the
other three callers use.

The "mem::compress is fire-and-forget so writes are not in timestamp order"
paragraph was duplicated verbatim in types.ts and events.ts. types.ts now
points at events.ts.

Four test cases went with it. Each asserted a mutation another case already
kills: the linear-vs-quadratic count is subsumed by the exact per-turn batch
assertion, the matched-pair case by the same (a wrong watermark changes the
next turn's batch), the synthetic torn pair by the realistic out-of-order
case, and the half-written pair killed nothing at all — it passed with the
guard present or absent. The fake KV's set/delete were never invoked.
…serve

Nothing verified the claim the whole change rests on. The incremental suite
sets concepts and files to [] and mocks sdk.trigger, so mem::graph-extract
never runs and the graph is never built.

Call extractGraphHeuristics directly with populated concepts and files and
compare one batch of two observations against two batches of one, merged by
(type, name) and edge endpoints the way persistGraphDelta merges them. Node
ids are random, so the comparison is by name throughout. This is what would
catch a future cross-observation link, which is the only way splitting the
batch could change the set.

Also pins order-independence of the fingerprint: the same observation set
listed back in a different order must stay on the incremental path. Kills
the mutation that drops the sort.
The mutation run showed the `typeof at === "string"` guard could be removed
with every test still green: with no watermark the timestamp filter yields
an empty set, that fingerprints to something no absent digest equals, and
the full extract happens anyway. The guard's one remaining job is keeping
the stale-watermark log off every session's first turn, and nothing checked
that.

Assert the log is silent on the first stop and carries the two set sizes on
the fallback path. Also the only coverage the log line has.
graphExtractedDigest is new, so on the first stop after this ships every
existing session record has graphExtractedAt and no digest. That branch is
traversed once by 100% of real sessions and nothing exercised it — a
permissiveness mutation (`!mark || fingerprint(seen) === mark`, the
"do not punish upgraders with one re-extract" shortcut) passes the entire
1720-test suite untouched, while silently dropping every observation at or
below the stale watermark.

Assert the whole session goes out when the digest is absent and the
observations straddle the watermark. Kills that mutation.
`extracted` filtered `compressed` down to observations at or below `newest`,
and returned `compressed` on every reachable path. On the fallback branch
`batch` IS `compressed`, so `newest` is its maximum. On the incremental branch
`batch` is the tail above `at` and non-empty, so `newest` is the maximum of the
whole list either way. Verified algebraically and by exhaustive enumeration.

It was scaffolding for a batch cap that does not exist. Without it, a future
cap makes the next turn fingerprint miss and the session falls back to a full
re-extract, which the log added alongside it now makes visible rather than
silent. Nothing is skipped either way, and `newest` reducing over `batch` is
what actually closes that hazard.

The comment claimed both halves of the watermark pair came off the dispatched
batch. Only `newest` did; the fingerprint came off `compressed` through a
filter that excluded nothing. Says what the code does now, and drops two
paragraphs that restated what the tests already execute.
The quadratic-heap explanation lived in three files. An earlier commit
replaced the copy in types.ts with a pointer to events.ts and left the one in
this test header untouched.
@inix-x
inix-x merged commit 586bc60 into production Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant