Skip to content

perf(nodes): per-IATA region membership, union per request, warm at startup - #619

Merged
dborup merged 4 commits into
masterfrom
perf-618-node-region-per-iata
Oct 26, 2026
Merged

dborup merged 4 commits into
masterfrom
perf-618-node-region-per-iata

Conversation

@dborup-agent

@dborup-agent dborup-agent commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #618

Problem

/api/nodes?region=… built its node set with a full history scan per region set (observers ⋈ observations ⋈ ADVERT transmissions), cached under the joined codes. After a restart every new set — a single code, a combination, a quick-pick group — paid that scan again, about a minute each on a large database, while the Nodes page showed nothing.

Plan (implemented in this PR)

  1. One per-IATA index, shared with nodes: investigate direct-evidence regional placement without regressing historical cache #488 M2. The heard rule now uses the same machinery as placement: regionIndex in the new region_index.go holds a regionEvidenceAgg with an observations.id watermark, delta scans past it, a full rebuild every nodeRegionRebuildInterval, a singleflight and an immutable snapshot (region → sorted members). The two rules differ only in their scan and in how a node's per-region evidence becomes member regions (regionIndexSpec). currentRegionPlacement / refreshRegionPlacement / placedNodeRegion and the heard buildNodeRegion / extendNodeRegion are folded into that one driver.
  2. Union per request. A region set is the union of its codes' member lists, cut into the existing bounded per-set cache (32 LRU entries, digest keys). Entries carry the snapshot's ages, so fresh / stale-while-revalidate / max-stale keep their meaning.
  3. Warm at startup. StartRegionIndexWarmer (started in main, stopped before the DB closes) builds the index right after startup and refreshes it every nodeRegionFreshTTL: a delta scan normally, the full rebuild when due. It reads SQLite only, never the PacketStore or PacketStore.mu.
  4. No queueing behind a build. A request with a usable (stale) snapshot is cut from it at once and only kicks a refresh. Before the first snapshot exists, a waiting request waits for the index build the warmer already has in flight (singleflight), so it waits at most the rest of the warmup and starts no second full scan. Only if that index build fails does it fall back to its own per-set build (master's query), so a failing index is not a 500. Fallbacks are serialised, and one still queued when a retry has published the index is cut from it instead. Identical sets share one refresh.
  5. Faster full scan. The full heard scan drives from the ADVERT transmissions (idx_transmissions_payload_type → idx_observations_transmission_id) and dedupes in Go instead of a SQL DISTINCT. Without a region filter the planner walked every observation by rowid and looked up its transmission (0.3–0.7× master's all-codes time, below).

Acceptance (restated, owner decision in review round 1)

The issue's "the cold path never blocks longer than today" is replaced by:

  • A cold request during warmup waits at most the remaining warmup time; after warmup every set is answered in milliseconds.
  • Concurrent identical requests share one build; the result equals today's per-set build for every set.

Waiting for the warmup can be longer than master's per-set build for a request that arrives right at boot on a DB with many equal small regions (measured below: 745 vs 346 ms at 0 % on the #176 generator). In exchange the warmup is not slowed by a competing scan, so every other set is served earlier (warmup 0.74 s instead of 1.40 s there).

Server stays read-only; no new map[string]interface{}; no frontend, config or customizer change (the intervals stay the existing constants).

Equivalence

TestNodeRegionIndex618_UnionEqualsPerSetBuild compares every non-empty subset of AAA…EEE,ZZZ (63 sets plus normaliser spellings) against master's per-set query, kept verbatim as the oracle, on a generated DB with awkward observers: lower-case/padded IATA, NULL/blank IATA, an ADVERT without from_pubkey, a non-ADVERT. It runs cold, after a delta and after a rebuild that has to move a node between regions. The same property runs on the e2e fixture (31 sets) and, env-gated, on both large generated DBs:

DB regions sets compared result
e2e fixture 4 (+1 unknown) 31 equal, 1 full index build
#488 generator (scale 60, 1M filler) 4 15 equal, 1 full index build
#176 generator (1.5M obs) 13 92 equal, 1 full index build

Performance (local, 4 vCPU, medians of 5, re-measured in round 2)

#488 generator (1.07M obs, 2,340 nodes, 5,400 pairs) #176 generator (1.5M obs, 13 regions, 4,000 nodes, 51,437 pairs)
master: per-set builds summed (all singles + 3 groups) 1.28 s 4.91 s
index: cold build, all codes 107 ms 730 ms
master's per-set build alone, largest single code 173 ms (BBB) 346 ms (SJC)
cold request during warmup, largest code, arriving at 0 / 25 / 50 / 75 % of it 107 / 81 / 52 / 25 ms 745 / 558 / 374 / 202 ms
… equal to the rest of the warmup 107 / 81 / 52 / 25 ms 744 / 556 / 371 / 201 ms
warmup finished after (with that request) 105–108 ms 736–750 ms
warm: worst cut of a new set from the snapshot 0.52 ms 1.28 ms
warm: cached hit 262 ns 850 ns
index heap 1.3 MiB 7.9 MiB

TestPerf_RegionPlacementM2_488 on the #488 DB: heard delta refresh over the newest 5,000 observations 3.5 ms, one delta scan of 5,000 ADVERT observations for all regions 7.1 ms.

Full heard scan variants measured during the work (#176 DB): planner order + DISTINCT 1.21 s, transmission-first + DISTINCT 1.24 s, transmission-first + Go dedupe 0.87 s (chosen).

Commands:

CORESCOPE_PERF_488_GEN=/path/big488.db CORESCOPE_PERF_488_SCALE=60 go test -run '^TestPerf_RegionPlacementM2Generate_488$' .
CORESCOPE_PERF_176_GEN=/path/big176.db go test -run '^TestPerf_GenerateRegionDB_176$' -timeout 2h .
CORESCOPE_PERF_618=/path/big.db go test -run '^TestPerf_NodeRegionIndex618$' -v -timeout 2h .
CORESCOPE_PERF_618_DB=/path/big.db go test -run 'UnionEqualsPerSetBuild_LargeDB$' -v -timeout 2h .

Complexity / scale. The full scan is O(ADVERT observations) once per nodeRegionRebuildInterval in the warmer, instead of once per region set per interval. A delta is a rowid range. A snapshot is O(pairs). A cut is O(members of the set's codes) plus a sort and JSON encode, at most once per nodeRegionFreshTTL per set. Memory is bounded by distinct (node, region) pairs (TestNodeRegionIndex618_BoundedMemory); the per-set cache stays at 32 entries.

Round 2 replaced an earlier race between the index build and the request's own per-set build. Same machine, same test, the race build: #176 generator 478 / 481 / 481 / 403 ms at 0 / 25 / 50 / 75 %, with the warmup slowed to 0.95–1.40 s; #488 generator 158 / 120 / 81 / 48 ms, warmup 128–158 ms.

Known limits

Tests

New: cmd/server/nodes_region_index_618_test.go (property and fixture equivalence, delta = full, warmup, keep-alive, cold requests wait for the warmup's build, cold requests share one index build, per-set fallback when the index build fails, queued fallbacks take an index published meanwhile, fallbacks serialised, idle refresh re-stamps without scanning, no queueing behind a rebuild, warmer stop, bounded memory, v2 schema, source guards, the server process warming at startup, env-gated large-DB property and perf). Adapted: the #176 and #488 M2 tests age the index together with the entry and use the shared seams (regionPlaced.*).

🤖 Generated with Claude Code

…tartup (#618)

A region-filtered /api/nodes request used to run a full history scan per
region set (observers x observations x ADVERT transmissions), so every new
set or quick pick cost about a minute on a large database after a restart.

The heard rule now shares the #488 M2 machinery with placement: one global
region index per rule (region_index.go), kept by one shared scan grouped by
observer IATA, with the aggregate's watermark, delta scans, periodic full
rebuild, singleflight and immutable snapshot. A region set is the union of
its codes' member lists, cut into the existing bounded per-set cache.

- StartRegionIndexWarmer builds the index after startup and refreshes it
  every nodeRegionFreshTTL, so the full scans and rebuilds run outside any
  request (and outside PacketStore.mu: it reads SQLite only).
- A request with a usable (stale) snapshot is cut from it at once and only
  kicks a refresh; it never queues behind a running rebuild.
- Before the first snapshot, a waiting request races its own per-set build
  (master's query, serialised) against the index build; identical sets
  share one build.
- Entries carry the snapshot's ages, so the fresh/stale/max-stale contract
  is that of the data.
- The full heard scan drives from the ADVERT transmissions and dedupes in
  Go instead of a SQL DISTINCT.

Tests: union equals the per-set build for every subset on a generated DB,
the e2e fixture and (env-gated) large generated DBs; one full scan serves
every set; warmup, keep-alive, cold race both ways, no queueing behind a
rebuild, warmer stop, bounded memory, v2 schema, source guards, and the
server process warming at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#619 #618 — head 51e62b8

Status: Draft, implemented as written; all local suites and mutants green; CI: all jobs green on the first attempt. One known limit (cold window on many equal small regions) is documented below.

Evidence tags: [T] run for this report (test, mutant, benchmark or command output), [A] argued from the code, not executed, [K] carried over from the issue, CI or an earlier run, not re-verified.

"Red before": a test either fails on origin/master 3cb770e (where it compiles), or, where it needs seams that do not exist on master (regionHeard, startRegionIndexWarmer, …), fails to compile there and fails behaviourally under the listed mutant, which restores the master-like behaviour.

Requirements

# Requirement Test(s) Red before Mutant (killed) Evidence
1 Per-IATA heard membership from one shared scan TestNodeRegionIndex618_OneScanServesEverySet_Fixture, …_UnionEqualsPerSetBuild master: fixture test fails, 31 sets → 31 full builds (want 1) M1: every refresh a full rebuild [T]
2 Region sets served as unions, equal to today's per-set build …_UnionEqualsPerSetBuild (63 subsets + spellings; cold / delta / rebuild), …_OneScanServesEverySet_Fixture (31 sets), …_UnionEqualsPerSetBuild_LargeDB (#488 gen: 15 sets, #176 gen: 92 sets), …_V2Schema compile (seams); fixture test red on master via the scan count M2a: union of first code only · M2b: no UPPER() · M2c: payload_type >= [T]
3 Background warmup after startup, outside requests and PacketStore.mu …_WarmupServesFirstRequests (no PacketStore exists), …_ServerWarmsAtStartup (real main(), no request), …_WarmerKeepsIndexCurrent compile (seams) M3a: no initial build · M3b: main does not start the warmer · M3c: warmer stops refreshing [T]
4 Watermark / delta / rebuild semantics kept #176 suite (adapted helper ages index and entry), …_DeltaEqualsFullBuild, …_UnionEqualsPerSetBuild delta + rebuild stages, all #488 M2 tests compile (seams) M4a: delta not merged · M4b: rebuild never due · M4c: max-stale bound ignored [T]
5 Singleflight; concurrent identical cold requests share one build …_ColdRequestsDuringWarmup (3 refreshes for 6 requests, 1 index scan), …_ColdRequestTakesIndexWhenFirst (4 cold sets, 1 index scan) compile (seams) M5: index refresh bypasses its singleflight [T]
6 Cold path never blocks longer than today …_ColdRequestsDuringWarmup (served by own per-set build while the index build is held), …_ColdRequestTakesIndexWhenFirst, …_ColdPerSetBuildsAreSerialised, …_RequestsDoNotQueueBehindRebuild compile (seams) M11: no per-set build in the race · M12: index result ignored while per-set runs · M13: per-set builds not serialised · M6a: new set waits for the index · M6b: entry stamped now [T]; bound under contention: see Rests
7 Warmer stop at shutdown is prompt …_WarmerStopDoesNotWaitForForeignFlight red on this branch before the fix (stop waited 2 s+) M10: waiter ignores its own ctx [T]
8 Bounded memory …_BoundedMemory (15 pairs after 20 refresh rounds; snapshot keys = pairs; 64 sets → cache ≤ 32) compile (seams) M7a: pairs++ per hearing · M7b: LRU cap ×1000 [T]; heap 1.3 MiB / 7.9 MiB on the large DBs [T]
9 No SQL under PacketStore.mu …_SourceGuards (region_index.go, nodes_region_cache.go, region_placement.go) — (guard) M8b: a *PacketStore / st.mu.RLock() helper [T]; the index has no store reference [A]
10 cmd/server read-only TestServerSourceHasNoCachedRWCalls — (guard) M8c: mode=rw literal [T]
11 No new map[string]interface{} …_SourceGuards; diff count of + lines in non-test cmd/server/*.go: 0 — (guard) M8a: map[string]interface{}{} [T]
12 Placed mode still served by its own rule all TestRegionPlacement488_* — M9: placed spec uses the heard rule [T]
13 Benchmarks: cold all-codes vs sum of per-set builds; warm union cost TestPerf_NodeRegionIndex618, TestPerf_RegionPlacementM2_488 — — [T], table below

Mutation runner: 23 mutants, 23 killed, each on a failing assertion (none on a compile error) [T].

Benchmarks (local, 4 vCPU, medians of 5) [T]

#488 generator: 1.07M obs, 4 regions, 2,340 nodes #176 generator: 1.5M obs, 13 regions, 4,000 nodes
master per-set builds, summed (singles + 3 groups) 1.30 s 4.88 s
index cold build, all codes 108 ms (12.0× less) 737 ms (6.6× less)
cold request before any snapshot, largest single code 161 ms vs 183 ms master 479 ms vs 327 ms master
warm: worst cut of a new set 0.50 ms 1.31 ms
warm: cached hit 272 ns 737 ns
index heap 1.3 MiB 7.9 MiB

Full-scan variants on the #176 DB: planner + DISTINCT 1.21 s, transmission-first + DISTINCT 1.24 s, transmission-first + Go dedupe 0.87 s (chosen) [T].

Local verification [T]

CI per job

Run 40286467215 on head 51e62b8, first attempt, no reruns (the known flaky #271 did not fire) [T]:

Job Result Time
Go Build & Test pass 34s
Go Ingestor Tests pass 3m33s
Go Libraries & CLI pass 1m56s
Go Server Tests (1/3) pass 7m44s
Go Server Tests (2/3) pass 8m20s
Go Server Tests (3/3) pass 20m31s
JavaScript Unit Tests & Lint pass 2m28s
Script & Container Unit Checks pass 1m6s
Playwright E2E Tests (1/3) pass 11m47s
Playwright E2E Tests (2/3) pass 16m3s
Playwright E2E Tests (3/3) pass 10m49s
Validate Docker Image pass 53s
Verify E2E Shards & Coverage pass 18s
Verify Go Test Assignments pass 1m30s
Build & Publish Docker Image pass 15s
Publish Badges & Summary / Release Artifacts / Deploy Staging skipped (fork guards) —

Go Server shard 3 at 20m: another PR branch's recent run took 19m and master's 12m [T]. The new #618 tests take about 1.4 s together locally [T], so the difference is runner variance, not this change [A].

Rests

  1. Cold window, many equal small regions. Before the first snapshot, a request races its own per-set build against the index build. Both share the machine, so on the 13-region generator the winner took 1.46× master's per-set build alone (479 vs 327 ms) [T]; on the nodes: investigate direct-evidence regional placement without regressing historical cache #488 generator it took 0.88× [T]. Waiting for the index alone would have been 2.3× on the 13-region DB [T]. The acceptance "never longer than today" therefore holds on the nodes: investigate direct-evidence regional placement without regressing historical cache #488 shape and not strictly on the 13-equal-region shape. Production's plan and region distribution were not measured: no prod or staging access, by instruction.
  2. The warmer runs one full scan per nodeRegionRebuildInterval even with no region traffic [A]. Master scanned only on demand, but once per set.
  3. Two concurrent full scans on modernc SQLite slow each other (index 0.89 → 1.67 s, per-set 0.38 → 0.57 s on the perf(nodes): port upstream region membership cache (#2102) #176 DB) [T]. Per-set builds are serialised, so at most two scans run at once (pool of 4) [A].
  4. Not verified in a browser beyond the Playwright suites above: there is no visual change.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-MacBook PR#619 — head 51e62b8

Dom: APPROVE with nits. The owner still has to accept the cold-window deviation from the acceptance criteria explicitly (F1).

Evidence tags: [T] run for this review, [A] argued from the code and not executed, [K] carried over from the author's report, the issue or CI and not re-run.

Trees: head 51e62b89 (git archive); merged tree cda6fb46 = git merge-tree --write-tree origin/master(b519e6bb) 51e62b89, no conflicts. Every run below used the merged tree. git ls-remote showed the head at 51e62b89 before and after the review.

Findings

# Sev Finding Evidence
F1 Medium (owner decision) The cold-window race does not meet "never longer than today" on the 13-region shape. It also slows the shared warmup and is usually worse than just waiting for the index. The details and numbers are below. Suggestion: drop the per-set race and let a cold request wait for the index build the warmer already has in flight. Alternatively, only start the per-set build when the request arrives early in the warmup. Either way, the issue's acceptance criterion needs to be restated. [T] [A]
F2 Low (test gap) A failing index build should fall back to the per-set build, and no test covers it. Own mutant O5 changes coldNodeRegion to return the index error at once instead of waiting for the per-set result, and the mutant survives. The design says the cold path covers "warmup still running, or failing" (region_index.go:184-186). Today a regression here would turn a transient index failure into a 500 with no test failing. [T]
F3 Low (test gap) The early exit for queued per-set builds is untested. Own mutant O4 removes the ctx.Err() check after nodeRegionColdMu in buildNodeRegionCold (nodes_region_cache.go), and the mutant survives. Without that check, a burst of cold sets keeps running full per-set scans one after another even after the index won. That happens exactly in the 2-vCPU startup window where it hurts most. [T]
F4 Low (test gap) Re-stamping an idle index is untested. Own mutant O2 removes s.refreshed = time.Now() from the wm <= agg.watermark branch of refreshRegionIndex, and the mutant survives. On a DB with no new observations the snapshot would then age past nodeRegionMaxStale, and requests would start waiting on refreshes. Production is never idle, so the impact is low, but it is the behaviour the warmer exists to keep. [T]
F5 Nit The warmer's full rebuild runs whether or not anyone asks for regions. It runs every 30 min, plus once at boot during the startup fill load, and it ignores analytics.pauseWhenIdle (#492). The author lists this as known limit 2. On production it is roughly one ~70–90 s scan per 30 min [A], which beats master as soon as one region set is in use. Consider honouring pauseWhenIdle in a follow-up. [A]
F6 Nit Shutdown does not cancel request-started work. A background refresh kicked by a request (currentRegionIndex DoChan) and the detached index goroutine in coldNodeRegion run under context.Background(). The warmer's stop does not cancel them, so a request during the HTTP drain can start a scan that is still running when the DB closes. The same pattern existed for the per-set background refresh on master. Only the warmer's own flight is stopped promptly. [A]

F1 in detail

These are my own measurements on the merged tree with the author's generators, medians of 5, local [T]. A reviewer-only test (not committed) starts the real warmer, then makes one cold request for the largest single code at an offset into the warmup.

#176 generator: 1.5M obs, 13 regions. Largest code alone takes 234 ms; the index build alone takes 405 ms.

request arrives at latency with the race (vs per-set alone) just waiting for the index would be warmup finished after
0 % 360 ms (1.54×) ~405 ms (1.73×) 809 ms (alone 405)
25 % 347 ms (1.48×) ~304 ms (1.30×) 644 ms
50 % 349 ms (1.49×) ~203 ms (0.87×) 553 ms
75 % 169 ms (0.72×) ~101 ms (0.43×) 474 ms

#488 generator: the index (57 ms) is cheaper than the per-set build (141 ms), so the index always wins: 0.13–0.55× of today.

So the race helps the requester only very early in the warmup. After about the first fifth of the warmup it is slower than waiting. In every case it delays the one build that serves every other region set, by up to 2× (809 vs 405 ms).

Production shape [A]. The per-set plan was recorded (in the comment this PR removes) as driving off idx_transmissions_payload_type, and RKE and RNN both cost ~70 s. So a per-set build already walks every ADVERT observation, and the index scan walks the same rows. On top of that it returns every regioned ADVERT observation to Go instead of a DISTINCT subset: roughly +1 µs/row, on the order of 10 s at an assumed 10M ADVERT observations. On 2 vCPU, with the ingestor and the startup fill loader running, the race therefore probably means two ~70–90 s scans side by side, each slowed by the other. Waiting for the in-flight index would bound a cold request by about the index time from boot. The warmer starts before the listener binds, so that is ≤ ~1.2× today for a request at t≈0 and less for later ones.

This is a trade-off, not a correctness bug. The PR is a large net win in any case:

  • every set after the first is served from one scan instead of 70 s each, queued behind each other;
  • warm cuts take ≤ 1 ms.

Answers to the verification points

  • Result equivalence: equal. [T]
  • Cold path never slower than today: not strictly. See F1. The author's 479 vs 327 ms reproduces here as 370 vs 232 ms (1.59×) in TestPerf_NodeRegionIndex618. The cause is CPU and IO contention between the two concurrent scans. That explanation is correct and honestly disclosed, but the race is the wrong default for most of the window.
  • Singleflight: OK.
    • Per set: nodeRegionSF. Per index: regionIndex.sf plus ix.mu. Per-set cold builds are serialised by nodeRegionColdMu, and full scans of either index by nodeRegionFullMu, so at most 2 full scans run at once in a pool of 4.
    • ColdRequestsDuringWarmup (3 refreshes for 6 requests) and ColdRequestTakesIndexWhenFirst (1 index scan for 4 sets) pass. [T]
  • Warmer lifecycle and prompt shutdown: OK. [T]/[A]
    • The warmer starts in main after the eviction ticker and before the listener binds. It is stopped explicitly at shutdown step 1d, before the drain and DB close, and the deferred stop is once-guarded.
    • The warmer cancels its own scan through ctx and stops waiting on a foreign flight (WarmerStopDoesNotWaitForForeignFlight; M10 per the author).
    • ServerWarmsAtStartup runs the real main(), and the local E2E server logged heard: full build … 4 regions at boot with no request.
    • Request-started background work is not cancelled at shutdown (F6).
  • Bounded memory: OK. [T]
  • No SQL under PacketStore.mu: OK. region_index.go, nodes_region_cache.go and region_placement.go have no store reference, which the source guard enforces. The warmer gets only *DB. [T]/[A]
  • Server read-only: OK. All new SQL is SELECT on db.conn (the read-only handle), and TestServerSourceHasNoCachedRWCalls passes. [T]
  • No new map[string]interface{}: OK. 0 added lines in non-test cmd/server/*.go. [T]
  • No duplication with the nodes: investigate direct-evidence regional placement without regressing historical cache #488 M2 aggregate: OK, it is consolidated. [A]
    • Placement's snapshot, refresh, singleflight and scan-hook code is removed (-132 lines). Both rules now run through one regionIndex driver parameterised by regionIndexSpec.
    • The only parallel code left is the two scan queries, which differ in their columns.
  • Production (67M obs, 2 vCPU, ~70 s per set today) [A]:
    • Time. One full index scan ≈ one per-set scan plus Go-side row transfer, so ~70–90 s, once at boot and then every 30 min, instead of 70 s per region set per 30 min.
    • Memory. A few MiB retained, plus transient GC churn proportional to the ADVERT rows.
    • Ingest. The scan is a long read transaction on the read-only handle. It does not block the ingestor's writes, but it holds back WAL checkpoint truncation for its duration, as each per-set scan did before. That now happens on a fixed schedule rather than on demand.
    • Boot contention. At boot it overlaps the hot-startup fill loader and the analytics recomputers. With the F1 race, a cold region request adds a second concurrent full scan in that window.

Tests run (merged tree)

What Result
cd cmd/server && go test ./... ok (81 s) [T]
cd cmd/ingestor && go test ./... ok (126 s) [T]
go test -race -count=3 -run 'NodeRegion|RegionPlacement|GetNodesRegion' ok [T]
sh test-all.sh 274/274 files passed [T]
node test-frontend-helpers.js 731 passed, 0 failed [T]
Large-DB union property, #488 and #176 generators 15/15 and 92/92 equal, 1 full scan each [T]
TestPerf_NodeRegionIndex618, #488 DB per-set sum 1.005 s vs index 58 ms; cold BBB 79 ms (alone 140 ms); worst warm cut 0.28 ms; hit 167 ns; heap 1.3 MiB [T]
TestPerf_NodeRegionIndex618, #176 DB per-set sum 3.635 s vs index 405 ms; cold SJC 370 ms (alone 232 ms); all-codes cold 616 ms; worst warm cut 0.84 ms; hit 542 ns; heap 7.9 MiB [T]
E2E, local Go server see below [T]
scripts/check-xss-sinks.sh --diff origin/master clean, no public/ changes [T]

The E2E server ran on e2e-fixture.db, prepared as in CI: freshen, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL, corescope-migrate, then seeds 2073, 199, 245 and 122. The server was stopped by its listening pid.

  • test-e2e-playwright.js: 156/159, 3 skipped, 0 failed.
  • test-issue-416-region-quick-picks-e2e.js: 17/17.
  • test-issue-531-region-url-e2e.js: 31/31.
  • test-map-nodes-pagination-e2e.js: 3/3.
  • test-regions-map-e2e.js: passed.
  • test-issue-560-role-breakdown-e2e.js: 6/6; the area checks were skipped because the bin env vars were not set.

CI. Run 40286467215, attempt 1: every job passed. Publish Badges, Release Artifacts and Deploy Staging were skipped by the fork guards. The known flakes #256 and #267 did not fire. [T]

Hygiene. [T]

Mutants (run on the merged tree, filter NodeRegion|RegionPlacement|GetNodesRegion|ServerSourceHasNoCachedRW, clean baseline green)

Mutant Change Result
M2a members() unions only the first code killed: 9 tests, incl. UnionEqualsPerSetBuild, the fixture test and the #176 cache test [T]
M2b heard scan without UPPER() killed: UnionEqualsPerSetBuild, WritePathShapesMatchJSONExtract_PR38 [T]
M4a delta not merged (agg.merge(delta) dropped) killed: 9 tests, incl. the #176 delta tests, WarmerKeepsIndexCurrent, BoundedMemory and the #488 tests [T]
M6a a new set waits for the index refresh (currentRegionIndex(…, true)) killed: RequestsDoNotQueueBehindRebuild [T]
O1 (own) the cold race's index build runs under a ctx cancelled when the request returns killed: 10 tests [T]
O3 (own) the heard scan no longer raises agg.watermark, so every delta becomes a full rescan killed: 9 tests [T]
O2 (own) idle refresh does not re-stamp refreshed survived → F4 [T]
O4 (own) queued per-set build does not give up after the index won survived → F3 [T]
O5 (own) index failure in the cold path returns at once, no per-set fallback survived → F2 [T]

All kills were on failing assertions, not compile errors.

Red before green. The new tests use seams that do not exist on master (regionHeard, startRegionIndexWarmer, waitForHeardIndex), so they do not compile there. I relied on the author's mutant table for "red before" on the acceptance criteria not covered above: M1, M3a–c, M5, M7a/b, M8a–c, M9–M13 [K]. M2a/M2b/M4a/M6a were re-run here [T].

Not verified

  • Production or staging behaviour, plans and timings. Everything about the 67M-observation, 2-vCPU host is [A], including the assumed ADVERT share of observations and the WAL effect. Nothing was measured there, by instruction.
  • The author's mutants other than M2a/M2b/M4a/M6a [K].
  • Red-before on master for the new tests (compile-gated) [K].
  • Browser validation beyond the Playwright suites listed above; there is no visual change.
  • The full CI E2E shard set locally. I ran only the PR's own and the region-related E2Es plus test-e2e-playwright.js.

dborup and others added 3 commits October 26, 2026 09:34
…, queue and idle re-stamp (#618)

Review round 2 of PR #619 (F1-F4). Red on 51e62b8's race:

- ColdRequestsDuringWarmup: cold requests during the warmer's held
  build are not served before it lands and run no per-set build (F1).
- ColdRequestFallsBackWhenIndexFails: a failing index build falls back
  to the per-set build, not a 500; once the index builds, new sets are
  cut from it (F2, kills O5).
- QueuedFallbackTakesRecoveredIndex: a fallback queued behind
  nodeRegionColdMu is cut from an index published meanwhile (F3).
- IdleRefreshReStampsIndex: an idle refresh re-stamps the snapshot
  without scanning (F4, kills O2).

ColdRequestTakesIndexWhenFirst becomes ColdRequestsShareOneIndexBuild,
ColdPerSetBuildsAreSerialised now runs with a failing index, and the
perf test times cold requests at 0/25/50/75 % of the warmup. Adds the
nodeRegionPerSetHook test seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…618)

Review round 2 of PR #619. The owner chose F1 option 1: drop the race
between the index build and a per-set build. Before the first snapshot
a request waits for the index build the warmer already has in flight
(singleflight), so it waits at most the rest of the warmup and adds no
second full scan that slows the one build every set is served from.

- F2: only when that index build fails does the request fall back to
  its own per-set build, so a failing index is not a 500.
- F3: fallbacks stay serialised under nodeRegionColdMu (now taken in
  coldNodeRegion), and one still queued when a retry published the
  index is cut from it instead of scanning. The old ctx.Err() check
  and the WithoutCancel timeout in buildNodeRegionCold were only
  reachable when the index won the race, so they are removed.
- F4 needed no code change; the idle re-stamp is now tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#619 runde2 — head 6d63333

Review feedback addressed (commit 6d633333, tests in 66d8945c, origin/master b519e6b merged in eb7e2e9c)

  1. F1 (owner decision: option 1), fixed. The race is gone. Before the first snapshot, coldNodeRegion now waits for the index build through the index singleflight (regionIndexFlight). That is the warmer's build, or one the request starts when there is no warmer. A cold request therefore waits at most the rest of the warmup and starts no second full scan. The detached index goroutine and the per-set goroutine are removed (-77/+42 lines in non-test code). Test: ColdRequestsDuringWarmup (rewritten) holds the warmer's build. No request may be served before it lands, and all must be served right after. It asserts 0 per-set builds, 3 refreshes for 3 distinct sets and 1 full index scan. Red on 66d8945c (the race code with the new tests): "6 cold requests were served before the warmup's build landed (3 per-set builds)". Mutant R1 (the cold path skips the index wait and goes straight to the per-set build) is killed by 10 tests. [T]
  2. F2, fixed. The per-set build is now only the fallback when the index build fails, so a failing index is not a 500. New test ColdRequestFallsBackWhenIndexFails: with a failing index, 2 cold sets are served equal to the oracle by 2 per-set builds and no snapshot is published. Once the index recovers, a new cold set is a cut with no per-set build. Mutant O5 (return the index error at once) is killed by 3 tests: ColdRequestFallsBackWhenIndexFails, QueuedFallbackTakesRecoveredIndex and ColdPerSetBuildsAreSerialised. [T]
  3. F3, the code O4 targeted is removed and replaced by a tested check. After F1, the ctx.Err() check after nodeRegionColdMu in buildNodeRegionCold was dead. Its ctx is refreshNodeRegion's own 30-min timeout, and the only thing that cancelled it early was the race's winning index build. It is removed, together with the WithoutCancel re-timeout, which only guarded an interrupted losing scan [A]. The queue now lives in coldNodeRegion. A fallback that acquires nodeRegionColdMu after a retry has published the index is cut from that index instead of scanning. In the startup window this covers a burst of cold sets after a transient index failure. New test QueuedFallbackTakesRecoveredIndex: two fallbacks are queued behind a held nodeRegionColdMu, the index is then published, and the test expects 0 per-set builds. Red on 66d8945c (2 per-set builds). Mutant O4′ (drop the re-check) is killed by 1 test. Mutant M13 (fallbacks not serialised) is killed by 2 tests. [T]
  4. F4, test added; no code change needed. New test IdleRefreshReStampsIndex: after warmup, with no new observations, the snapshot is aged to nodeRegionMaxStale and one warmer tick (regionIndexFlight) runs. The returned and published snapshots must be re-stamped, with the watermark unchanged and no full or delta scan. Mutant O2 (no s.refreshed = time.Now()) is killed by 1 test. The test passes on the old code too: it closes a gap, it does not reproduce a bug. [T]
  5. F5 (warmer ignores pauseWhenIdle) and F6 (request-started work not cancelled at shutdown): follow-ups, not in this PR. One addition for F6: when the warmer is stopped mid-build, the waiting cold requests get its context.Canceled. They then fall back to a per-set build during the HTTP drain. That is the same class of issue as F6 (the same scan master would have run), so it belongs in the same follow-up [A].

Evidence tags: [T] run for this report, [A] argued from the code, not executed, [K] carried over from the review or an earlier run.

Acceptance (restated in the PR description)

A cold request during warmup waits at most the remaining warmup time; after warmup every set is answered in milliseconds.

The perf test measures this directly. The request's latency equals the rest of the warmup within 1–3 ms at every offset, on both DBs (table below) [T]. After warmup, the worst cut of a new set is 0.52 ms / 1.28 ms and a cached hit takes 262 ns / 850 ns [T].

Cold / warm, both generator DBs (local, 4 vCPU, medians of 5) [T]

Same machine, same TestPerf_NodeRegionIndex618. "Race" is 66d8945c, which has 51e62b8's code; only the perf test's guard was relaxed, in a throwaway copy, so the race version could be timed. "Wait" is 6d633333. The request asks for the largest single code and arrives at x % of the warmer's build.

#176 generator (1.5M obs, 13 regions; SJC per-set alone: 346 ms wait run / 334 ms race run)

arrives at race: latency race: warmup landed wait: latency (× per-set alone) wait: rest of warmup wait: warmup landed
0 % 478 ms 1.40 s 745 ms (2.16×) 744 ms 744 ms
25 % 481 ms 1.40 s 558 ms (1.61×) 556 ms 739 ms
50 % 481 ms 1.36 s 374 ms (1.08×) 371 ms 736 ms
75 % 403 ms 0.95 s 202 ms (0.58×) 201 ms 750 ms

#488 generator (1.07M obs, 4 regions; BBB per-set alone: 173 ms / 180 ms)

arrives at race: latency race: warmup landed wait: latency (× per-set alone) wait: warmup landed
0 % 158 ms 158 ms 107 ms (0.62×) 107 ms
25 % 120 ms 147 ms 81 ms (0.47×) 108 ms
50 % 81 ms 134 ms 52 ms (0.30×) 106 ms
75 % 48 ms 128 ms 25 ms (0.15×) 105 ms

The trade-off the owner accepted shows on the #176 shape. In the first quarter of the warmup the requester waits longer than with the race (745 vs 478 ms at 0 %). In exchange the warmup is no longer slowed by a competing scan, and every other set waits for it: 0.74 s instead of 1.40 s. From about half the warmup on, waiting is also faster for the requester itself. On the #488 shape waiting is faster at every offset.

Other numbers (wait build): index cold build of all codes 107 ms / 730 ms, against 1.28 s / 4.91 s for master's per-set builds summed; index heap 1.3 MiB / 7.9 MiB. With no warmer, a cold request starts the build itself: 110 ms / 730 ms. Large-DB union property: #488 15/15 sets and #176 92/92 sets equal the per-set build, 1 full scan each. [T]

Tests and mutants [T]

Test Finding Red on 66d8945c Mutant killed
ColdRequestsDuringWarmup (rewritten) F1 yes: served before the build landed, 3 per-set builds R1
ColdRequestFallsBackWhenIndexFails (new) F2 yes, for a different reason: the race also ran a per-set build after recovery O5
QueuedFallbackTakesRecoveredIndex (new) F3 yes: 2 per-set builds O4′, M13, O5
IdleRefreshReStampsIndex (new) F4 no (gap only, code was correct) O2
ColdRequestsShareOneIndexBuild (was ColdRequestTakesIndexWhenFirst) F1 (no warmer: one index build for 4 cold sets) no R1
ColdPerSetBuildsAreSerialised (now with a failing index) F3 no M13, O5

Mutation run (filter NodeRegion|RegionPlacement|GetNodesRegion|ServerSourceHasNoCachedRW, clean baseline green): 5 mutants, 5 killed, each on a failing assertion, 0 build errors.

Mutant Change Killed by
R1 cold path does not wait for the index, goes straight to the per-set build 10 tests, incl. ColdRequestsDuringWarmup, ColdRequestsShareOneIndexBuild, UnionEqualsPerSetBuild, RequestsDoNotQueueBehindRebuild
O5 index error returned at once, no fallback ColdRequestFallsBackWhenIndexFails, QueuedFallbackTakesRecoveredIndex, ColdPerSetBuildsAreSerialised
O4′ queued fallback does not re-check for a published index QueuedFallbackTakesRecoveredIndex
O2 idle refresh does not re-stamp refreshed IdleRefreshReStampsIndex
M13 fallbacks not serialised by nodeRegionColdMu QueuedFallbackTakesRecoveredIndex, ColdPerSetBuildsAreSerialised

Local verification [T]

  • cd cmd/server && go test ./...: ok (69 s). cd cmd/ingestor && go test ./...: ok (208 s).
  • -race -count=5 -run 'NodeRegionIndex618|NodeRegion|RegionPlacement|GetNodesRegion': ok.
  • sh test-all.sh: 274/274 files. node test-frontend-helpers.js: 731 passed, 0 failed.
  • node scripts/run-go-server-shard.js --validate: 3,006 entries, each in exactly one shard.
  • bash scripts/check-xss-sinks.sh --diff origin/master: no public/ changes. No .github/ changes against master. 0 added map[string]interface{} lines in non-test cmd/server/*.go.
  • E2E against a local Go server on a copy of e2e-fixture.db, prepared as in CI: freshen, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL taken from deploy.yml, corescope-migrate, then seeds 2073/199/245/122. The server ran with -public public and was stopped by pid; afterwards the port was free and "shutdown complete" was logged. The server logged [nodes-region] heard: full build … 4 regions in 1ms at startup with no request.
    • test-e2e-playwright.js: 156/159, 3 skipped, 0 failed.
    • test-issue-416-region-quick-picks-e2e.js: 17/17. test-issue-531-region-url-e2e.js: 31/31. test-map-nodes-pagination-e2e.js: 3/3. test-regions-map-e2e.js: passed.
  • Commits: author and committer dborup <kontakt@meshview.dk>; no rebase, amend or force-push. The PR stays a draft.

CI per job

Run 40304631701 on head 6d63333, first attempt, no reruns; the known flaky #271 and #301 did not fire [T].

Job Result Time
Go Build & Test pass 0m27s
Go Ingestor Tests pass 4m13s
Go Libraries & CLI pass 2m03s
Go Server Tests (1/3) pass 6m46s
Go Server Tests (2/3) pass 5m25s
Go Server Tests (3/3) pass 12m25s
JavaScript Unit Tests & Lint pass 2m44s
Script & Container Unit Checks pass 1m10s
Playwright E2E Tests (1/3) pass 14m21s
Playwright E2E Tests (2/3) pass 14m58s
Playwright E2E Tests (3/3) pass 12m55s
Validate Docker Image pass 0m48s
Verify E2E Shards & Coverage pass 0m18s
Verify Go Test Assignments pass 1m13s
Build & Publish Docker Image pass 0m14s
Publish Badges & Summary / Deploy Staging / Release Artifacts skipped (fork guards) —

Not verified

  • Production or staging timings and plans (no access, by instruction). On the production shape the cold window is about the index build time from boot [A]. The reviewer estimated that at ~70–90 s on 2 vCPU [K].
  • Browser validation beyond the Playwright suites above; there is no visual change.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-MacBook PR#619 — head 6d63333

Dom: APPROVE with nits

Round 2. Evidence tags: [T] run for this review, [A] argued from the code and not executed, [K] carried over from the author's report, CI or round 1 and not re-run.

Trees: head 6d633333 (git archive); merged tree 24295534 = git merge-tree --write-tree origin/master(b519e6bb) 6d633333, no conflicts. Every run below used the merged tree unless noted. git ls-remote showed the head at 6d633333 before and after the review.

Findings

# Sev Finding Evidence
F1–F4 — Round-1 findings resolved (details below). [T]
F7 Low (test gap) Sharing of the index flight by cold requests is no longer pinned by a test. Own mutant X1 gives every regionIndexFlight call its own singleflight key (DoChan(fmt.Sprint(time.Now().UnixNano()), …)). It survives: ix.mu still serialises the refreshes, and the late ones fall into the idle re-stamp branch, so ColdRequestsDuringWarmup still sees 1 full and 0 delta scans. The real cost is on the failure path [A]. With a failing index, N concurrent cold requests run N failed full index attempts one after another under ix.mu / nodeRegionFullMu, and only then fall back. A test with a failing, held scanHook and 3 cold sets that expects 1 full attempt would pin it. [T] mutant, [A] impact
F8 Nit Worst case on a persistently failing index. A cold request that starts a fresh index attempt pays the failed scan plus its own per-set scan. A retry started by a later cold request (nodeRegionFullMu) can also run alongside a fallback (nodeRegionColdMu), so up to two full scans. It is the exceptional path, and it is still not a 500. A line under "Known limits" would be enough. [A]
F5, F6 Nit Carried as follow-ups, as agreed in round 2. The warmer ignores pauseWhenIdle, and request-started work is not cancelled at shutdown. The author's addition is correct [A]: when the warmer is stopped mid-build, waiting cold requests get context.Canceled and fall back to a per-set build during the drain. [A]

Verification points

  • F1, option 1: implemented as decided. [T]/[A]
    • coldNodeRegion now calls regionIndexFlight first. That joins the warmer's in-flight build through ix.sf, or starts one when there is no warmer.
    • The per-set build runs only after that returns an error, under nodeRegionColdMu. A re-check of ix.snap after taking the lock cuts from an index that was published meanwhile.
    • The detached index goroutine and the per-set goroutine are gone, so the cold path no longer runs a second concurrent scan.
    • Placed mode (perSet == nil) is unchanged and goes through currentRegionIndex.
    • ColdRequestsDuringWarmup is red on 66d8945c, which has the new tests on the old race code: "6 cold requests were served before the warmup's build landed (3 per-set builds)". It is green on the head.
  • O5, F2 fallback (return the index error at once): killed by ColdRequestFallsBackWhenIndexFails, QueuedFallbackTakesRecoveredIndex and ColdPerSetBuildsAreSerialised. All three fail on assertions, for example region "AAA": … injected scan failure. [T]
  • O4, F3: the code is removed, correctly. [A]/[T]
    • The old ctx.Err() check in buildNodeRegionCold could only fire when the race's winning index build cancelled the per-set ctx. Without the race, its ctx is refreshNodeRegion's own 30-min timeout, so the check was dead. The WithoutCancel re-timeout likewise only guarded a losing scan. [A]
    • The replacement is the post-lock ix.snap re-check. Mutant O4′ (re-check removed) is killed by QueuedFallbackTakesRecoveredIndex: "2 queued fallbacks ran a per-set build after the index was published, want 0". [T]
    • Own mutant X2 moves the re-check before the lock. It is killed by the same test with the same assertion. [T]
  • O2, F4 (idle refresh does not re-stamp refreshed): killed by IdleRefreshReStampsIndex: "returned snapshot is 30m0s old, want re-stamped". The test also passes on 66d8945c, as the author says: it closes a gap and does not reproduce a bug. [T]
  • Restated acceptance criterion. "A cold request during warmup waits at most the remaining warmup time; after warmup every set is answered in milliseconds." It is met on both generator DBs. The cold latency equals the rest of the warmup within 1 ms at every offset, and the warmup is no longer slowed (400 ms flat on perf(nodes): port upstream region membership cache (#2102) #176, against 474–809 ms with the race in round 1). [T]
    • The deviation from "never longer than today" applies on the 13-equal-region shape: 1.72× at a 0 % offset, 1.28× at 25 %. It is disclosed in the PR body and was the owner's decision.
  • New cold/warm numbers reproduce. Merged tree, run alone, local [T]:
#488 generator (1.07M obs, 4 regions) #176 generator (1.5M obs, 13 regions)
master per-set builds, summed 1.022 s 3.713 s
index cold build, all codes 62 ms 400 ms
largest code, per-set build alone BBB 161 ms SJC 233 ms
cold request at 0 / 25 / 50 / 75 % of warmup 56 / 39 / 23 / 7 ms 400 / 299 / 199 / 101 ms
… rest of warmup 56 / 39 / 23 / 7 ms 400 / 298 / 198 / 101 ms
warmup landed after 55–56 ms 400–402 ms
cold request, no warmer 66 ms 400 ms
warm: worst cut / cached hit 287 µs / 167 ns 844 µs / 583 ns
index heap 1.3 MiB 7.9 MiB

These numbers match the author's ratios: their absolute times are about 1.8× higher on a slower machine. They also match round 1's estimate of "just waiting for the index" (~405 / 304 / 203 / 101 ms).

Tests and mutants

What Result
cd cmd/server && go test ./... ok (79 s) [T]
cd cmd/ingestor && go test ./... ok (113 s) [T]
go test -race -count=3 -run 'NodeRegionIndex618|NodeRegion|RegionPlacement|GetNodesRegion' ok [T]
sh test-all.sh 274/274 files passed [T]
node test-frontend-helpers.js 731 passed, 0 failed [T]
Large-DB union property #488: 15/15 sets equal, 1 full scan; #176: 92/92 sets equal, 1 full scan [T]
scripts/check-xss-sinks.sh --diff origin/master clean, no public/ changes [T]
CI run 40304631701 on 6d633333 every job passes, first attempt; Publish Badges, Deploy Staging and Release Artifacts skipped by the fork guards; #256 and #267 did not fire [T]

E2E. Local Go server on port 13900 against e2e-fixture.db, prepared as in CI: freshen, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL extracted from deploy.yml, corescope-migrate, then seeds 2073, 199, 245 and 122. The server logged heard: full build … 4 regions in 1ms at boot with no request. It was stopped by its listening pid; "shutdown complete" was logged and the port was free afterwards. [T]

  • test-e2e-playwright.js: 156/159, 3 skipped, 0 failed.
  • test-issue-416-region-quick-picks-e2e.js: 17/17.
  • test-issue-531-region-url-e2e.js: 31/31.
  • test-map-nodes-pagination-e2e.js: 3/3.
  • test-regions-map-e2e.js: passed.

Mutants. All run on a merged-tree copy with the filter NodeRegion|RegionPlacement|GetNodesRegion|ServerSourceHasNoCachedRW. The clean baseline is green. Every kill below was on a failing assertion. go vet flags O5's unreachable code, but the test binary built and failed on assertions. [T]

Mutant Change Result
O5 index error returned at once, no fallback killed (3 tests)
O4′ post-lock re-check of ix.snap removed killed (QueuedFallbackTakesRecoveredIndex)
O2 idle refresh does not re-stamp killed (IdleRefreshReStampsIndex)
X2 (own) re-check moved before nodeRegionColdMu.Lock() killed (QueuedFallbackTakesRecoveredIndex)
X3 (own) every heard request goes through coldNodeRegion, even with a snapshot killed (RequestsDoNotQueueBehindRebuild: "waited for the running rebuild")
X1 (own) unique singleflight key per regionIndexFlight call survived → F7

Red before green. On 66d8945c, ColdRequestsDuringWarmup, ColdRequestFallsBackWhenIndexFails and QueuedFallbackTakesRecoveredIndex fail, and all three pass on the head [T]. IdleRefreshReStampsIndex is a gap test and passes on both [T]. Round 1's acceptance criteria were covered there [K].

Hygiene. [T]

Not verified

  • Production and staging timings and plans. The production cold window (about the index build time from boot, estimated at 70–90 s on 2 vCPU) remains [A]/[K].
  • The author's R1 and M13 mutants [K]. The O5, O4′ and O2 equivalents were re-run here [T].
  • Browser validation beyond the Playwright suites listed; there is no visual change.
  • The full CI E2E shard set locally.

@dborup
dborup marked this pull request as ready for review October 26, 2026 10:55
@dborup
dborup merged commit 960c910 into master Oct 26, 2026
18 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