Skip to content

fix(sync): remove 64k _meta ceiling + materialize catch-up snapshots (fifa-class CGs) - #1880

Merged
branarakic merged 12 commits into
testnet-canaryfrom
fix/testnet-canary-fifa-class
Jul 21, 2026
Merged

fix(sync): remove 64k _meta ceiling + materialize catch-up snapshots (fifa-class CGs)#1880
branarakic merged 12 commits into
testnet-canaryfrom
fix/testnet-canary-fifa-class

Conversation

@branarakic

Copy link
Copy Markdown
Contributor

What ships (step 2 — fifa-class Context Graphs)

Two stacked fixes that must ship as a pair; either alone is insufficient:

  1. Remove the 64k _meta snapshot ceiling (issue bug(sync): SWM meta lane permanently unsyncable once _meta reaches 64,000 rows (oversized-snapshot fallback disabled for TTL-filtered sessions) #1847, canonical PR fix(sync): serve TTL-filtered SWM meta past the 64,000-row snapshot ceiling #1868): the responder applied its 64,000-row budget to the raw meta graph before TTL filtering and passed the fallback flag positionally-wrong, so any long-lived CG (e.g. fifa-world-cup-2026: ~4,600 data quads, 64k+ meta rows) became permanently unsyncable to every peer. Replaced with a bounded session-plan lane: budget binds on served rows, no store-side global sort, count- and digest-verified pages. 10/15 mainnet cores emit these refusals today.
  2. Materialize catch-up snapshots (canonical PR fix(swm): materialize verified public snapshots on catch-up #1842): the public catch-up lane fetched and verified content snapshots, cached them — and never wrote them to the store, so a node that missed live gossip stayed empty forever. Now materialized under the same per-KA write lock live gossip uses, with in-lock version ordering and a digest-bound already-materialized guard.

Meta arrives + content lands. Base: current origin/testnet-canary (includes #1876 and the #1879 merge-main; its cli conflicts hand-resolved here).

Gate evidence (6-node devnet, full sync suite)

Final head: verify-fixes 8/10 (only the sanctioned #1780-409 and #1779-markdown knowns), proof 32/34 (only the two known watermark checks), and the fifa-class proof itself:

resilience/holdout: node absent during publication reconstructs the FULL corpus — 100/100 quads in 3009ms

Two earlier full-green runs on the pre-absorb head (32/34 + 32/34; holdout 100/100 in 6078ms and 9164ms). Agent unit suite 1249 passing.

Known issues (documented, non-blocking)

Neighbors #1869/#1870/#1873 are compatible, not competing. Real-world acceptance test: the fifa-world-cup-2026 CG itself — a testnet node syncing its full corpus is the field confirmation.

🤖 Generated with Claude Code

Jurij89 and others added 11 commits July 20, 2026 15:59
…es-control-plane

fix(publisher): honor publisher.maxRetries for API- and agent-admitted lift jobs (#1836)
…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>
…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>
…etadata

Two review findings on the catch-up materialization path, plus the
extraction they both wanted a home for:

1. Count-only materialized-check could skip a newer snapshot. All
   assertion versions of a graph-scoped KA share ONE graph URI, so an
   older version with the same quad count read as "already
   materialized" and the verified newer snapshot was never written,
   while its metadata could still land — content and head permanently
   inconsistent. The guard now requires count AND publicQuadsDigest
   equality: the CONSTRUCT read-back only runs when the count already
   matches (bounded by exactly the snapshot size we would otherwise
   write, exact per-KA IRI scope), and the digest-over-roundtrip
   comparison is the same check resolveWorkspaceOperation already
   relies on for stored snapshot graphs.

2. Materialization left stale head metadata behind. The sync lane's
   meta insert is append/union-style, so materializing v2 on top of a
   v1 head stacked both versions' assertionVersion/shareOperationId
   rows on one subject — resolveKnowledgeAssetWorkspaceHead reads with
   LIMIT 1 and could resolve a stale or mixed head. After a successful
   graph replace (graph FIRST, so a crash never leaves a head without
   content) the new replaceHeadMetadata deletes the head subject and
   every operation subject it references — the catch-up counterpart of
   gossip's delete-then-insert (storeKnowledgeAssetWorkspaceHead) and
   recovery's replaceMetaForGraphAssets, including its kaUal guard so a
   corrupt head row can never delete another KA's operation. The fresh
   verified meta then lands on a clean subject. readStoredHead
   (MAX-version read, unchanged semantics) now also detects
   union-insert residue (>1 distinct version/operation) and the skip
   path collapses it — otherwise a round that failed between replace
   and head swap would leave the ambiguity permanent, because every
   later round skips on matching content.

Structural: the store-side policy moved out of dkg-agent-lifecycle into
createSharedMemorySnapshotMaterializer (swm-snapshot-materializer.ts).
The lifecycle now only wires agent-owned resources (store, the SAME
lock map SharedMemoryHandler uses, list-cache invalidation); the
SPARQL, parsing and replace semantics have a named, directly testable
home. Every query in the module is bound to an exact per-KA IRI (head
subject / operation subject / assertion graph) — no bucket scans;
sparql-scale-lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… subgraph + network paths

The regression tests injected isGraphAssetMaterialized, so the actual
lifecycle SPARQL implementation was untested — a regression back to a
marker-based (or count-only) guard would have stayed green. New
swm-snapshot-materializer.test.ts drives the REAL
createSharedMemorySnapshotMaterializer against a real OxigraphStore:

- marker-without-content (the pre-fix broken state) => guard false
- short graph => false; exact content => true (digest survives the
  store round-trip)
- EQUAL-COUNT graph holding another version's content => false — the
  count-only trap
- readStoredHead returns MAX over duplicate head rows and flags the
  union-insert residue for repair
- replaceHeadMetadata deletes head + referenced operations, spares
  unrelated subjects and other KAs' operations (kaUal guard)
- end-to-end: a node fully holding v1 (same quad count as v2) catches
  up to v2 — graph replaced, exactly ONE head version remains, and the
  LIMIT-1 production reader resolveKnowledgeAssetWorkspaceHead resolves
  v2; a second round is a pure no-op (no replace churn)

The decision-test file gains the two missing coverage lanes: a KA under
a REGISTERED subgraph materializes into its subgraph assertion graph
(dropping the parser admission pass-through fails exactly that test),
and a cold node fetches the snapshot via the phase='snapshot' network
branch and still materializes it / still withholds meta when the
replace fails after the fetch.

Mutation-tested — each mutant killed by exactly the intended test(s):
count-only guard, marker-based guard, MAX->MIN head read,
needsRepair=false, head swap removed, skip-path repair removed,
subgraph admission removed, network onSnapshotReady dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
packages/evm-module/deployments/localhost_contracts.json is a generated
artifact the local build rewrites (branch names, commit hashes,
timestamps). None of it is needed by the SWM materialization work;
restored byte-identical to origin/main so the diff carries only the
actual change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 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>
# Conflicts:
#	packages/agent/vitest.unit.config.ts
@branarakic

Copy link
Copy Markdown
Contributor Author

Review: approve, with one required cleanup and three observations. I verified this against the branch head (70fc28325) rather than the PR text, and independently reproduced the test evidence: all 43 tests across the three new/modified suites pass locally against real OxigraphStores, including the 64k+-row ceiling scenarios (swm-snapshot-materializer 15, swm-public-snapshot-materialization 8, sync-responder-swm-meta-ceiling 20).

What I verified holds

  • The plan-lane ordering contract is sound. The plan sorts graphs and subjects with compareCodePoint, and compareRows is compareCodePoint on (g, s, p, o) — so the plan's prefix sums genuinely agree with served row order, which is what the whole pagination scheme rests on. The set-equivalence and reassembly tests then prove it empirically at hostile page sizes (4-row pages vs 5- and 11-row groups).
  • The crash windows in the materializer self-heal. Graph replace → head swap → meta append: a crash after replace leaves content newer than the head (next round: digest matches → needsRepair collapse); a crash after the swap but before the meta append leaves no head at all, and the next round's own verified-meta insert restores it. Each intermediate state converges. The replaceHeadMetadata foreign-operation kaUal ASK guard correctly mirrors the recovery lane's replaceMetaForGraphAssets join.
  • The digest guard closes a real trap. All versions of a graph-scoped KA share one assertion-graph URI, so an equal-count older version passes any count-only check — the real-store test proving v1→v2 catch-up with same-count payloads (and the LIMIT-1 resolveKnowledgeAssetWorkspaceHead resolving unambiguously afterwards) is exactly the right evidence, and the second-round no-op doubles as proof the digest survives the store round-trip.
  • Budget lifecycle checks out. admitrelease (evictable retention), touch+release on memo hit, remove with reasons on expiry/LRU/replacement — all match the snapshot-budget API surface, and the time-based-expiry test is correctly isolated from the LRU test (roomy maxEntries so only the clock can evict). The controlPlane bypass of per-snapshot caps is well-argued: shrinking the per-snapshot budget is precisely how a session is forced into plan-paged mode, and rejecting the plan there would turn the degradation back into a refusal — while global-budget charging still bounds retained plans. The named-options refactor of readResponderRowsPage (killing the positional boolean that caused the production defect) is the right structural fix, not just a patch.
  • The pair-shipping claim is real: the responder fix alone leaves requesters caching-but-never-writing snapshots; the materializer alone never receives meta from a 64k+-row responder. Either alone leaves fifa-class CGs broken.

Required cleanup

Drop packages/evm-module/deployments/localhost_contracts.json from the PR. It's 289 lines of regenerated dev-deploy artifact (the build rewrites it — this repo's known churn trap), it points the recorded deployment at an unrelated feat/rpc-usage-metrics commit, and it downgrades the recorded PublishingConviction version 10.0.8 → 10.0.7. Pure noise in a sync fix, and a guaranteed conflict magnet.

Observations (non-blocking)

  1. Sessionless requesters on the TTL lane rebuild the plan every page. prepareResponderSession returns undefined without a syncSessionId, so createSessionPlanGetter gets no cache key and calls loadPlan per page: per-page discovery + chunked GROUP BY cost (bounded, and still far cheaper than the deleted global-sort query), but also no requireExisting protection and a fresh digest sidecar per plan object — offset>0 pages against a mutating store can skip/duplicate for such requesters. This is the same exposure the old OFFSET query had, so it's not a regression — but a code comment stating "sessionless TTL paging is best-effort" would prevent someone later mistaking the digest guard as covering that case. Worth confirming current fleet requesters always send syncSessionId on the SWM meta phase.
  2. The paged lane serves discovery's admission; only the snapshot lane re-applies filterSwmMetaSnapshotRows. The doc honestly scopes agreement to "canonical typed-literal meta writes," and the set-equivalence test proves that scope. Non-canonical rows (e.g., an untyped publishedAt literal, where SPARQL dateTime comparison and the JS filter may disagree) could be served by paged mode and dropped by snapshot mode. Requester-side verification still gates everything, so this is a consistency footnote, not a hole.
  3. Charging asymmetry: TTL meta plans are now charged to the global responder budget, but the exact-graph and fresh-SWM-data plan memos remain uncharged control-plane retention. Their per-entry footprint is much smaller (per-graph, not per-subject), but it's the same class of state — fine as a follow-up.
  4. Minor: readStoredHead's MAX(?v) is numeric for the canonical xsd:integer writes; a legacy plain-string assertionVersion would compare lexicographically ("9" > "10"). Identical semantics to the pre-PR MAX read, so noting only for completeness.

The test engineering here deserves explicit credit: real >64k-row stores at default production budgets, mutation-kill coverage aimed at the exact positional-boolean defect, the aggregate-across-subgraphs cardinality cap, the same-count split-subject hybrid repro from the #1868 review, and store-query spies that structurally forbid the deleted global-sort shape from ever reaching the store. This is the standard sync-lane changes should be held to.

…ssionless TTL paging as best-effort

Review cleanup (branarakic): the regenerated evm-module deployment artifact
leaked into the composition — 289 lines of dev-deploy churn pointing the
recorded deployment at an unrelated commit and downgrading the recorded
PublishingConviction version. Restored byte-identical to the base. This
repo's known churn trap; caught for the third time this cycle.

Also addresses review observation 1: a doc comment on createSessionPlanGetter
stating plainly that sessionless TTL paging is best-effort and the digest
guard does not cover it, so the guard is not later mistaken for offset-page
consistency protection it does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@branarakic

Copy link
Copy Markdown
Contributor Author

Review addressed in 588628e:

Required cleanup — done. localhost_contracts.json restored byte-identical to the base (the churn also mispointed the recorded deployment and downgraded the recorded PublishingConviction version, as you noted — third time this cycle the regenerated artifact has tried to sneak into a PR).

Observation 1 — comment added on createSessionPlanGetter stating sessionless TTL paging is best-effort and explicitly NOT covered by the per-subject digest guard, so nobody later mistakes the guard for offset-page consistency protection. Worth a fleet check that current requesters always send syncSessionId on the SWM meta phase — agreed, tracking as follow-up.

Observations 2–4 — acknowledged as follow-ups: non-canonical literal admission asymmetry (consistency footnote, requester verification gates), charging the exact-graph/fresh-data plan memos to the global budget (same class, smaller footprint), and the legacy plain-string assertionVersion lexicographic MAX (pre-existing semantics, canonical writes are typed).

Build clean on the new head. Thank you for reproducing the test evidence independently — clear to merge.

@branarakic
branarakic merged commit d03001f into testnet-canary Jul 21, 2026
3 checks passed
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.

2 participants