Skip to content

Regions: path and step budgets for real data - #510

Merged
dborup merged 8 commits into
masterfrom
fix/508-regions-budgets
Oct 20, 2026
Merged

dborup merged 8 commits into
masterfrom
fix/508-regions-budgets

Conversation

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Relates to #508.

Why

Every Regions capture and publication budget was a single global count, and real data exhausted two of them.

  • The membership capture budget (maxRegionsRoutePaths = 250000 distinct retained paths, global) ran out after 48 h of traffic (~805k observations). Once spent, every later hour reported capped membership evidence — coverage.evidence.capped = 1114 across 17 of 20 hours in #dk — which correctly withheld the Health and Timeline rates for #dk and for all scopes. The addendum on regions: Connections step cap is hit on real data; measure route-snapshot memory (follow-ups to #505) #508 treats this as a blocker.
  • The directed-step row cap (5,000) was reached at the same retention: /api/regions/routes reported steps: 5000, stepsTruncated: true, so directed steps were effectively always incomplete. A larger fixed row count would not give a payload guarantee either.
  • The membership entry budget (maxRegionsMembershipEntries = 1000000) retained one entry per (relay, transmission), so it grew with observations. The 168 h shape measured below exhausts it even before the path budget is considered.

All three scale with observations rather than with the evidence actually published, which is why 168 h of retention on meshview-pve could not come out uncapped.

Plan

  1. Make capture spend a per-hour reference budget instead of one global count, so retained capture scales with retention and one busy hour cannot starve the rest of the horizon.
  2. Intern identical observation paths, so a relay chain observed by thousands of transmissions costs one entry and is parsed and identity-resolved once per snapshot.
  3. Aggregate memberships straight into (relay, scope, hour) cells, so the budget grows with populated hours, not with observations.
  4. Budget published directed steps by estimated payload bytes shared across rows and hourly buckets, which is an explicit response-size guarantee rather than a row count.
  5. Measure the retained route/membership snapshot before and after, and expose it in the memory diagnostics.

No merge, no deployment, no staging or production access. No schema, retention, dependency or configuration change. cmd/server stays read-only.

Changes

Capture (regions_routes.go). maxRegionsRoutePaths is replaced by maxRegionsRoutePathRefsPerHour = 16384, charged to the transmission's own hourly bucket (0 through 504, the last being the current hour Latest summarises). Observation paths are interned into pathIDs/pathList (at most 131,072 distinct paths) and a transmission retains 8-byte regionsRoutePathRef{path, observations} references instead of 24-byte string entries. The path is looked up once and the per-transmission dedup is keyed on the interned id, so capture still does one string-map lookup per observation. regionsPathResolver parses and resolves each distinct path once per snapshot, cached within a 2,097,152-hop-token budget; past that budget paths are resolved uncached — slower, never truncated.

A reference costs 8 bytes against the roughly 200 the packet store already holds for the same observation, so the real bound stays the retained store rather than this safety net. The per-hour ceiling exists to keep truncation, if it ever happens, from concentrating silently in the newest hours.

Memberships (regions_evidence.go, regions_history.go). regionsMembershipBuilder aggregates into (relay, scope, hour) cells with {count, last}, budgeted at maxRegionsMembershipCells = 500000 plus the unchanged 10,000-identity cap. Nothing per transmission is retained, and a repeated transmission in an admitted cell costs no budget. regionsHistoryBuilder.add now consumes those cells. The time-range and scope-name gates move to the cell's own hour index and regions.ValidateCandidate; the advert/TRACE, planned-direct, prefix-only and unparsable-timestamp gates were already applied before capture (captureAt) and are pinned there by TestRegionRoutesStrictObservationScopeAndBounds.

Publication (regions_routes.go). maxRegionsRouteSteps/maxRegionsRouteCells are no longer independent caps but ceilings implied by one budget: maxRegionsRouteStepBytes = 16 MiB, spent at regionsRouteStepRowBytes = 180 per published row and regionsRouteStepCellBytes = 75 per hourly bucket — at most 93,206 rows or 223,696 buckets. Step endpoint metadata gets the 10,000 ceiling the OpenAPI schema already claimed but did not enforce; steps whose endpoints are omitted become stepEvidence.missingEndpointSteps, as they already did for hidden endpoints. The budget lives in a collector field so tests can exercise truncation without building a 16 MiB snapshot.

The estimates are validated against encoded JSON, not asserted: saturating the budget with one bucket per row gives 65,793 rows and 16,514,617 encoded bytes against 16,777,215 budgeted (1.6 % conservative); the cell-heavy shape gives 441 rows × 504 buckets and 16,700,362 bytes against 16,749,180 (0.3 % conservative).

Memory diagnostics (regions_memory.go, /api/perf?mem=1). New regionsSnapshot: published counts (nodes, scopes, history nodes/cells, steps, step cells, evidence cells), per-part and total retained bytes, totalMB, and estimatedStepPayloadBytes against stepPayloadBudgetBytes, plus truncated repeating the snapshot's own publication flags so a small byte figure is not read as a complete snapshot. The walk is O(published cells) over immutable published state: no SQL, no packet-store walk and no packet lock, so it is not part of the admission-controlled store scan and runs after it is released. It is absent before the first background relay snapshot. Byte figures are struct sizes plus owned string content plus a flat per-map-entry allowance — an estimate of retained heap, like the existing store breakdown, and explicitly not the transient aggregation that builds the snapshot.

Browser (public/regions-routes.js). The defensive row/cell bounds are derived from the same 16 MiB / 180 / 75 numbers instead of the retired 5,000 and 100,000, and the endpoint bound is the server's 10,000 rather than MAX_STEPS * 2. The three bounds are exported so the unit test can pin them against the server's budget.

Evidence and lock rules, unchanged

#507 semantics are preserved exactly. Capture and membership limits stay capped evidence in the affected transmission's own hour and scope, so they still fail closed for that bucket's rates and comparisons. The published-payload budget and the per-transmission pair cap stay stepEvidence.cappedStepAppearances and never reach coverage.evidence; TestRegionsEvidenceStepCapCountedSeparately and TestRegionsEvidenceMembershipBudgetIndependentOfSteps pin both directions, and a new assertion pins that payload truncation does not drop the memberships of the relays whose step row was omitted. Excluded-only data stays complete.

The #495 lock rule is unchanged: capture copies immutable strings and scalar wire evidence under the packet read lock and does only O(1) map work; path parsing, identity resolution, membership aggregation, publication and the memory diagnostic all run after the lock is released. The memory diagnostic takes repeaterEnrichMu only to read the snapshot pointer and releases it before walking.

Honesty is unchanged in the other direction too: a capped subset is a retained subset, not a ranking of all possible evidence, and truncation is never resolved by preferring better-evidenced rows.

Measurements

Apple M4 / darwin-arm64 / Go 1.26, local synthetic data, before = 45c96070. The before column was produced by applying this PR's diagnostic walk and benchmarks to 45c96070 unchanged in a throwaway worktree, so both columns measure the same thing.

168 h at the rate real data showed at 48 h — 5,200 retained path references per hour over 168 hours (873,600 references), 400 relays, BenchmarkRegions168HBudgets, median of 3:

before 45c96070 after
stepEvidence.incomplete true false
published history cells 19,600 (29 % of the horizon) 67,200
published step cells 19,600 67,200
retained snapshot bytes 2,352,350 7,294,848
aggregation allocations 307,969,648 B / 1,555,927 allocs 383,398,456 B / 1,031,883 allocs
capture + aggregation 249 ms 342 ms

The retained snapshot is 3.1× larger because it now publishes the whole 168 h instead of truncating 71 % of it; 7.3 MiB for 400 relays × 168 hours × 1 scope. Allocation count falls 34 % (paths are parsed once each), while allocated bytes and wall time rise with the 3.4× more aggregation actually performed.

Mixed-evidence 30k-transmission / 2k-relay fixture (8 scopes, direct, mismatched and unknown observations; the fixture #507 round 2 used), median of 3–6:

Workload before 45c96070 after
Whole paired background snapshot 85.1 ms; 137.60 MB; 724,593 allocs 89.6 ms; 139.33 MB; 551,884 allocs
Capture under the packet read lock 6.35 ms; 13,084,144 B; 30,027 allocs 7.42 ms; 12,925,212 B; 30,072 allocs
Warm paired response 17.3 ms; 10,868,561-byte JSON 17.8 ms; 10,868,561-byte JSON
Retained Regions snapshot 10,436,512 B (membership 722,000; history 5,482,000; steps 4,220,024; evidence 12,488) identical
measureRegionsSnapshot itself — 96 µs; 176 B; 2 allocs

This fixture fits inside the old budgets, so the published snapshot and its retained size are byte-identical before and after — the right outcome, and the reason the 168 h shape above is the meaningful comparison. Allocation count falls 24 % here too.

Disclosed cost. Capture under the read lock is about 1.07 ms slower per snapshot refresh at 30k transmissions (+17 % on an already 6.4 ms hold), from interning against a global table instead of a per-transmission one. It buys 3× smaller retained capture state (8 bytes per reference instead of 24), a bounded variant table, and one parse/resolve per distinct path instead of per (transmission, path). Snapshot refreshes are periodic background work, not per request. The response payload is unchanged at this scale and grows with retention as it did before #508; what is new is that the step ceiling is explicit, measured, and reported per snapshot.

These are noisy single-host synthetic measurements, not production latency or memory guarantees. Staging RSS is for the coordinator to measure; this PR does not touch staging or production.

Validation

  • Test-first. cmd/server/regions_budgets_508_test.go describes the redesigned budgets through the collector's and the API's public behaviour only, so it compiles and runs on 45c96070. All five points failed there for the right reason: capture truncated at 250,000 with capped=12080; 20,000 references in one hour truncated nothing; 1.3M memberships reported capped=305600; 5,000 of 6,000 directed step rows were published; mem=1 returned no regionsSnapshot. The browser bound test failed on 45c96070 too (MAX_STEPS undefined).
  • Mutants killed (one or more per point) are listed in the report comment on this PR.
  • Full cmd/server suite; -race on Region|Route|Relay|Evidence|Perf|412|470|495; sh test-all.sh (258/258); node test-frontend-helpers.js (719).
  • node test-regions-map-e2e.js (24/24) and node test-regions-map.js, plus test-issue-480-no-scope-e2e.js (25/25), the perf suites and the core browser regression (154 passed, 3 fixture-dependent skips), against a local Go server on the CI-prepared e2e-fixture.db. The live /api/perf?mem=1 response was checked against that server.
  • scripts/check-xss-sinks.sh --diff origin/master clean; fork guards unchanged (9 in deploy.yml, 1 in release-fast-path.yml); no new map[string]interface{} outside tests; no hardcoded colours.

Not done here

The response is large at 168 h retention by the design of #505's single bulk fetch, and most of it is hourly relay history rather than directed steps. Shrinking the wire format (parallel arrays or an in-hour offset instead of a full RFC3339Nano stamp per bucket) would cut the per-bucket cost about fivefold, but it changes the API and the browser's parsing and belongs in its own change. The published history cell budget (250,000) still fits 168 h comfortably and would need revisiting only for the full 21-day horizon.

🤖 Generated with Claude Code

Relates to #508.

Every Regions capture and publication budget was a single global count,
and real data exhausted two of them. The 250,000 path-variant capture
budget ran out after 48 h of traffic (~805k observations), so most hours
reported capped membership evidence and the Health and Timeline rates
were withheld. The 5,000-row directed-step cap was also reached.

- Capture now spends a per-hour reference budget (16,384) instead of one
  global count, so retained capture scales with retention and one busy
  hour cannot starve the rest of the horizon.
- Identical observation paths are interned, so a relay chain observed by
  thousands of transmissions costs one entry and is parsed and
  identity-resolved once per snapshot instead of once per transmission.
- Memberships aggregate straight into (relay, scope, hour) cells, so the
  budget grows with populated hours rather than with observations. The
  retired budget retained one entry per (relay, transmission).
- Published directed steps share one 16 MiB estimated-payload budget
  across rows and hourly buckets, which gives an explicit response-size
  guarantee instead of a fixed row count. Endpoint metadata has its own
  10,000 ceiling, matching what the API already documented.
- /api/perf?mem=1 reports regionsSnapshot: published counts and retained
  bytes for memberships, hourly history, directed steps and per-bucket
  evidence, plus the step payload actually spent against its budget.

#507 evidence semantics are unchanged: capture and membership limits are
capped evidence in the affected transmission's own hour and scope, while
the payload budget and the per-transmission pair cap stay step
appearances only. The #495 lock rule is unchanged: capture copies
immutable strings under the packet read lock, and parsing, resolution,
aggregation and the memory diagnostic all run outside it.
…dgets

Relates to #508.

Look the observation path up once in the interning table and key the
per-transmission dedup on the interned id, so capture does one string-map
lookup per observation as it did before interning instead of two.

Add the measurements the issue asks for:

- BenchmarkRegions168HBudgets captures 168 h at the per-hour rate real
  data showed at 48 h (5,200 references per hour, 400 relays) and reports
  the retained snapshot size, the aggregation's allocations and whether
  any budget truncated.
- BenchmarkRegionsSnapshotRetainedMemory reports the retained snapshot
  size for the mixed 30k/2k fixture, the paired recomputation's
  allocations and the cost of the diagnostic walk itself.
- BenchmarkRegionsRoutesCapPayload now saturates the directed-step byte
  budget in both shapes it admits (one bucket per row, and few rows with
  many buckets) and reports encoded payload against budgeted bytes, so
  the 180/75 estimates are validated against real JSON.
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#510 #508 — head 88e2ada

Status: Implemented as written, including the addendum: capture is budgeted per hour, memberships aggregate per (relay, scope, hour), directed steps are budgeted by estimated payload bytes, and the retained route/membership snapshot is measured before and after and exposed in /api/perf?mem=1. Every requirement has a test that was red on 45c96070 and green here, plus at least one killed mutant. CI per job below.

Evidence markers: [T] test, [A] measurement/analysis, [K] killed mutant.

Requirements

# Requirement Test (red → green) Mutant killed
1 Global membership path budget (maxRegionsRoutePaths = 250000) exhausted on real data → per-hour capture budget, 168 h not capped [T] TestRegionsRoutePathCaptureBudgetIsPerHour — 262,080 references spread over 504 hours. Red on 45c96070: truncated=true capped=12080. Green: no sourceTruncated, no capped, 100 step rows × 504 cells, 262,080 step appearances [K] P1a charge every hour to one counter → test fails
1b One busy hour must not starve the horizon [T] TestRegionsRoutePathCaptureBudgetFairAcrossHours — 20,000 references in one hour, one in the next. Red on 45c96070: nothing truncated at all (20,000 ≪ 250,000). Green: the busy hour is capped in bucket 0 only, bucket 1 has zero evidence and keeps its own step [K] P1b per-hour check always false → test fails
1c Identical paths cost one entry and one parse/resolve [T] TestRegionRoutesSourceAndRowBounds — 50 transmissions sharing one path give 1 variant and 50 references [K] P1c never reuse an interned path → test fails
2 Aggregate per (relay, scope, hour) before keeping paths; the per-(relay, transmission) entry budget no longer scales with observations [T] TestRegionsMembershipAggregatesPerRelayScopeHour — 20,400 transmissions × 64 relays = 1,305,600 memberships in 1,536 cells. Red on 45c96070: capped=305600 across six buckets. Green: no capped, 64 identities, 24 buckets of 850 each [K] P2a drop hour from the cell key → buckets collapse, test fails. [K] P2b count = 1 instead of count++ → bucket counts wrong, test fails. [K] P2c spend budget for an already-admitted cell → TestRegionsEvidenceMembershipBudgetIndependentOfSteps fails
3 Directed-step cap hit on real data → size-based budget with a measured payload limit; bounded payload guarantee kept [T] TestRegionsRouteStepsBudgetedByPayloadBytes — 6,000 distinct rows published untruncated (red on 45c96070: 5,000 of 6,000, truncated=true), and 600 rows × 450 hours publishes ≥ 200,000 cells (red: capped at 100,000) while staying inside 16 MiB and disclosing stepsTruncated/cappedStepAppearances. [T] TestRegionRoutesActualStepPayloadBound spends the budget exactly and is order-independent [K] P3a reinstate a 5,000-row cap → test fails. [K] P3b stop charging hourly cells → TestRegionRoutesActualStepPayloadBound fails
4 Measure retained route/membership snapshot memory and add it to the memory diagnostics [T] TestPerfMemRegionsSnapshotMemory — /api/perf?mem=1 returns regionsSnapshot with the published shape, per-part bytes summing to the total, and a generation time. Red on 45c96070: field absent [K] P4a report 0 steps/step cells → test fails. [K] P4b never publish the diagnostic → test fails
5 Keep #507 evidence semantics (excluded/unprovable/capped per bucket and scope) [T] TestRegionsEvidenceStepCapCountedSeparately — the payload budget is cappedStepAppearances only, membership coverage stays clean and both relays keep their memberships. [T] TestRegionsEvidenceMembershipBudgetIndependentOfSteps, TestRegionsEvidenceIncompletenessIsPerBucketAndScope, TestRegionsEvidenceExcludedOnlyIsComplete unchanged and green Covered by P2c and P3a/P3b above
6 Keep the #495 lock rule [A] Capture copies immutable strings and does only O(1) map work under the packet read lock; path parsing, identity resolution, aggregation and publication run after s.mu.RUnlock(). The diagnostic takes repeaterEnrichMu only to read the snapshot pointer, releases it, then walks immutable state — no SQL, no packet walk, no packet lock. [T] -race on Region|Route|Relay|Evidence|Perf|412|470|495 green —
7 Client bounds must not discard what the server may now publish [T] test-regions-map.js — exported MAX_STEPS/MAX_CELLS/MAX_STEP_NODES pinned to the server budget, and 6,000 rows indexed without limited. Red on 45c96070: MAX_STEPS undefined [K] P5 revert the client bound to 5,000 → test fails

Measurements

Apple M4 / darwin-arm64 / Go 1.26, local synthetic data. "Before" is 45c96070 with this PR's diagnostic walk and benchmarks applied unchanged in a throwaway worktree, so both columns measure the same thing.

168 h at the per-hour rate real data showed at 48 h (5,200 references/hour × 168 hours = 873,600, 400 relays), median of 3 [A]:

before 45c96070 after
stepEvidence.incomplete true false
published history cells 19,600 (29 % of the horizon) 67,200
published step cells 19,600 67,200
retained snapshot bytes 2,352,350 7,294,848
aggregation allocations 307,969,648 B / 1,555,927 allocs 383,398,456 B / 1,031,883 allocs
capture + aggregation 249 ms 342 ms

The snapshot retains 3.1× more because it now publishes the whole 168 h instead of truncating 71 % of it: 7.3 MiB for 400 relays × 168 hours × 1 scope. Allocation count falls 34 % (one parse per distinct path); allocated bytes and wall time rise with the 3.4× more aggregation actually performed.

Mixed-evidence 30k-transmission / 2k-relay fixture (the one #507 round 2 used), median of 3–6 [A]:

Workload before 45c96070 after
Whole paired background snapshot 85.1 ms; 137.60 MB; 724,593 allocs 89.6 ms; 139.33 MB; 551,884 allocs
Capture under the packet read lock 6.35 ms; 13,084,144 B; 30,027 allocs 7.42 ms; 12,925,212 B; 30,072 allocs
Warm paired response 17.3 ms; 10,868,561-byte JSON 17.8 ms; 10,868,561-byte JSON
Retained Regions snapshot 10,436,512 B (membership 722,000; history 5,482,000; steps 4,220,024; evidence 12,488) identical
measureRegionsSnapshot itself — 96 µs; 176 B; 2 allocs

This fixture fits inside the old budgets, so the published snapshot and its retained size are byte-identical before and after — the expected result, and why the 168 h shape is the meaningful comparison.

Payload estimate validated against encoded JSON, not asserted [A]: saturating the budget with one bucket per row gives 65,793 rows and 16,514,617 encoded bytes against 16,777,215 budgeted (1.6 % conservative); the cell-heavy shape gives 441 rows × 504 buckets and 16,700,362 bytes against 16,749,180 (0.3 % conservative).

Disclosed cost [A]: capture under the read lock is ~1.07 ms slower per snapshot refresh at 30k transmissions (+17 % on an already 6.4 ms hold), from interning against a global table instead of a per-transmission one. It buys 3× smaller retained capture state (8 bytes per reference instead of 24), a bounded variant table, and one parse/resolve per distinct path. Refreshes are periodic background work, not per request.

These are noisy single-host synthetic numbers, not production latency or memory guarantees. No staging or production access; staging RSS remains for the coordinator to measure.

CI — run 39373142353 on 88e2adab

All jobs passed on attempt 1, no reruns. The known-flaky #271 suite was not involved.

Job Result
Go Server Tests (1/3) success
Go Server Tests (2/3) success
Go Server Tests (3/3) success
Go Ingestor Tests success
Go Libraries & CLI success
Verify Go Test Assignments success
JavaScript Unit Tests & Lint success
Script & Container Unit Checks success
Playwright E2E Tests (1/3) success
Playwright E2E Tests (2/3) success
Playwright E2E Tests (3/3) success
Verify E2E Shards & Coverage success
Validate Docker Image success
Build & Publish Docker Image success
Go Build & Test (final gate) success
Release Artifacts skipped
Deploy Staging skipped
Publish Badges & Summary skipped

Local validation

  • Full cmd/server suite green [T]; -race on Region|Route|Relay|Evidence|Perf|412|470|495 green [T].
  • sh test-all.sh 258/258 [T]; node test-frontend-helpers.js 719 [T].
  • Against a local Go server on the CI-prepared e2e-fixture.db (freshen, migrate, the four seed files and the inline grouped/GRP_DATA rows) [T]: test-regions-map-e2e.js 24/24, test-regions-map.js, test-issue-480-no-scope-e2e.js 25/25, test-perf-go-runtime.js 30, test-xss-392-perf-e2e.js 1, test-perf-disk-io-1120.js 15, test-perf-anomaly.js 3, and the core browser regression 154 passed / 3 fixture-dependent skips. The live /api/perf?mem=1 response was inspected on that server.
  • scripts/check-xss-sinks.sh --diff origin/master clean [T]; fork guards unchanged — 9 in deploy.yml, 1 in release-fast-path.yml [A]; no new map[string]interface{} outside tests [A]; no hardcoded colours [A]; cmd/server read-only, no schema, retention, dependency or configuration change [A].

Residuals

  1. The response is still large at 168 h retention, and most of it is hourly relay history rather than directed steps — a property of feat(regions): explore shared history and observed directed routes #505's single bulk fetch, not of this change. Cutting the per-bucket cost roughly fivefold is possible (parallel arrays, or an in-hour offset instead of a full RFC3339Nano stamp per bucket) but changes the API and the browser's parsing, so it belongs in its own change. Not filed.
  2. The published history cell budget (250,000) fits 168 h comfortably (400 relays × 2 scopes × 168 hours ≈ 134,000) but not the full 21-day horizon (≈ 386,000). Left as is; the honest bucketsTruncated flag still applies. Not filed.
  3. Whether a given instance caps now depends on real row and cell counts rather than on an arbitrary number. The flags and regionsSnapshot.estimatedStepPayloadBytes against stepPayloadBudgetBytes make that observable per snapshot, which is the point — but the 16 MiB ceiling is a judgement call derived from the measured 48 h shape, not a proof that meshview-pve will fit.
  4. Three history-builder guards moved layers. Advert/TRACE payloads, planned direct paths, prefix-only evidence and unparsable timestamps can no longer reach the hourly history builder — they are excluded before capture, pinned by TestRegionRoutesStrictObservationScopeAndBounds. The removed assertions in TestRegionsHistoryBucketsAndScopeProvenance were testing data that the aggregated input cannot express; the horizon and scope-name gates stay at that layer.
  5. A comment nit. pathRefs()'s doc comment says "for the memory diagnostics and the capture benchmarks"; it is used by the capture tests and benchmarks only, not by the diagnostic. Left as is rather than restarting a green CI run for a comment.
  6. Per-scope budgets were not added. The issue offered per-scope or per-hour budgets; per-hour was chosen for capture because #dk dominates our traffic, so a per-scope split would not have relieved the scope that was actually capped. Directed steps use the byte budget instead. Not filed.

dborup and others added 4 commits October 20, 2026 08:54
…eadroom

Round 1 replaced the global 250,000-reference budget with a per-hour one but
left the 131,072-entry path interning table as a single global ceiling over
the whole 504 h horizon. Once full it refused every new path string and
reported it as capped evidence in whichever hour the transmission fell in,
which is the starvation #508 reported, at a lower ceiling. Real path diversity
reaches it on ordinary traffic: e2e-fixture.db, real captured traffic, holds
376 distinct path strings in 399 path-carrying observations.

The table is now a bounded dedup cache. Past it a path is retained
un-interned — a path slot and a reference instead of a map entry — so the only
refusal left is the per-hour reference budget, which is fair across the
horizon. That budget is raised to 65,536, about four times the ~16,800
observations/hour 48 h of real traffic averaged across all scopes, so even a
peak hour has headroom if every observation were transport-scoped with a
distinct path.

/api/perf?mem=1 now reports regionsSnapshot.capture: retained paths and how
many are interned, retained references with the per-hour high-water mark,
retained transmissions and aggregated membership cells and identities, each
against its budget, plus an estimate of the capture state's bytes. Those are
the budgets that decide `capped`, and publication sizes did not show their
headroom. regionsSnapshot.truncated is now budget truncation only;
evidenceIncomplete carries the evidence qualification separately, because
unresolved hops and unprovable observations raise it on ordinary data.

The published step-endpoint ceiling gets a test-only override so the ceiling
and its missingEndpointSteps disclosure can be exercised, and the 168 h
benchmark gains a distinct-path shape with shuffled capture order next to the
shared-path one. Docs disclose the transient capture cost per retained row,
the per-hour ceiling's horizon product and what actually binds first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transient-capture section gave the per-hour budget's horizon product for
references and path slots but left out the retained transmission headers,
which are the largest term. State the whole nominal 3.4 GiB figure, why it is
unreachable, what the arithmetic gives at the rate real data showed, and the
measured 168 h synthetic figure next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…imate

The transient-capture disclosure compared the capture state against ~100 bytes
per observation and ~1,000 per transmission. Those were the load-time
conservative floor, not what the store actually accounts for: the store's own
estimate, the one packetStore.maxMemoryMB divides by, is 3,214 bytes for a
transmission with one observation and 8,506 for one with ten. Capture is
therefore 3-5 % of the store state for the rows it mirrors, not ~40 %, and the
nominal horizon ceiling needs a store on the order of a hundred gigabytes
rather than ten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#510 runde-2 — head 2ba5e05

Review feedback addressed (commit 05a08f83, with two disclosure corrections in 39888732 and 2ba5e052). origin/master (f56ef97c) merged in as 483b4af4. Ordinary commits, no force-push, PR stays a draft.

Evidence markers: [T] test, [A] measurement/analysis, [K] killed mutant.

Findings

  1. F1 (blocker) — the variant table was one global ceiling over the 504 h horizon. Confirmed, and fixed by removing the refusal rather than by resizing it. The interning table (131,072 entries) is now a bounded dedup cache: past it a path is retained un-interned — a path slot and a reference instead of a map entry — so the only refusal left in capture is the per-hour reference budget, which is fair across the horizon by construction. That budget is raised from 16,384 to 65,536, about four times the ~16,800 observations/hour that 48 h of real traffic averaged across all scopes.
    [T] TestRegionsRoutePathCaptureBudgetAtRealPathDiversity — 168 h × 1,000 distinct 5-hop chains (168,000 variants, above the retired table), shuffled into production's map-iteration order. Red with round-1 behaviour: truncated=true capped=36928, which is exactly the figure the review's probe reported. Green here: nothing capped, each of the 168 hours keeps clean evidence, 20 relays × 168 hourly buckets retained, no publication truncation.
    [K] restore the refusal when the table is full → that test and the fallback test fail.

  2. F2 — benchmark and acceptance test used 400 paths, not realistic diversity or rate. The acceptance test above now gives every transmission its own chain. The fixture's real diversity is 376 distinct path strings in 399 path-carrying observations (94 %); the tests use 100 % distinct, the harder case. [T] TestRegionsRouteCaptureBudgetHeadroomOverMeasuredRate pins the per-hour budget at ≥ 2× the measured 16,770 observations/hour and pins the cost model the disclosure rests on against the actual struct sizes. [A] BenchmarkRegions168HBudgets now runs two shapes, both shuffled: shared-paths (400 chains, the round-1 shape) and distinct-paths (873,600 distinct 5-hop chains at 5,200/hour over 168 h). Numbers below.

  3. F3 — capture-budget headroom was not observable. /api/perf?mem=1 now reports regionsSnapshot.capture: retained paths and how many are interned against the table size, retained references with the per-hour high-water mark against the per-hour budget, retained transmissions, aggregated membership cells and identities against their budgets, an estimate of the capture state's bytes, and the capture budget's own sourceTruncated. [T] TestRegionsSnapshotCaptureHeadroomMeasured, plus assertions in the two capture tests. [T] the live response was inspected on a local server over the CI-prepared fixture (all zeros — that fixture has no transport-routed rows, as the review also found). [K] two mutants: the per-hour high-water mark never reported; the capture stats never attached to the published snapshot.

  4. F4 — M5, M6 and M10 now have tests.
    [T] M5: TestRegionsRoutePathInterningFallsBackUnInterned — a collector with a 4-entry table takes 40 distinct paths, interns exactly 4, refuses none, publishes all 40 rows and still shares the interned entry for a repeat. A disabled bound interns 40 and fails it.
    [T] M6: TestRegionsRouteStepEndpointCeilingDisclosed — the 10,000-endpoint ceiling and its missingEndpointSteps disclosure, through the real handler, via a test-only Server.stepEndpointLimit seam (the same pattern as the existing collector.stepBytes, and the only production-visible change is that the limit is read from a field that is zero in production).
    [T] M10: TestRegionsSnapshotTruncatedIsBudgetTruncationOnly.

  5. F5 — budget truncation separated from evidence incompleteness. regionsSnapshot.truncated is now the hourly-history and directed-step publication flags plus the capture budget's own sourceTruncated. The new regionsSnapshot.evidenceIncomplete carries the evidence qualification, so nothing is lost. [T] the same test covers all three cases: an unresolved hop gives truncated=false, evidenceIncomplete=true; a spent publication budget and a spent capture budget each give truncated=true. [K] OR-ing evidence.Incomplete back in fails it.

  6. F6 — the transient capture ceiling is documented. docs/regions-observed.md gains a Transient capture memory section with the per-row cost (8 bytes per reference, 24 per retained path, 48 more if it got a table entry, 72 per retained transmission header; path content is shared with the store rather than copied), the per-hour budget's nominal full-horizon product (33.1 M references → 265 MiB of references, up to 794 MiB of path slots, 6 MiB of table and up to 2.4 GiB of transmission headers, about 3.4 GiB in total), why that is unreachable, the arithmetic at the rate real data showed (168 h × ~16,800 observations/hour, all transport-scoped and all distinct → about 300 MiB) and the measured 97 MiB of the synthetic 168 h snapshot. docs/api-spec.md and the OpenAPI /api/perf description document capture, truncated and evidenceIncomplete.
    Two corrections to my own first draft of that section, both pushed: it priced the horizon product without the transmission headers, which are its largest term (39888732); and it compared capture against ~100 bytes per observation and ~1,000 per transmission, which is the load-time conservative floor rather than what the store accounts for. [A] estimateStoreTxBytesTypical, the figure packetStore.maxMemoryMB actually divides by, is 3,214 bytes for a transmission with one observation and 8,506 for one with ten, so capture is 3–5 % of the store state for the rows it mirrors, not ~40 %, and the nominal ceiling needs a store on the order of a hundred gigabytes rather than ten (2ba5e052). The review's ~66 MB priced references only.

  7. F7 (nit) — fixed. pathRefs()'s doc comment now says what uses it (the capture tests and benchmarks).

Where I did not do what was asked

The task asked for the budget per hour and scope. I made it per hour only, and did not add a per-scope split. With the un-interned fallback the only refusal left is the per-hour reference budget, set at ~4× the measured total observation rate across all scopes, so a scope flood cannot starve #dk without also quadrupling the instance's whole ingest rate — and when it does, the damage is confined to the hour it happened in rather than spreading over the horizon, which was F1's actual defect. Both shapes of a per-scope split are worse for this data: a budget per (hour, scope) multiplies the horizon ceiling by the scope count, and scope names are attacker-chosen; a reserved share inside the hour guarantees each scope only 1/N of it, which at N=8 is 8,192 references/hour — below #dk's own measured rate — unless surplus sharing is layered on top, which reintroduces first-come behaviour inside the hour anyway. Not filed.

Measurements

Apple M4 / darwin-arm64 / Go 1.26, local synthetic data, median of 3, -benchtime 1x. "Before" is this PR's benchmark with round-1 capture behaviour restored in place (the global table refusal and the 16,384 per-hour budget), so both columns measure the same code path.

168 h, one distinct 5-hop chain per transmission, 5,200/hour (873,600 transmissions), shuffled capture order [A]:

before (round 1) after
stepEvidence.incomplete true false
retained path variants 131,072 (table full) 873,600
max per-hour references 853 5,200
estimated capture state 19,922,944 B 97,145,856 B
published step cells 61,096 63,840
retained snapshot bytes 3,478,474 3,610,188
capture + aggregation 684 ms 1.90 s
aggregation allocations 224.7 MB / 1,819,087 allocs 1,303 MB / 11,700,929 allocs

The extra time and allocation are the work the old ceiling was discarding: it parsed and resolved 131,072 of 873,600 chains and capped the remaining 85 %. Refreshes are periodic background work, not per request.

168 h at 5,200 references/hour over 400 shared chains [A] — byte-identical before and after (383,398,568 B / 1,031,883 allocs aggregation, 7,294,848 B retained, 67,200 history cells, 67,200 step cells, nothing incomplete, 69,916,800 B of capture state). The shape round 1 measured is untouched; only the diverse shape changed.

Fixture path diversity [A]: test-fixtures/e2e-fixture.db, real captured traffic over 2.13 h — 500 observations, 399 with a non-empty path, 376 distinct path strings (94 %), mean path length 39 bytes. It has no transport-routed rows, so the transport-scoped share of that diversity is still unverified.

These are noisy single-host synthetic numbers, not production latency or memory guarantees. No staging or production access was used.

Mutants [K]

Run against Region|Route|Relay|Evidence|Perf|508|510|412|470|495.

# Mutation Result Killed by
R1 interning-table bound disabled (M5) killed …InterningFallsBackUnInterned
R2 un-interned fallback refuses when the table is full (F1) killed …AtRealPathDiversity, …FallsBackUnInterned
R3 step-endpoint ceiling always admits (M6) killed …StepEndpointCeilingDisclosed
R4 regionsSnapshot.truncated ignores route flags (M10) killed …TruncatedIsBudgetTruncationOnly
R5 regionsSnapshot.truncated ORs evidence.Incomplete (F5) killed …TruncatedIsBudgetTruncationOnly
R6 per-hour high-water mark never reported (F3) killed …CaptureHeadroomMeasured, …AtRealPathDiversity
R7 capture stats never attached to the snapshot (F3) killed 3 tests
R8 per-hour budget back to 16,384 (F2) killed …BudgetHeadroomOverMeasuredRate
R9 membership cell budget never spent (#507 regression guard) killed TestRegionsEvidenceMembershipBudgetIndependentOfSteps

Local validation

  • Full cmd/server suite green [T]; -race on Region|Route|Relay|Evidence|Perf|508|510|412|470|495 green [T].
  • sh test-all.sh 259/259 [T]; node test-frontend-helpers.js 719 [T]; node test-regions-map.js passed [T].
  • Against a local Go server on port 13860 over the CI-prepared e2e-fixture.db (freshen, the inline grouped/GRP_DATA rows, corescope-migrate, then seeds 2073, 199, 245 and 122) [T]: test-regions-map-e2e.js, test-issue-480-no-scope-e2e.js 25/25, test-perf-go-runtime.js 30/30, test-xss-392-perf-e2e.js 1/1, test-perf-disk-io-1120.js 15/15, test-perf-anomaly.js 3/3, test-e2e-playwright.js 154 passed / 3 skipped. The local E2E needs a .git-commit file for the Version/Perf check, as the review found; it was removed afterwards, along with the built binaries.
  • scripts/check-xss-sinks.sh --diff origin/master clean [T]; .github untouched [A]; no new map[string]interface{} outside tests [A]; no hardcoded colours and no frontend change in round 2 [A]; cmd/server stays read-only — no schema, retention, dependency or configuration change [A].
  • TestRepeaterRelayInfoMap_ConcurrentIngestAndEviction, which the review saw flake under parallel load, passed in every run here, including the nine mutant runs [A].
  • fix(regions): exclude planned paths from observed history #507 evidence semantics and the perf(store): move runtime SQL outside packet-store locks #495 lock rule hold: capture still does only O(1) map work on collector-owned state under the packet read lock, and captureStats runs with the rest of build() after the unlock [A], -race green [T].

CI — run 39385839927 on 39888732

All jobs passed on attempt 1, no reruns. The known-flaky #271 and #301 suites did not fire.

Job Result
Go Server Tests (1/3) success
Go Server Tests (2/3) success
Go Server Tests (3/3) success
Go Ingestor Tests success
Go Libraries & CLI success
Verify Go Test Assignments success
JavaScript Unit Tests & Lint success
Script & Container Unit Checks success
Playwright E2E Tests (1/3) success
Playwright E2E Tests (2/3) success
Playwright E2E Tests (3/3) success
Verify E2E Shards & Coverage success
Validate Docker Image success
Build & Publish Docker Image success
Go Build & Test (final gate) success
Release Artifacts skipped
Deploy Staging skipped
Publish Badges & Summary skipped

Residuals

  1. Real transport-scoped path diversity and per-hour rate are still unmeasured. F1/F2 are demonstrated synthetically and argued from the fixture's unscoped diversity. The headroom is now observable per snapshot through regionsSnapshot.capture, which is the part an operator can act on, but whether a given instance caps is still a question about its own traffic. No staging or production access. Not filed.
  2. The nominal capture ceiling is large (about 3.4 GiB over the full 504 h horizon) even though it is unreachable while the packet store is memory-bounded. Making it small would mean either capping real diversity again or making capture cheaper per row — dropping the retained transmission header to a packed record, which is the largest term. That is a separate change. Not filed.
  3. Round-1 residuals 1, 2, 4 and 6 stand: the 168 h response is still large and mostly hourly relay history; the published history cell budget fits 168 h but not the full 21 days; three history-builder guards moved to captureAt; the 16 MiB step payload ceiling remains a judgement call from the measured 48 h shape.
  4. Server.stepEndpointLimit is a test-only field. It is read on every routes request, which is a field load; the alternative was a 10,001-node fixture. Noted rather than hidden.

@dborup

dborup commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Staging verification — master f56ef97c + #510 + #511 (three restarts)

Staging config: retentionHours 48, hotStartupHours 6, maxMemoryMB 1800, packetDays 30, background loading. Each round waited for loading to finish plus one relay recompute (5.5 min).

== round 1 at 08:49
 loadCoverageRatio 0.9990375360923965 bgDone True evicted 326
 regions coverage.start None loading False evidence capped/unprovable/excluded 1077 0 7 truncated False
 routes steps 6303 stepsTruncated False
 node activity24h complete False None
mem 1.796GiB / 2.734GiB
 errors: 0
 restarted
== round 2 at 09:01
 loadCoverageRatio 0.9987226468432625 bgDone True evicted 243
 regions coverage.start None loading False evidence capped/unprovable/excluded 1068 0 7 truncated False
 routes steps 6345 stepsTruncated False
 node activity24h complete False None
mem 1.702GiB / 2.734GiB
 errors: 0
 restarted
== round 3 at 09:12
 loadCoverageRatio 1.000188371216878 bgDone True evicted 313
 regions coverage.start None loading False evidence capped/unprovable/excluded 1096 0 7 truncated False
 routes steps 6319 stepsTruncated False
 node activity24h complete False None
mem 1.757GiB / 2.734GiB
 errors: 0

[exited with code 0]

#511 / #509 is not fixed on this configuration. Regions coverage.start stays null and node activity24h.complete stays false in all three rounds, including round 3 where loadCoverageRatio is 1.0002. The round-2 review noted that the retention floor is pinned only on the sync path (mutant R2 survives). Staging uses the background path, so that path, or a gate other than the ratio, still blocks the proof.

#510 / #508 is partly fixed. Directed steps are no longer truncated (about 6,300 steps, stepsTruncated: false). But coverage.evidence.capped is still about 1,070–1,100 (all in #dk), so the #dk and "all scopes" rates are still withheld. Something in membership capture still caps on real data. Candidates are the per-transmission distinct-path limit (128), the path-bytes limit (8192) and the membership entries/nodes limits. Identify which one with the new headroom diagnostics.

Memory is fine: RSS 1.70–1.80 GiB with a 2.73 GiB cap. There are 0 panic, fatal, concurrent map or SQLITE_BUSY entries.

Both PRs need another round before merge.

…ling that caps

Staging on real data reported directed steps complete but coverage.evidence
.capped still 1,068-1,096, all in #dk, so the Health and Timeline rates stayed
withheld. Two capture ceilings reach that shape, and neither was observable.

The per-transmission ceiling of 128 distinct observation paths is the binding
one. A flood packet on a 65-observer mesh offers hundreds of distinct relay
chains, and the surplus was counted as capped in the transmission's own hour
and scope. It was never a memory bound: every retained path already spends one
of its hour's 65,536 references, so the per-transmission ceiling only decides
what share of an hour one transmission may take. It is now 4,096, a sixteenth
of that hour, which at 65 observers still allows 63 chains each.

Same-transmission dedup also stopped past the interning table, which is full
for most of a 48 h capture, so every repeat of one chain took its own slot,
reference and unit of the per-transmission budget. The per-transmission `seen`
map is keyed by the path string now instead of the interned id.

regionsSnapshot.capture gains the per-transmission and path-byte high-water
marks and attributes every capped observation to the ceiling that refused it,
so a withheld rate names its own cause instead of being bisected.
…stable

The fixture padded path i with i spaces, so with the per-transmission
ceiling raised the longest path approached maxRegionsRoutePathBytes and
the bound under test would have been masked by the byte ceiling. The
padding now encodes i as JSON whitespace, which is logarithmic in i and
parses to the same two hops.
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#510 runde-3 — head b4f45af

Review feedback addressed (commits ac7f3707, b4f45af7)

Evidence markers: [T] a test or probe I ran, [A] analysis, [K] a mutant I ran.
Base is origin/master f56ef97c, already merged into this branch as 483b4af4; no new merge was needed. No staging or production access was used.

1. Which ceiling caps membership capture on real data

coverage.evidence.capped is fed from exactly four places, and the staging probe could not tell them apart. Two are now excluded by arithmetic, one is excluded by the wire format, and the remaining one is reached directly by a 65-observer mesh [A]:

Candidate Verdict
per-hour reference budget (65,536) Ruled out. 48 h at the measured ~16,800 observations/hour across all scopes is 26 % of one hour, and only transport-scoped observations spend it. An overflow would also scale with the hour's traffic, not sit at 1,068/1,077/1,096 across three separate windows.
membership cells (500,000) Ruled out. 48 h x relays x scopes: even 1,000 relays in 4 scopes is 192,000 cells.
membership identities (10,000) Ruled out. It needs 10,000 distinct confirmed relay identities in the horizon.
overlong hop chain (> 64 tokens) Ruled out. MeshCore's MAX_PATH_SIZE is 64, so a valid frame cannot carry more.
path JSON > 8,192 bytes Unlikely. The longest path_json in test-fixtures/e2e-fixture.db's 500 real observations is 260 bytes, 31x under the ceiling [T].
distinct paths per transmission (128) The cause. One flood packet on a mesh with 65 observers offers hundreds of distinct relay chains, and every chain past the 128th was counted capped in the transmission's own hour and scope. It is the only candidate whose natural magnitude is a few hundred to a few thousand per 48 h.

Two things made it worse and hid it:

  • Same-transmission dedup stopped past the interning table (round-2 review N1). captureAt consulted seen only for interned paths, and the table is full for most of a 48 h capture, so each repeat of one chain inside a transmission took its own slot, its own reference and its own unit of the 128.
  • Nothing reported the per-transmission fill. regionsSnapshot.capture reported the table, the hour and the membership budgets, but not this one. Worse, the staging probe's truncated field is coverage.evidence.truncated — the evidence cell builder's flag — not the capture refusal regionsSnapshot.truncated, so a capture refusal was invisible from that output [A].

Repro [T]. BenchmarkRegions48HFloodFanout builds the staging shape: 48 h, 38,400 transmissions, ~17,560 observations/hour, 5 % of them floods heard by 65 observers over 4 distinct chains each plus one repeat per observer, chains drawn from a 512-relay pool and shifted per transmission, the interning table left at its real size so it fills part way through. On round-2 behaviour it reports 360,839 capped observations with stepsTruncated false — the staging symptom exactly, at synthetic magnitude. On head it reports 0.

2. The fix

  1. maxRegionsRoutePathsPerTx 128 → 4,096. It was never a memory bound: every retained path already spends one of its hour's 65,536 references, which is what bounds the snapshot. It only decides what share of an hour one transmission may take, and 4,096 is a sixteenth of it — at 65 observers, 63 chains each. TestRegionsRouteTransmissionPathBudgetHeadroomOverObservers pins both sides: at least 16 chains for each of staging's 65 observers, and never more than an eighth of an hour's references, so one transmission cannot starve the rest [T].
  2. Per-transmission dedup is keyed by the path string, not by the interned id, so a repeat shares one slot, one reference and one budget unit whether or not the chain is in the table (N1). It is also marginally cheaper: a repeat now costs one string-map lookup instead of two [T], see Measurements.
  3. regionsSnapshot.capture attributes every refusal. New: maxTransmissionPaths/transmissionPathBudget and maxPathBytes/pathByteBudget for headroom, and cappedPathBytes, cappedTransmissionPaths, cappedHourReferences, cappedOverlongPaths — which together account for the snapshot's cappedObservations — plus cappedMembershipCells and cappedMembershipNodes for dropped memberships, which qualify a bucket without being counted among its observations. A withheld rate now names its own cause instead of being bisected. The membership cell and identity ceilings became collector fields for the same reason internBound and stepBytes already are: so the counters are testable without a 10,000-relay mesh.
  4. Docs. docs/regions-observed.md gains the per-transmission ceiling, its rationale and the attribution counters, and round-2 N3 is fixed: the horizon figures were decimal numbers labelled MiB. Correct: 253 MiB of references, 758 MiB of path slots, 6 MiB of table, 2.22 GiB of transmission headers, 3.2 GiB total, not 3.4. docs/api-spec.md and the OpenAPI /api/perf text list the new fields.
  5. Round-2 N2 fixed. TestRegionsSnapshotCaptureHeadroomMeasured pinned only estimatedBytes > 0; it now pins the exact sum, so dropping the transmission-header term fails (mutant M11).
  6. The per-transmission source-bound fixture is length-stable now (b4f45af7). TestRegionRoutesSourceAndRowBounds padded path i with i spaces, so with the ceiling raised its longest path would approach maxRegionsRoutePathBytes and the bound under test would be masked by the byte ceiling — at 8,193 it already was [T]. The padding encodes i as JSON whitespace instead, which is logarithmic in i and parses to the same two hops.
  7. Round-2 N4 is an observation about TestRepeaterRelayInfoMap_ConcurrentIngestAndEviction flaking under CPU contention. It exists unchanged on master and passed in every run here, including all 15 mutant runs [A]. Nothing to change in this PR.

What the task asked for that I did differently

The task offered "count membership per (relay, scope, time) without storing each unique path per transmission, or raise and restructure the limit with measured memory". Memberships have been aggregated per (relay, scope, hour) since round 1 — regionsMembershipBuilder retains nothing per transmission. The per-transmission path list is not membership state; it is capture staging, and it has to exist because path parsing and identity resolution cannot run under the packet read lock (#495). So I took the second option: raised the ceiling, made it a fairness share of the hour rather than a standalone number, removed the duplicate inflation, and made the fill measurable.

Measurements

Apple M4 / darwin-arm64 / Go 1.26, local synthetic data, -benchtime 1x (single run each, so the timings are noisy).

BenchmarkRegions48HFloodFanout — 48 h, 38,400 transmissions, 5 % full-mesh floods [A]:

A: round 2 (128, id-keyed dedup) B: dedup fixed, 128 C: 4,096, dedup reverted D: head
capped observations 360,839 316,800 0 0
cappedTransmissionPaths 360,839 316,800 0 0
maxTransmissionPaths 128 (at the ceiling) 128 325 (duplicates counted) 260 (the true distinct count)
retained path variants 448,404 448,285 807,897 705,956
max per-hour references 9,680 9,680 17,361 14,960
capture state 23.5 MB 23.5 MB 35.0 MB 31.7 MB
retained snapshot bytes 21,715,628 21,926,584 22,635,694 22,635,694
published steps 54,212 55,580 60,200 60,200
capture + aggregation 512 ms 532 ms 794 ms 745 ms
allocations 413.8 MB / 4.44 M 424.8 MB / 4.45 M 714.3 MB / 7.74 M 644.6 MB / 6.84 M

A→D is the fix: 360,839 capped observations become 0. B isolates the ceiling (the dedup fix alone does not help, because 260 distinct chains still exceed 128). C→D isolates the dedup fix: 101,941 fewer path slots (-12.6 %), 2,401 fewer per-hour references (-14 %), 3.26 MB less capture state, and an honest high-water mark of 260 instead of 325. The extra time and allocation over round 2 are the work the old ceiling was discarding; refreshes are periodic background work, not per request.

Capture-path cost, BenchmarkRegionsRoutesCapture30K2K, -benchtime 10x -count=5 [T]: head 7.31–7.44 ms (median 7.37 ms), round-2 dedup 7.63–8.12 ms (median 7.82 ms), allocations identical (12,925,29x B / 30,072). The string-keyed dedup is not a regression; it saves a lookup on repeats.

BenchmarkRegions168HBudgets [T]: byte-identical to round 2 — shared-paths 69,916,800 B of capture state, 400 variants, 7,294,848 B retained, 383,399,320 B allocated; distinct-paths 97,145,856 B of capture state, 873,600 variants, 3,610,188 B retained, 1,303,057,128 B allocated, nothing incomplete. No regression on either shape.

Worst case for one transmission [A]: 4,096 references (8 B) plus up to 4,096 path slots (24 B) = 131 KB retained, all of it already charged to that hour's 65,536-reference budget, plus a transient per-call map[string]int of the same cardinality that is garbage when the transmission is done.

Live diagnostics [T]: /api/perf?mem=1 on the local fixture server returns all new regionsSnapshot.capture fields, with transmissionPathBudget: 4096 and pathByteBudget: 8192 and zeros elsewhere, as expected for a fixture with no transport-routed rows.

Tests

Red before green [T], with round-2 behaviour restored in place:

Test Round-2 failure
TestRegionsRouteCaptureDedupesRepeatedPathsUnInterned un-interned duplicates were retained separately: PathVariants:200 PathReferences:200 (want 1 and 1)
TestRegionsRouteCaptureAdmitsFloodHeardByManyObservers a flood heard by 65 observers over 260 chains was capped: truncated=true capped=165
TestRegionsRouteTransmissionPathBudgetHeadroomOverObservers per-transmission path budget 128 leaves under 16 chains for each of 65 observers
TestRegionsSnapshotCaptureAttributesCappedObservations (6 sub-cases) the per-reason fields do not exist on round-2 code; mutants M3–M8 and M10 show each one bites
TestRegionsSnapshotCaptureHeadroomMeasured (N2) pinned only > 0; mutant M11 survived before, is killed now

Local validation:

  • cd cmd/server && go test ./... -count=1: ok, 57.8 s [T]
  • go test -race -run 'Region|Route|Relay|Evidence|Perf|508|510|412|470|495': ok, 173 s [T]
  • sh test-all.sh: 259 passed, 0 failed [T]
  • node test-frontend-helpers.js: 719 passed [T]; node test-regions-map.js: passed [T]
  • E2E against a local Go server on port 13860 over e2e-fixture.db prepared as in CI (freshen, the inline grouped/GRP_DATA rows, corescope-migrate, then seeds 2073, 199, 245 and 122) [T]: test-regions-map-e2e.js passed, test-issue-480-no-scope-e2e.js 25/25, test-perf-go-runtime.js 30/30, test-xss-392-perf-e2e.js 1/1, test-perf-disk-io-1120.js 15/15, test-perf-anomaly.js 3/3, test-e2e-playwright.js 154 passed / 3 skipped. The local E2E needs a .git-commit file for the Version/Perf check; it, the built binaries, the mutated fixture and the test's test-fixtures/mbcap-snapshot/ artifact were all removed afterwards.
  • scripts/check-xss-sinks.sh --diff origin/master clean [T]; .github untouched [A]; no new map[string]interface{} outside tests [A]; no frontend change and no hardcoded colours [A]; cmd/server stays read-only — no schema, retention, dependency or configuration change [A].
  • fix(regions): exclude planned paths from observed history #507 semantics hold [A]: capture refusals are still capped in the transmission's own hour and scope, publication budgets are still only cappedStepAppearances, and excluded-only data stays complete. TestRegionsEvidenceMembershipBudgetIndependentOfSteps still guards the membership budget [T].
  • perf(store): move runtime SQL outside packet-store locks #495 lock rule holds [A]: capture still does only O(1) map work on collector-owned state under the packet read lock — the change swaps an int32 map key for a string key in the same per-call map and adds two integer comparisons. captureStats and the membership builder run inside build(), after the unlock. No SQL and no new lock. -race green [T].

Mutants [K]

Run against Region|Route|Relay|Evidence|Perf|508|510|412|470|495. 15 of 15 killed.

# Mutation Result Killed by
M1 per-transmission ceiling back to 128 killed …AdmitsFloodHeardByManyObservers, …TransmissionPathBudgetHeadroomOverObservers
M2 dedup only for interned paths (round-2 N1) killed …AdmitsFloodHeardByManyObservers, …DedupesRepeatedPathsUnInterned
M3 cappedTransmissionPaths never counted killed …AttributesCappedObservations/transmission_paths
M4 cappedPathBytes never counted killed …/path_bytes
M5 cappedHourReferences never counted killed …/hour_references
M6 cappedOverlongPaths never counted killed …/overlong_path
M7 cappedMembershipCells never counted killed …/membership_cells
M8 cappedMembershipNodes never counted killed …/membership_nodes
M9 maxTransmissionPaths high-water never updated killed …AdmitsFloodHeardByManyObservers, …DedupesRepeatedPathsUnInterned
M10 maxPathBytes high-water never updated killed …AttributesCappedObservations/path_bytes
M11 estimatedBytes drops the transmission-header term (N2) killed …CaptureHeadroomMeasured
M12 per-transmission budget 1,039 (one below the fan-out floor) killed …TransmissionPathBudgetHeadroomOverObservers
M13 per-transmission budget 8,193 (above an eighth of the hour) killed …TransmissionPathBudgetHeadroomOverObservers
M14 per-transmission ceiling off by one (>= → >) killed TestRegionRoutesSourceAndRowBounds, …AttributesCappedObservations/transmission_paths
M15 membership cell budget not disclosed in the diagnostic killed …CaptureHeadroomMeasured

CI — run 39404607486 on b4f45af7

All jobs passed on attempt 1, no reruns. The known-flaky #271 and #301 suites did not fire.

Job Result
Go Server Tests (1/3) success
Go Server Tests (2/3) success
Go Server Tests (3/3) success
Go Ingestor Tests success
Go Libraries & CLI success
Verify Go Test Assignments success
JavaScript Unit Tests & Lint success
Script & Container Unit Checks success
Playwright E2E Tests (1/3) success
Playwright E2E Tests (2/3) success
Playwright E2E Tests (3/3) success
Verify E2E Shards & Coverage success
Validate Docker Image success
Build & Publish Docker Image success
Go Build & Test (final gate) success
Release Artifacts skipped
Deploy Staging skipped
Publish Badges & Summary skipped

The earlier run 39403260691 on ac7f3707 was cancelled by the b4f45af7 push after nine jobs had already passed; it is superseded by the run above.

What the next staging round should read

/api/perf?mem=1 → regionsSnapshot.capture. If coverage.evidence.capped is still non-zero in #dk, exactly one of cappedTransmissionPaths, cappedHourReferences, cappedPathBytes, cappedOverlongPaths, cappedMembershipCells or cappedMembershipNodes will say which ceiling did it. maxTransmissionPaths against transmissionPathBudget shows the real observer fan-out, and regionsSnapshot.truncated (not coverage.evidence.truncated, which is the evidence-cell builder's own flag) is the capture refusal.

Residuals

  1. The cause is argued by elimination plus a synthetic repro, not measured on real data. No staging or production access. The attribution counters make the next round decisive either way: if cappedPathBytes or cappedOverlongPaths turns out to be non-zero instead, the traffic is anomalous and that is a different fix. Not filed.
  2. The per-transmission pair budget (8,192) is no longer implied by the path ceiling. It used to be exactly 128 x 64 hops, so the path ceiling always bound first; they are independent now. It is a publication budget — counted as cappedStepAppearances, never membership coverage (fix(regions): exclude planned paths from observed history #507) — and TestRegionsRouteCaptureAdmitsFloodHeardByManyObservers pins that a real flood does not reach it. Not filed.
  3. 4,096 is sized against staging's 65 observers. A much larger observer population would narrow the per-observer headroom; maxTransmissionPaths makes that visible before it caps. Not filed.
  4. maxPathBytes is a high-water over admitted observations, so an oversize path raises cappedPathBytes rather than maxPathBytes. Deliberate — it is headroom against the ceiling, not a maximum over all input. Documented in the struct.
  5. Round-1 and round-2 residuals stand: the 168 h response is still large and mostly hourly relay history; the published history-cell budget fits 168 h but not the full 21 days; the 16 MiB step payload ceiling remains a judgement call; Server.stepEndpointLimit is still a test-only field; the nominal capture ceiling over the full horizon is still large (3.2 GiB) though unreachable while the packet store is memory-bounded.

@dborup

dborup commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Review — CS-Coordinator PR#510 — head b4f45af

Verdict: APPROVE

Round 3 identifies and fixes the remaining membership cap. maxRegionsRoutePathsPerTx goes from 128 to 4096: busy #dk floods heard by many of our 65 observers exceeded 128 distinct paths per transmission. The builders now take explicit bounds, and every limit has its own capped* counter and budget in the diagnostics, so the next cap will be attributable.

Staging verification (master 78640faf + #511 r3 + this head, three restarts): coverage.evidence showed capped 0, unprovable 0 and excluded 7, against about 1,070–1,100 capped before. Directed steps were about 6,300 with stepsTruncated: false. RSS was 1.46–1.74 GiB against a 2.73 GiB cap, with 0 errors. The worst-case transient capture ceiling documented in round 2 did not materialize on real data. CI is green. Merging.

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