fix(sync): serve TTL-filtered SWM meta past the 64,000-row snapshot ceiling - #1868
Conversation
…eiling (#1847) A CG whose SWM `_meta` reached SYNC_RESPONDER_SNAPSHOT_BUILD_MAX_ROWS (64,000) raw rows became permanently unsyncable on the meta lane: `readSwmMetaPage` passed `params.cutoffIso == null` POSITIONALLY as `fallbackOnPerSnapshotBudget`, so TTL-filtered sessions (the normal modern path) got a bounded refusal with no fallback, and the bounded snapshot applied its row/byte budget to the RAW graph BEFORE the TTL filter, so the refusal fired even when the fresh subset was tiny. Live on mainnet: 10/15 cores refusing 78-202x/day; the fifa-world-cup-2026 CG (4,600 data quads, 64,001+ meta rows) never converges (`data=0 sharedMemory=0` forever while query-remote works). THE TRAP: a naive flag-flip is NOT a fix. The fallback was disabled deliberately because the TTL-filtered paged query was `SELECT DISTINCT ?g ?s ?p ?o` + a six-predicate UNION join + global `ORDER BY ?g ?s ?p ?o` re-evaluated with a growing OFFSET per page over a mutable graph family — the #1597 listGraphs-storm class that can pin cores and gigabytes on large stores. Re-enabling the flag alone would trade a bounded refusal for a store-melter. The fix mirrors how the SWM DATA lane solved the same problem (buildFreshSwmDataGraphPlan): * buildFreshSwmMetaPlan: two small-result discovery queries per meta graph (fresh subjects by publishedAt; graph-scoped heads via the fresh-WorkspaceOperation tuple join) plus chunked VALUES row counts. The session plan caches only graph/subject/count scalars. * readBoundedFreshSwmMetaSnapshot: the snapshot now materializes only the ADMITTED rows, so the per-snapshot budget binds on what is actually served — the fifa class (64k history, small fresh subset) takes the ordinary memoized-snapshot path. Final admission still runs through filterSwmMetaSnapshotRows, the canonical in-process filter. * readFreshSwmMetaRowsPageFromPlan: if even the ADMITTED set exceeds the budget, the session degrades to whole-subject window pages walked over the plan's prefix sums — VALUES-anchored reads with NO store-side ORDER BY and NO OFFSET (rows are sorted in-process; plan subject order is compareCodePoint, identical to compareRows on ?s). Chunk row counts are verified against the plan so a mutated subject fails the session (requester restarts) instead of skipping/duplicating rows, and a seal/head row-group is always read atomically within one chunk query (never torn the way #1788 durable batching tears groups). * The store-melting TTL query is DELETED (not gated), the dead readSwmMetaRows helper is removed, and only then is fallbackOnPerSnapshotBudget enabled for TTL sessions. * Legacy cutoff-less sessions keep the existing raw-snapshot + unfiltered store-paged compatibility path, byte-for-byte. Remaining bounded refusal: a single SUBJECT above the hard 64,000-row build cap (a coherent row-group that cannot fit any budget) — pinned by test as the only refusal left, and impossible to hit through organic operation history. Mutation-tested (each reverted before commit; each killed exactly the right tests): * M1 reintroduce `params.cutoffIso == null` positional arg -> the oversized-fresh-set, paged-equivalence, plan-mutation and paged requester-reassembly tests fail (4/8); the fifa-shape test still passes, proving budget-on-filtered-set independently fixes that class. * M2 disable the chunk row-count verification -> exactly the plan-mutation session test fails. * M3 remove the single-subject cap -> exactly the pathological-subject refusal test fails. * M4 off-by-one in the window slice -> all four paged-lane correctness tests fail. Evidence (in-memory Oxigraph, default production budgets): * fifa shape: 64,026 raw rows, 26 fresh -> served in 4 pages, 22ms. * intrinsically oversized fresh set: 65,000 admitted rows -> served completely in 14 bounded pages, 774ms, no refusal. * requester fetchSyncPages reassembly across 4-row pages: every op and seal/head row-group complete, dkg:assertionVersion never stripped. sparql-scale-lint: 0 new blocking findings (the rewritten legacy query carries R2/R3 pragmas; the new plan queries are lint-clean by shape). Fixes #1847 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review —
|
lupuszr
left a comment
There was a problem hiding this comment.
Changes requested on b7ba6d2bb71f7e2fc5962f3fd425186fbe777336.
Two merge-blocking issues remain:
- The plan-paged lane validates only subject row counts. A same-count delete+insert between pages passes the guard and can assemble a hybrid subject. A direct five-row/page-size-one reproduction completed without throwing, retained an old row and a replacement row, and omitted
dkg:publishedAt. Mutable SWM heads normally update without changing row count, so requester reassembly alone does not make the subject atomic. - The fresh-subject plan is not covered by any row, byte, or global responder-memory budget. Discovery materializes every admitted subject, and up to 64 peer-scoped copies can remain cached for ten minutes. With the default 30-day SWM TTL, a large admitted set can exhaust heap before payload paging begins.
I also confirmed the existing store-response-limit finding: StoreResponseTooLargeError escapes the snapshot path untyped, so readResponderRowsPage does not enter its per-snapshot fallback. Existing thread: #1868 (comment)
Validation: runtime package build passed; 51 focused responder tests passed, including all eight PR tests; two targeted reproductions confirmed the same-count hybrid and untyped store-response failures.
…ss pages (#1868 review) Four review findings on the #1847 lane, each with a dedicated regression: - Discovery is now bounded by construction: LIMIT-capped subject discovery (FRESH_SWM_META_PLAN_MAX_SUBJECTS) + fixed response byte caps on every plan query, with typed per-snapshot refusals; retained plans carry a scalar byte estimate charged to the process-wide responder snapshot budget as control-plane entries (LRU-evictable, globally rejected under pressure). - StoreResponseTooLargeError during TTL snapshot materialization converts to the per-snapshot snapshot_bytes budget error so the phase degrades to plan paging instead of failing outright. - Whole-subject window reads verify PER-SUBJECT counts against the plan and bind a content digest on first read, verified on every reread: same-count replacements and compensating cross-subject mutations fail the session instead of tearing or misaligning row-groups at page seams. - readResponderRowsPage optional behavior is a named options object; the fallback policy can no longer be passed positionally (the original defect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1868 review) Partial take on the plan-orchestration collapse suggested in review: the exact-graph and TTL SWM meta lanes now share one createSessionPlanGetter owning refresh consumption, offset>0 require-existing, and expiry translation — the lifecycle most likely to drift between lanes. The full collapse (snapshot + fallback wiring) is deferred; those parts differ by lane for reviewed reasons and are slated for the graph-plan module split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review dispositions — all four 🔴 fixed in 🔴 graph-plan.ts:2273 — subject plan unbounded, bypasses responder budgets (@lupuszr) — FIXED, agreed this was the most serious one. Bounded by construction now, plus global accounting:
🔴 graph-plan.ts:2420 —
🔴 graph-plan.ts:2405 — store byte-limit errors bypass the TTL meta fallback — FIXED as suggested: 🔴 test:293 — mutation coverage only proves row-count changes — FIXED: same-count replacement of a split subject (responder-level + full requester path), compensating cross-subject count mutation, and the coherent-new-group skew case, alongside the existing grow-mutation test. Mutants for digest-off and aggregate-count-only are each killed by their dedicated test. 🟡 :607 — positional fallback boolean — DONE: 🟡 :2542 — byte-budget fallback unverified — DONE: dedicated regression exceeds only 🟡 :555 — collapse inline TTL meta orchestration — PARTIAL, remainder deferred with reasons ( 🟡 :2264 — single admission owner — DEFERRED with reasons: the invariant (SPARQL discovery is a candidate superset of |
… TTL meta plan (#1868 review) FreshSwmMetaPlan is now a genuinely immutable pagination description (deep-readonly graph/subject/count scalars). The mutable per-session content-digest state that used to live on subject entries moves to a sidecar WeakMap keyed by plan instance — exactly the binding's intended lifetime: the memoized plan IS the session, a refreshed/rebuilt plan is a new object with a fresh empty binding map, and evicting or expiring the plan releases its digests with it. readFreshSwmMetaSubjectWindowRows is the only writer. Placement only: same-count replacement failure semantics are unchanged and every existing mutation test passes unmodified. Also closes the two remaining #1868 round-2 coverage asks on the plan budget thread, each proven by a killed mutant: - time-based TTL expiry (controlled clock) prunes a plan AND releases its global budget charge, distinct from the maxEntries eviction the prior test covered — a mutant that leaks the charge on expiry passes the old test and is killed only by the new one; - the plan cardinality cap binds in AGGREGATE across root and subgraph meta graphs — a mutant that resets the allowance per graph passes the single-graph cap test and is killed only by the new multi-graph test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readSwmMetaGraphSnapshot read `?s ?p ?o` unfiltered and accumulated every row against SYNC_RESPONDER_SNAPSHOT_BUILD_MAX_ROWS/_BYTES, stripping the finalized-cleanup rows only afterwards in process. Purely local GC bookkeeping therefore counted toward the ceiling that decides whether a context graph can be served at all — the #1847/#1868 snapshotBudgetError class. Apply the same store-side filter the fresh/TTL lanes already use. filterSwmMetaSnapshotRows also did its whole O(rows) index build before the early returns that used to fire first, so an unparseable cutoff paid for a full scan and then returned nothing. Settle that case up front, and build the per-subject map only in the TTL lane that consumes it. The cleanup-row strip deliberately stays above the TTL-disabled return: that lane must be filtered too, or the responder advertises local GC metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
Symptom
A context graph whose SWM
_metasnapshot reaches 64,000 rows becomes permanently unsyncable on the SWM meta lane — to every peer, forever. Live on mainnet: 10 of 15 cores emittingSync responder snapshot memory budget … phase=meta, workspace=true, reason=snapshot_rows, rows=6400178–202×/day; the canonical victim isfifa-world-cup-2026(≈4,600 data quads, 64,001+ meta rows), which reportsdata=0 sharedMemory=0 denied=0indefinitely whilequery-remoteproves the data exists. The requester's page-size retry ladder is provably futile because the cap is on snapshot build, not page size.Root cause
packages/agent/src/sync/responder/graph-plan.ts:504—readSwmMetaPagepassedparams.cutoffIso == nullpositionally asfallbackOnPerSnapshotBudgetintoreadResponderRowsPage(graph-plan.ts:1687, enforced at:1702). TTL-filtered sessions — the normal modern path — therefore got a bounded refusal with no fallback. Compounding it,readBoundedSwmMetaSnapshotapplied the 64,000-row / 32 MiB budget to the raw meta graph before the TTL filter, so the refusal fired even when the fresh subset a TTL session actually needs was a few dozen rows. The cap isMath.min-clamped (snapshot-cache.ts:150-152), so no configuration can raise it.Why the naive fix melts stores
The gate was deliberate. The TTL-filtered fallback query was
SELECT DISTINCT ?g ?s ?p ?o+ a six-predicate UNION join + globalORDER BY ?g ?s ?p ?ore-evaluated with a growing OFFSET per page over a mutable graph family — O(N²) paging of the #1597 listGraphs-storm class, able to pin multiple cores and gigabytes until the HTTP timeout. Flipping the flag alone would trade a deterministic bounded refusal for a store-melting query on exactly the mainnet cores currently refusing. This PR deletes that query instead of reviving it (and removes the deadreadSwmMetaRowstwin), and only then enables the fallback.Bounded design
Mirrors how the SWM data lane solved the identical problem (
buildFreshSwmDataGraphPlan):buildFreshSwmMetaPlan— per meta graph, two small-result discovery queries (no payload rows, no sort, no OFFSET): fresh subjects bydkg:publishedAt >= cutoff(the acceptedreadFreshSwmRootsshape), and graph-scoped SWM heads via the fresh-WorkspaceOperationtuple join (heads deliberately carry no ownpublishedAt; they are admitted with their immutable commitment atomically). ChunkedVALUES ?s … GROUP BY ?scounts complete a session-cached plan of graph/subject/count scalars only (createResponderFreshSwmMetaPlanMemo, same lifetime/refresh contract as the data-lane plan memo).readBoundedFreshSwmMetaSnapshot— the snapshot now materializes only the admitted rows, so the per-snapshot budget binds on what is actually served. The fifa class (huge history, small fresh subset) takes the ordinary memoized-snapshot path. Final admission still runs throughfilterSwmMetaSnapshotRows, the canonical in-process filter.readFreshSwmMetaRowsPageFromPlan— if even the admitted set exceeds the budget, the session degrades to whole-subject window pages walked over the plan's prefix sums:VALUES ?s-anchored reads with no store-side ORDER BY and no OFFSET (rows sorted in-process; plan subject order iscompareCodePoint, identical tocompareRowson?s). Chunk row totals are verified against the plan, so a mutated subject fails the session (requester restarts with a fresh plan) instead of skipping/duplicating rows.#1788 interaction (split seal row-groups stripping
dkg:assertionVersion)The SWM requester (
fetchSyncPages→processSharedMemoryBatch) reassembles all meta pages before verification, so page boundaries cannot strip fields in this lane — proven by a new requester-side test through realfetchSyncPageswith 4-row pages against 5- and 11-row groups, on both the snapshot and plan-paged lanes. Additionally, the plan lane reads each seal/head subject atomically within one chunk query, so unlike the durable batching in #1788 a row-group can never be torn even responder-side. (The durable-lane batch split itself is #1788's scope, not inherited here.)Test evidence
New
packages/agent/test/sync-responder-swm-meta-ceiling.test.ts(8 tests, added to the explicitvitest.unit.config.tsinclude list), seeding real >64,000-row Oxigraph stores at default production budgets:dkg:assertionVersionnever strippedMutation-tested (each mutation applied, run, reverted): reintroducing the positional
params.cutoffIso == nullfails exactly the 4 fallback-dependent tests (the fifa-shape test still passes — budget-on-filtered-set fixes that class independently); disabling count-verification fails exactly the mutation-detection test; removing the single-subject cap fails exactly the pathological-subject test; an off-by-one in the window slice fails all 4 paged-lane correctness tests.Gates:
packages/agentunit suite 1113/1113 green;tsc --noEmitclean;sparql-scale-lint --diff0 new blocking (the rewritten legacy query carries reviewed R2/R3 pragmas; all new plan queries are lint-clean by shape). A 2-node devnet repro was not run: the devnet port map collides with a live mainnet edge node on this machine; the handler-level +fetchSyncPagestests above exercise the full responder/requester protocol path with real stores at real budgets, which is the same repro minus libp2p transport.Mainnet impact
Unblocks the entire ≥64k-meta CG class (
fifa-world-cup-2026,start-systems, and every long-lived active CG drifting toward the ceiling) alongside the already-merged materialization fix, and silences the 78–202/day refusal storms on 10/15 cores. TTL sessions get strictly cheaper responder work (indexed discovery + exact-subject reads instead of raw-graph materialization). Complementary_metagrowth bounding (TTL/compaction of operation history) remains open as the cause-side follow-up.Fixes #1847
🤖 Generated with Claude Code