Repository navigation
perf(startup): defer chunked-loader distance indexing to the lazy builder - #589
Conversation
LoadChunked builds the distance index under s.mu at the end of startup without publishing the lazy builder's ready/generation state, so the first /api/analytics/distance request builds it again. - TestDistanceIndexNotBuiltOnLoadChunked: the LoadChunked variant of TestDistanceIndexNotBuiltOnLoad (red: 3 hops, 3 paths after load). - TestDistanceLoadChunkedFirstRequestBuildsOnce558: concurrent first requests with the recomputers running get 202, then 200 equal to the eager-build result after one build that starts on an empty index (red: the build started on 6 records). - Path-hop readiness and #149's generation gate on the LoadChunked path as regression guards (green). - An opt-in measurement test for the 150K tx / 2.5M obs shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chunk merge already indexes resolved-pubkey keys, so a non-empty byPathHop did not prove that the post-load buildPathHopIndex ran: a mutant without it passed. Assert every raw-hop key is indexed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unked (#558) LoadChunked built the distance index under s.mu at the end of the startup load, but did not mark it built for the lazy gate (Kpa-clawbot#1011, #149), so the first /api/analytics/distance request built it again. Drop the eager build, as legacy Load() already does: the first request gets 202 while the one lazy build runs, then 200. The path-hop index and its readiness stay where they were. The node snapshot taken before the chunk loop is now used only for the relay prefix map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent1 PR#589 #558 — head b7a0ebaStatus: Draft. The fix and tests are complete, every local suite is green, and all 15 CI jobs that ran passed on the first run (no rerun); not merged and not marked ready. Evidence: [T] automated test result · [A] analysis / code reading · [K] command run or measurement on this machine or in CI. Requirement 1: no eager
|
| Check | Test | Before fix | After fix | Mutant (killed) |
|---|---|---|---|---|
Index empty, not built and not building after the real LoadChunked; no lazy build started by startup; a later trigger yields records (not vacuous) |
TestDistanceIndexNotBuiltOnLoadChunked |
red: 3 hops, 3 paths [T] |
green [T] | M1 eager call restored → red; M2 TriggerDistanceIndexBuild() at the end of LoadChunked → red (DistanceIndexBuilding() = true) [T] |
The fix (b7a0eba0) removes the call. The node snapshot now only supplies relayPM [A]. The lazy gate code is untouched [A].
Requirement 2: keep path-hop/subpath indexes and the readiness that startup and repeater enrichment need
| Check | Test | Before fix | After fix | Mutant (killed) |
|---|---|---|---|---|
PathHopIndexReady, WaitIndexesReady(0) and every raw path-hop key indexed after LoadChunked |
TestLoadChunkedPathHopIndexReadyAtStartup558 |
green (regression guard; the behaviour was already correct) [T] | green [T] | M3 buildPathHopIndex dropped → red; M4 markIndexesReadySync dropped → red [T] |
M3 first survived a weaker "byPathHop non-empty" check, because the chunk merge already adds resolved-pubkey keys. The test was strengthened in b871c486 [T][A].
Requirement 3: the first distance request builds exactly once and returns the same result as before
| Check | Test | Before fix | After fix | Mutant (killed) |
|---|---|---|---|---|
Recomputers running; 16 concurrent first requests → 202; request during the held build → 202 + Retry-After: 5; then 200 deep-equal to the old eager-build analytics; exactly 1 build, which starts on an empty index; later requests → 200 with no further build |
TestDistanceLoadChunkedFirstRequestBuildsOnce558 |
red: the lazy build started on an index that already held 6 records [T] |
green [T] | M1 eager call → red; M5 in-flight guard dropped → red (lazy builds = 17); M6 RecomputeNow dropped → red (200 differs from the eager result, a stale snapshot) [T] |
Requirement 4: do not regress #149's race, generation and lock-order fixes
| Check | Test | Before fix | After fix | Mutant (killed) |
|---|---|---|---|---|
Background-load completion after the build read the LoadChunked dataset: the old generation is not reported built, the handler answers 202 during the rebuild and 200 after, 2 passes. Channels and a parked-goroutine check, no sleeps, run in a child process |
TestDistanceLoadChunkedLoadCompletionDuringBuild558 |
green (guard) [T] | green [T] | M7 stale := false → red (timeout waiting for the rebuild); M8 built generation read at bookkeeping → red (DistanceIndexBuilt() = true for the stale index) [T] |
Existing #149 / Kpa-clawbot#1011 suites (distance_lazy_build_149_test.go, distance_lazy_index_test.go, lock-order guard and contention tests) |
full server suite | — | green, also with -race [T][K] |
— |
Lock order: no new s.mu / distLazyMu nesting; the diff only deletes a call inside an existing s.mu section |
— | — | [A] | — |
Requirement 5: -race, TestNoSQLUnderStoreLock
- The 4 new tests plus
TestDistanceIndexNotBuiltOnLoad,TestNoSQLUnderStoreLockandTestNoSQLUnderStoreLockDetectswith-race -count=30: ok [K]. - The full
cmd/serversuite with-race -timeout 60m: ok in 1229 s, no data races [K]. A first run under Go's default 10 m timeout panicked on the timeout (a wall-clock limit, not a test failure). CI uses 3 shards at 20 m each [K][A]. - In CI, the new tests ran and passed in Go Server Tests (3/3), which runs with
-race. The measurement test was skipped there as intended [K].
Requirement 6: measure startup time and s.mu hold time in LoadChunked, before and after
The TestDistanceLazyChunked558_Measure harness (opt-in) used the same DB for both sides: #534's large-instance generator with 150 000 tx, 2 435 578 obs and 2 000 nodes (spread over about 1°), giving 303 111 hop and 148 672 path records. The machine was an i7-1260P with GOMAXPROCS=4 and go1.27.1. There were 3 interleaved runs per side, "before" being the test-only commit b871c486 [K].
| Metric | Before (median; runs) | After (median; runs) |
|---|---|---|
| Distance builds until the first request is ready | 2 (1 startup + 1 lazy) | 1 (lazy) |
s.mu post-load hold seen by a reader |
1.66 s (1.48 / 1.66 / 2.66) | 0.38 s (0.36 / 0.38 / 0.49) |
LoadChunked wall time |
37.6 s (37.1 / 38.4 / 37.6) | 35.2 s (33.2 / 35.2 / 38.5) |
Longest s.mu hold during the load (chunk merge) |
2.50 s | 2.32 s |
| First request: trigger → index built | 1.04 s | 1.10 s |
Trade-off: the work is deferred, not removed. The first distance request pays about 1 s for the cold build, as it already did before, because the startup-built index was never marked built. Wall-time differences are within noise [K][A].
Standing constraints
| Constraint | Result |
|---|---|
cmd/server stays read-only |
No DB access added; the diff only removes an in-memory build call [A] |
No new map[string]interface{} outside tests |
0 added lines outside _test.go [K] |
| No hardcoded colours | No frontend or CSS changes [K] |
scripts/check-xss-sinks.sh --diff origin/master |
Clean: no public/ changes to scan [K] |
| Fork guards | deploy.yml: 9 matches; release-fast-path.yml: 1; no .github diff [K] |
| Commits | 3 commits, explicitly added files, no rebase, amend or force-push [K] |
Local runs
| Suite | Result |
|---|---|
cmd/server: go vet ./... + go test ./... |
ok [K] |
cmd/ingestor: go vet ./... + go test ./... |
ok [K] |
sh test-all.sh |
270/270 files [K] |
node test-frontend-helpers.js |
725 passed [K] |
Local Go server on a copy of e2e-fixture.db prepared as in CI (freshen, seed rows, corescope-migrate, seed files); stopped by pid, port then free |
Startup logs no distance build; /api/analytics/distance returns 202 once, then 200 with data; 1 Built distance index line in total [K] |
test-e2e-playwright.js against it |
154/157 passed, including "Analytics Distance tab renders content". The 3 skips are built into the suite (2 marked flaky, 1 needs GO_BASE_URL) [K] |
CI per job (run 40173799303, head b7a0eba)
| Job | Result |
|---|---|
| Go Build & Test | pass |
| Go Ingestor Tests | pass |
| Go Libraries & CLI | pass |
| Go Server Tests (1/3) | pass |
| Go Server Tests (2/3) | pass |
| Go Server Tests (3/3) | pass (ran the new tests) |
| JavaScript Unit Tests & Lint | pass |
| Script & Container Unit Checks | pass |
| Playwright E2E Tests (1/3) | pass |
| Playwright E2E Tests (2/3) | pass |
| Playwright E2E Tests (3/3) | pass |
| Validate Docker Image | pass |
| Verify E2E Shards & Coverage | pass |
| Verify Go Test Assignments | pass |
| Build & Publish Docker Image | pass (fork-guarded steps) |
| Publish Badges & Summary, Release Artifacts, Deploy Staging | skipped (fork guards) |
The known flaky test #271 did not fail, so there was no rerun [K].
Remaining items
- The local E2E used the plain
public/directory, not the instrumented copy CI uses (coverage collection only). CI covers the instrumented run [K]. - The measurement was synthetic data on one laptop-class machine; no staging or production measurement was made, by design [A].
- The longest
s.muhold at startup is still the chunk merge (about 2.3–2.5 s at this size). That is out of scope for perf(startup): defer chunked-loader distance indexing to the lazy builder #558 [K][A].
Review — CS-pve-agent3 PR#589 — head b7a0ebaDom: APPROVE with nits Independent, read-only review. Evidence: [T] automated test result · [A] analysis / code reading · [K] command run or measurement by me (my own scratch trees, not the author's numbers). Trees used: Findings
No blocking findings. The points to verifyEager distance index removed from Path-hop/subpath readiness intact. The first distance request builds exactly once.
#149 race/generation/lock-order work holds.
These numbers agree with the PR's direction and magnitude: the post-load exclusive hold drops by about 4×, and there is one build fewer. Wall time and the chunk-merge maximum are within noise. As stated, the cost is deferred to the first request, not removed [K][A].
Acceptance criteria → tests (red before / green after)
The two "guard" rows are regression guards for behaviour that already worked, so red-before is not applicable. I proved they are not vacuous with mutants (R3; the author's M3/M4) [T]. My mutants (on the PR head)
Standing constraints
Tests I ran (merged tree against
|
| Suite | Result |
|---|---|
cmd/server: go vet ./... + go test -count=1 ./... |
ok [K] |
cmd/ingestor: go vet ./... + go test -count=1 ./... |
ok [K] |
Focused -race -count=20 (see above) |
ok [K] |
sh test-all.sh |
271/271 files passed [K] |
node test-frontend-helpers.js |
725 passed, 0 failed [K] |
Local Go server on a copy of e2e-fixture.db prepared as in CI (freshen, seed SQL from deploy.yml, corescope-migrate, seeds 2073, 199, 245 and 122); stopped via the pid found on its port, port free afterwards |
Startup: no distance build; first request 202, then 200 with data [K] |
test-e2e-playwright.js, once with a warm index and once on a cold server |
154/157 both times, 3 built-in skips (2 marked flaky, 1 needs GO_BASE_URL); "Analytics Distance tab renders content" passes [K] |
test-issue-1648-m4-icons-e2e.js (cold, then warm) |
9/9 both runs [K] |
test-a11y-axe-1668.js |
0 violations across 120 cells, including 4 /analytics?tab=distance cells [K] |
PR-specific Go tests on b871c486 (red-before check) |
2 red, 2 guards green, as tabled above [T] |
CI (run 40173799303, head b7a0eba0, attempt 1). All 15 jobs that ran passed, including Go Server Tests 1–3/3, Go Ingestor, Playwright E2E 1–3/3, JS unit/lint, Verify E2E Shards and Verify Go Test Assignments. Publish Badges, Release Artifacts and Deploy Staging were skipped by the fork guards. The server shard logs contain 0 "Action":"fail" events. The known flaky tests (#256, #267) did not fail, so there was no rerun [K].
Not verified
- I did not repeat the author's 150K-tx / 2.4M-obs measurement. Mine is a smaller shape (50K tx) on the same harness; no staging or production numbers [K].
- I did not run the full
cmd/serversuite with-race, only the focused set above. CI runs the full suite with-race[K]. - Local E2E used plain
public/, not the instrumented copy. I ran the main suite plus the two distance-touching E2E files, not all shards [K]. test-issue-1648-m4-icons-e2e.jswarns "no map-jump button rendered (empty dataset)" on the distance tab, both cold and warm. I did not check whetherorigin/masterprints the same warning. The test passes either way, and the warning does not depend on the index being cold [K][A].- No browser screenshot. The change has no UI component [A].
Relates to #558
Problem
LoadChunked(the production startup loader) calleds.buildDistanceIndex(nodeSnapshot)inside its post-loads.mu.Lock()section. That build never set the lazy builder's state (distLazyBuilt/distLazyBuiltGen), soDistanceIndexBuilt()stayed false. The first/api/analytics/distancerequest answered 202 and started the lazy build anyway, which built the same index a second time. LegacyLoad()already left this build out (Kpa-clawbot#1011).TestDistanceIndexNotBuiltOnLoadonly coveredLoad().Plan / change
47abf286,b871c486). They reproduce the double build through the realLoadChunkedpath and guard the startup indexes plus fix(store): lazy distance build can crash (sync.Once reset during Do) and deadlock (lock order) #149's generation gate on that path.b7a0eba0): drop the eagerbuildDistanceIndexcall fromLoadChunked. The node snapshot taken before the chunk loop now only supplies the relay prefix map. Nothing else changes:buildPathHopIndex()stays in the post-load section, andmarkIndexesReadySync()still runs after the unlock. Startup dependants such asStartRepeaterEnrichmentRecomputer→WaitIndexesReadysee the index ready immediately.TriggerDistanceIndexBuild/runDistanceIndexBuild) is untouched. Its fix(store): lazy distance build can crash (sync.Once reset during Do) and deadlock (lock order) #149 lock order (s.mu never taken underdistLazyMu), the generation check and the cache reset plusRecomputeNowbefore the index is marked built all stay as they are.map[string]interface{}outside tests.Tests
TestDistanceIndexNotBuiltOnLoadChunkedTestDistanceIndexNotBuiltOnLoad: index empty, not built, no lazy build started by startup; a later trigger does produce records3 hops, 3 paths)TestDistanceLoadChunkedFirstRequestBuildsOnce558started on an index that already held 6 records)TestLoadChunkedPathHopIndexReadyAtStartup558PathHopIndexReady,WaitIndexesReady(0)and every raw path-hop key indexed after LoadChunkedTestDistanceLoadChunkedLoadCompletionDuringBuild558TestDistanceLazyChunked558_MeasureCORESCOPE_558_MEASURE=1)Mutants, all killed: eager build restored; lazy build kicked at the end of LoadChunked;
buildPathHopIndexdropped;markIndexesReadySyncdropped; in-flight guard dropped fromTriggerDistanceIndexBuild;RecomputeNowdropped after the build; stale pass not rebuilt; built generation read at bookkeeping.Perf justification
Hot path: the startup load, under
s.mu. The change removes an O(transmissions × hops) build from the exclusive section. That work now runs once, through the existing lazy builder, on the first distance request. It is deferred, not removed.Same synthetic DB for both sides (#534's large-instance generator: 150 000 tx, 2 435 578 obs, 2 000 nodes spread over about 1°, giving 303 111 hop / 148 672 path records). i7-1260P, GOMAXPROCS=4, go1.27.1, 3 interleaved runs each:
Trade-off: the first distance request pays the cold build (about 1 s at this size). It already did before, because the startup-built index was never marked built. Wall-time differences are within run-to-run noise. The clear wins are one fewer build and roughly 1.3 s less exclusive
s.muhold at the end of startup. The longest hold overall (the chunk merge) is unchanged by this PR.Reproduce (the measurement file also compiles on the test-only commit, which gives the "before" numbers):
Validation (local)
cd cmd/server && go vet ./... && go test ./...: ok. The full suite with-race -timeout 60m: ok (no races). The four new tests with-race -count=30, together withTestDistanceIndexNotBuiltOnLoad,TestNoSQLUnderStoreLockandTestNoSQLUnderStoreLockDetects: ok.cd cmd/ingestor && go vet ./... && go test ./...: ok.sh test-all.sh: 270/270 files.node test-frontend-helpers.js: 725 passed.e2e-fixture.dbprepared as in CI (freshen, seed rows,corescope-migrate, seed files): startup logs the path-hop build and no distance build./api/analytics/distancereturns 202 once, then 200 with data, and oneBuilt distance indexline in total.test-e2e-playwright.jsagainst it: 154/157 passed. The 3 skips are built into the suite (two marked flaky, one needsGO_BASE_URL), and "Analytics Distance tab renders content" passes.scripts/check-xss-sinks.sh --diff origin/master: no frontend changes to scan. No.githubchanges.🤖 Generated with Claude Code