fix(sync): raise the responder snapshot build caps, and pin the two ceilings that must not move - #2102
Conversation
…eilings that must not move A context graph whose `_meta` crosses the snapshot build cap falls off the in-memory admission predicate (`filterDurableMetaSnapshotRows`) onto the SPARQL fallback (`buildDurableMetaRowsQuery`). That fallback's assertion-name branch is O(candidates x assertionName-lifecycles) — its inner EXISTS has no triple-pattern correlation to `?s`, so per candidate subject it scans every lifecycle and evaluates an unindexable `STRENDS` — and the whole filter is re-paid per page under `ORDER BY ?g ?s ?p ?o OFFSET n LIMIT 501`. Measured on Base mainnet against `fifa-world-cup-2026` (76,265 `_meta` rows / 11,525 subjects / 1,553 assertionName lifecycles / ~34.5 MiB): that branch alone exceeded a 120s server-side query deadline over the full subject set, and took 49.97s even when restricted to its 1,553 real candidates. The CG was only **1.19x over the row cap and 1.08x over the byte cap** when it fell off — a cliff, not a gradient. Raise the build caps to 200,000 rows / 96 MiB. The two are co-tuned against measured density (474.5 B/row): 200,000 x 474.5 B ~= 90.5 MiB, just under the byte ceiling, so neither cap is dead code — ordinary rows bind on ROWS, large- literal rows bind on BYTES. Both stay strictly below the retained per-snapshot caps (250,000 / 128 MiB), preserving the documented "hard build caps < retained- cache defaults" ordering, so a snapshot that passes the build check is always admissible rather than built and then rejected. Two use sites are NOT graph-level snapshot sizing and must not drift with it, so they move to their own constants at their historical values: - `SYNC_RESPONDER_MAX_SINGLE_SUBJECT_ROWS = 64_000` — the plan lane's single- oversized-subject refusal. Whole-subject windows are the consistency unit (#1788), so this is a statement about row-group atomicity, not materialization size. - `SYNC_RESPONDER_PLAN_MAX_BYTES_ESTIMATE = 32 MiB` — the retained ceiling for plan scalars. A plan holds subject IRIs and row counts; letting it grow with the snapshot ceiling would triple retained plan state for no benefit. Tests. Two existing seeds in sync-responder-swm-meta-ceiling.test.ts were defanged by the raise — they would have stayed green while testing nothing: - The single-pathological-subject refusal hard-coded 64,000 rows. At a 200,000 cap that subject now builds as an ordinary snapshot and never reaches the plan lane where the refusal lives, so the assertion silently stopped firing. It is now sized off `SYNC_RESPONDER_SNAPSHOT_BUILD_MAX_ROWS` directly. - The intrinsically-oversized-fresh-set test relied on 65,000 admitted rows exceeding the cap to force the degradation path. It now declares an explicit `maxSnapshotRows: 60_000` session budget, which forces degradation regardless of the constant — `snapshotLoadLimits` is `min(BUILD_CAP, configured)`. The fifa-shape test needs no change: it is a TTL session, so it takes the fresh-plan path regardless of raw graph size, and its `assertWindowQueriesObserved()` still passes. Added invariant tests pinning both new constants, the build-caps-below-retained- caps ordering, and that a default-budget memo resolves its build limits to the build caps (otherwise the raise would be inert). This moves the cliff from 64,000 to 200,000 raw rows; it does not remove it. `_meta` is append-mostly, so this buys headroom, not permanence — the fallback query itself still needs fixing. 169 sync-responder tests pass, 0 failures. tsc --noEmit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| await store.close(); | ||
| }); | ||
|
|
||
| it('refuses fresh-SWM plan scalar growth at the plan byte cap, independent of snapshot caps', async () => { |
There was a problem hiding this comment.
🟡 Issue: Extract the fresh-SWM plan cap test harness before this large test file sprawls further
What's wrong
This PR adds another heavyweight, bespoke scenario to a test file that is already over 1k lines. The inline query interceptor is doing too many jobs and exposes implementation details that obscure the budget concept under test, which makes future cap changes harder to maintain cleanly.
Example
The next plan-budget test will likely copy the same store.query = async (...) => { source === ...; matchAll(...); return bindings; } structure, making the file longer and coupling each test directly to query source names and SPARQL formatting.
Suggested direction
Move the source-dispatched query fake and cap-recording behavior behind a small helper such as mockFreshSwmMetaPlanQueries(...), then keep the test focused on the budget boundary it is asserting. This would remove most of the cast-heavy, stringly query plumbing from the test body.
For Agents
Look at packages/agent/test/sync-responder-swm-meta-ceiling.test.ts around the new plan-byte-cap scenario. Preserve the two assertions: plan scalar estimates refuse at FRESH_SWM_META_PLAN_MAX_BYTES_ESTIMATE, and storage response-cap translation reports the plan cap. Extract the fake fresh-SWM plan store/query interceptor into a focused helper, or split the scenario so the test body reads as setup plus assertions rather than an inline mock framework.
Summary
A context graph whose
_metacrosses the snapshot build cap falls off the in-memory admission predicate(
filterDurableMetaSnapshotRows,graph-plan.ts:3655-3756) onto the SPARQL fallback(
buildDurableMetaRowsQuery,:3459-3520). That fallback's assertion-name branch (:3507-3515) isO(candidates × assertionName-lifecycles):
?anLifecyclehas no triple-pattern correlation to?s, so per candidate subject the engine scans everylifecycle and evaluates an unindexable
STRENDS— and the entire filter is re-paid per page underORDER BY ?g ?s ?p ?o OFFSET n LIMIT 501.Measured on Base mainnet
Against
0x633E5a7C…/fifa-world-cup-2026(dmaast, 2026-08-05):_metasizeThe CG was only 1.19× over the row cap and 1.08× over the byte cap when it fell off. That's a cliff, not a
gradient — 19% more data moved it from "in-memory, milliseconds" to ">120 seconds".
The change
Raise the build caps to 200,000 rows / 96 MiB, co-tuned against measured density (474.5 B/row via
estimateStringRowHeapBytes): 200,000 × 474.5 B ≈ 90.5 MiB, just under the byte ceiling — so neither cap is deadcode (ordinary rows bind on ROWS, large-literal rows bind on BYTES).
Both stay strictly below the retained per-snapshot caps (250,000 / 128 MiB), preserving the documented
"hard build caps are intentionally lower than the retained-cache defaults" ordering. That ordering is what
guarantees a snapshot passing the build check is always admissible, rather than built and then rejected by
budget.admit().Two use sites are not graph-level snapshot sizing and must not drift with it. They move to their own constants,
at their historical values — zero behaviour change:
SYNC_RESPONDER_MAX_SINGLE_SUBJECT_ROWS = 64_000graph-plan.ts:3126)SYNC_RESPONDER_PLAN_MAX_BYTES_ESTIMATE = 32 MiBgraph-plan.ts:2968)Two existing tests were defanged — this is the part worth reviewing
Both would have stayed green while testing nothing:
as an ordinary snapshot and never reaches the plan lane where the refusal lives, so the assertion silently
stopped firing. Now sized off
SYNC_RESPONDER_SNAPSHOT_BUILD_MAX_ROWSdirectly, so it tracks the cap forever.path. Now declares an explicit
maxSnapshotRows: 60_000session budget —snapshotLoadLimitsismin(BUILD_CAP, configured), so degradation is forced regardless of the constant.The fifa-shape test needs no change: it's a TTL session, so it takes the fresh-plan path regardless of raw graph
size, and its
assertWindowQueriesObserved()still passes. (I verified this rather than assuming it.)New invariant tests pin both constants, the build-caps-below-retained-caps ordering, and that a default-budget memo
resolves its build limits to the build caps — otherwise the raise would be inert.
Memory cost
SYNC_RESPONDER_GLOBAL_CONCURRENCY = 3.readBoundedDurableMetaSnapshotmaterializes rawrows before checking
bytesEstimate. The only pre-materialization bound issnapshotResponseByteLimit(maxBytesEstimate)= 2 × 96 MiB = 192 MiB transport per query, up from 64 MiB.This is why the byte cap is 96 MiB rather than bumped to the 128 MiB retained ceiling. For fifa concretely:
34.5 MiB estimated / ~69 MiB transport.
Honest limitation
This moves the cliff from 64,000 to 200,000 raw rows; it does not remove it.
_metais append-mostly and fifais at 76,265 today, so this buys headroom, not permanence. It also raises transport and parse peaks for every lane,
not just durable meta. A follow-up PR fixing the fallback query itself is still required — this is the fleet fix,
not the design fix.
Testing
169 sync-responder tests pass, 0 failures.
tsc --noEmitclean.