Skip to content

fix(sync): raise the responder snapshot build caps, and pin the two ceilings that must not move - #2102

Merged
branarakic merged 3 commits into
mainfrom
fix/durable-meta-snapshot-build-caps
Aug 7, 2026
Merged

fix(sync): raise the responder snapshot build caps, and pin the two ceilings that must not move#2102
branarakic merged 3 commits into
mainfrom
fix/durable-meta-snapshot-build-caps

Conversation

@branarakic

Copy link
Copy Markdown
Contributor

Summary

A context graph whose _meta crosses 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) is
O(candidates × assertionName-lifecycles):

|| ( CONTAINS(STR(?s), "/assertion/") &&
     EXISTS { GRAPH ?g { ?anLifecycle <…/assertionName> ?an ; <…/memoryLayer> ?ml }
              FILTER(STRENDS(STR(?s), CONCAT("/", STR(?an)))) } )

?anLifecycle has no triple-pattern correlation to ?s, so per candidate subject the engine scans every
lifecycle and evaluates an unindexable STRENDS — and the entire filter is re-paid per page under
ORDER BY ?g ?s ?p ?o OFFSET n LIMIT 501.

Measured on Base mainnet

Against 0x633E5a7C…/fifa-world-cup-2026 (dmaast, 2026-08-05):

_meta size 76,265 rows / 11,525 subjects / 1,553 assertionName lifecycles / ~34.5 MiB
branch-9 over all subjects HTTP 500 at 120.008 s (hit a server-side query deadline)
branch-9 over its 1,553 real candidates 49.97 s
indexed replacement (2 discovery queries + JS tail match) 1.78 s, admitted sets identical 1553/1553

The 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 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 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:

New constant Guards Why pinned
SYNC_RESPONDER_MAX_SINGLE_SUBJECT_ROWS = 64_000 plan lane's single-oversized-subject refusal (graph-plan.ts:3126) whole-subject windows are the consistency unit (#1788) — a statement about row-group atomicity, not materialization size
SYNC_RESPONDER_PLAN_MAX_BYTES_ESTIMATE = 32 MiB retained ceiling for plan scalars (graph-plan.ts:2968) a plan holds subject IRIs + row counts; growing it with the snapshot ceiling would triple retained plan state for no benefit

Two existing tests were defanged — this is the part worth reviewing

Both would have stayed green while testing nothing:

  1. Single-pathological-subject refusal hard-coded a 64,000-row subject. 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. Now sized off SYNC_RESPONDER_SNAPSHOT_BUILD_MAX_ROWS directly, so it tracks the cap forever.
  2. Intrinsically-oversized-fresh-set relied on 65,000 admitted rows exceeding the cap to force the degradation
    path. Now declares an explicit maxSnapshotRows: 60_000 session budget — snapshotLoadLimits is
    min(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.

  • Retained: 3 × 96 MiB = 288 MiB, inside the 384 MiB global ceiling.
  • Parse peak (not bounded by the budget, and the real cost): readBoundedDurableMetaSnapshot materializes raw
    rows before checking bytesEstimate. The only pre-materialization bound is
    snapshotResponseByteLimit(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. _meta is append-mostly and fifa
is 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 --noEmit clean.

…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>
Comment thread packages/agent/test/sync-responder-swm-meta-ceiling.test.ts Outdated
Comment thread packages/agent/src/sync/responder/snapshot-cache.ts Outdated
Comment thread packages/agent/src/sync/responder/snapshot-cache.ts
Comment thread packages/agent/src/sync/responder/graph-plan.ts Outdated
Comment thread packages/agent/src/sync/responder/graph-plan.ts
Comment thread packages/agent/src/sync/responder/graph-plan.ts
await store.close();
});

it('refuses fresh-SWM plan scalar growth at the plan byte cap, independent of snapshot caps', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@branarakic
branarakic merged commit 1bb6b72 into main Aug 7, 2026
52 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