Skip to content

test(server): fix the reach-rank expired-snapshot round deadline flake (#237) - #269

Merged
dborup merged 1 commit into
masterfrom
codex/issue-237-reach-rank-flake
Oct 6, 2026
Merged

dborup merged 1 commit into
masterfrom
codex/issue-237-reach-rank-flake

Conversation

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Relates to #237

Cause

TestReachRank_ExpiredSnapshotAlwaysRefreshes hammers an expired snapshot from
16 goroutines with no pause between requests. On a loaded host those 16
runnable goroutines take every available P from the one goroutine the round is
actually waiting for — the single singleflight refresh — and the delay lands
inside loadDegreeSnapshot. A round then misses its 3 s deadline with nothing
wrong in production.

To answer the question the issue asks directly: the round is starved, but
it is starved by the test itself, not by the scheduler in general and not by a
leftover singleflight flight from the previous round.

Evidence

Instrumented scratch copy (per-round duration, per-round refresh count,
singleflight path counters, goroutine dump when a round runs long), -race,
GOMAXPROCS=2, loaded host.

  • The round waits on exactly one load, and that load is what runs long.
    Slowest round captured: 276 ms, of which 272 ms inside
    loadDegreeSnapshot
    . [T]

  • Goroutine dump at that moment [A]: all 16 hammer goroutines parked in
    singleflight.(*Group).DoChan on the group mutex; the single refresh
    goroutine parked inside the sqlite driver mid-query
    (newRows → columnCount, semacquire), reached through
    refreshDegreeSnapshot.func1 → loadDegreeSnapshot → scanRankRows.

  • A leftover flight is real but cheap. A round sees 1–8 flights, of which
    at most one loads; the rest return the already-fresh snapshot and exit. A
    caller that joins a leftover loses one loop iteration out of the ~1000 it
    runs per round, not a round. [T]

  • The retry backoff is never involved. degreeFailAt is the zero time and
    degreeFailErr is nil at every slow round, so the 15 s
    reachDegreeRetryBackoff path is not what suppresses the refresh. [T]

  • Volume of the hammer: ~490k–640k refreshDegreeSnapshot calls per test
    process, up to ~30k in a single round, each appending a singleflight result
    channel that nobody ever reads. [T]

  • The round-duration tail scales with how hot the hammer is. Same host,
    same load, interleaved runs, 300 rounds each: [T]

    per-request pause requests/process dur p50 dur p99 dur max
    none (master) ~490k–560k 1.9–2.3 ms 17–43 ms 36–75 ms
    100 µs ~28k–34k 1.2–1.4 ms 2–11 ms 2.4–12.2 ms
    250 µs ~12k–20k 1.4–1.5 ms 2–12 ms 2.7–22 ms
    1 ms ~6k–8k 2.2–2.4 ms 6–13 ms 8–21 ms

This is scheduling starvation, so by the issue's own rule a raised deadline
would have been allowed — but the starvation is the test's own doing, so the
deadline stays at 3 s and the hammer is fixed instead.

Fix

Test-only, both hunks in TestReachRank_ExpiredSnapshotAlwaysRefreshes.

  1. Each caller pauses degreeHammerPause (100 µs) between requests. Every
    round still has 16 concurrent callers hitting the stale snapshot
    continuously, ~30k requests per process and over 100 per round; the test
    just stops competing with the refresh for the last free P. 100 µs was
    picked from the table above: it has the best tail and still issues 4× the
    requests of a 1 ms pause.
  2. awaitDegreeRefresh moves into t.Cleanup — the test(server): deflake reach_rank hook race and the #1008 not-ready window (#224, #227) #234 review item that
    belongs to this test. A t.Fatalf in the round loop skipped the tail call,
    so a failing run could leave a stray refresh behind and add a misleading
    race report to the next test. Cleanup is LIFO, so it runs before
    resetReachState's cleanup, while the snapshot is still there.

No production change. The 3 s per-round deadline, the 300 rounds and the 16
callers are unchanged.

Measurements

go test -race -count=200 -run TestReachRank_ExpiredSnapshotAlwaysRefreshes ./...
in cmd/server, with three cmd/server -race suite loops plus eight CPU
burners alongside. The reported symptom reproduces on master. [T]

failures load avg during the run
master, run 1 1/200 — round 150: expired snapshot never refreshed (3.63 s) 45
master, run 2 1/200 — round 128: expired snapshot never refreshed (3.62 s) 38
master, run 3 0/200 40
this branch, run 1 0/200 38
this branch, run 2 0/200 247
this branch, run 3 0/200 257

Master fails 2/600 at load ~40; the branch is clean 0/600, and two of its three
runs ran at 6× that load. The literal acceptance command was then run three
more times at default GOMAXPROCS with the same load setup: 0/200, 0/200,
0/200 (162 s, 182 s, 172 s), the last two at load 235–331. [T]

Because a 1/200 rate is a poor measuring instrument, the same comparison was
also run with the 3 s budget scaled down 100× to 30 ms, which turns the
rare tail event into a rate, 20 processes per cell, interleaved: [T]

failures
master's hammer, deadline 30 ms 9/20, 8/20, 17/20
100 µs pause, deadline 30 ms 0/20, 1/20, 1/20

Mutants

Mutant Result
A — the pause removed (master's hammer), deadline scaled to 30 ms, 20 processes per cell, interleaved, load ~210 killed: mutant 13/20 and 11/20 missed the round deadline, fixed 0/20 and 0/20 [T]
B — awaitDegreeRefresh back as a tail call instead of t.Cleanup, with a round assertion forced to fail while one extra refresh is in flight (the condition the item protects against), run together with TestReachRank_RefreshHonoursBackoffAfterQueuedFailure, 20 processes per cell killed: mutant 12/20 -race reports and 1/20 "2 loads inside the backoff window", fixed 0/20 and 0/20 [K]
B, unforced — same two shapes, a round forced to fail but no extra refresh injected, 40 processes per cell survived: 0/40 both ways. With the pause in place the stray-refresh window of #224 is itself much harder to hit, so the t.Cleanup join is now defence in depth rather than something an unforced mutant can distinguish. Noted, not hidden. [T]

Checks

  • cd cmd/server && go test -race -count=1 -timeout 40m ./... (whole package,
    once, unloaded): ok 486.069 s, 0 race reports. [T]
  • go vet ./... clean. gofmt -l reach_rank_test.go clean.
  • cmd/server stays read-only — the change is one test function. No new
    map[string]interface{}. Fork guards unchanged: 9 in deploy.yml, 1 in
    release-fast-path.yml.

Remaining

The other two #234 review items are in index_ready_1008* and belong to a
different test, so they are not folded in here:

  1. TestIssue1008_PathHopIndexReadyFalseImmediatelyAfterLoad has the test: TestIssue1008_HandlerReturns503WhileSubpathIndexLoading races the background index build under load (-race flake) #227 race
    class (asserts "not ready" right after Load(); the path-hop builder can
    win). It needs a gate like subpathBuildGate for the path-hop builder.
  2. store.subpathBuildGate = func() { close(building); <-release } closes
    building unguarded, so a second Load() on the same store would panic.
    It wants a sync.Once.

Worth one follow-up issue; not filed from here.

Also observed but not changed: the stale-while-revalidate path in
getDegreeSnapshot calls refreshDegreeSnapshot(ctx) and discards the
channel, so every request arriving during a refresh allocates a singleflight
result channel that nobody reads and that the finishing flight must still send
to while holding the group mutex. The test made that pathological (up to ~30k
per round); at real request rates it is negligible, which is why production is
left alone here.

🤖 Generated with Claude Code

…efresh

TestReachRank_ExpiredSnapshotAlwaysRefreshes hammers an expired snapshot from
16 goroutines with no pause between requests. On a loaded host those 16 take
every available P from the one goroutine the round is waiting for, the single
singleflight refresh, and the delay lands inside loadDegreeSnapshot. A round
then misses its 3 s deadline with nothing wrong in production (#237).

Each caller now pauses 100 us between requests. Every round still has 16
concurrent callers hitting the stale snapshot continuously; the test just
stops issuing ~530k requests per process. The deadline is unchanged.

Also takes the test-hardening item from the #234 review that belongs to this
test: awaitDegreeRefresh moves into t.Cleanup, so the join also runs when a
round assertion fails and a failing run cannot leave a stray refresh behind
for the next test. Cleanup is LIFO, so it runs before resetReachState's.

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

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#269 #237 — head cfa4e0a

Status: Cause named and measured, fixed in the test, acceptance met 3/3 under load (master reproduces 1/200 twice), both mutants killed, CI green on every job.

Cause [T] [A]

The test hammers an expired snapshot from 16 goroutines with no pause between
requests
. On a loaded host those 16 runnable goroutines take every available P
from the one goroutine the round is waiting for — the single singleflight refresh —
and the delay lands inside loadDegreeSnapshot. The round then misses its 3 s
deadline with nothing wrong in production.

So the round is starved, but by the test itself. The two alternatives the issue
asked about were both ruled out by measurement:

  • Not a leftover flight from the previous round. A round sees 1–8 flights, of
    which at most one loads; the rest return the already-fresh snapshot and exit. A
    caller that joins a leftover loses one loop iteration out of the ~1000 it runs per
    round, not a round. [T]
  • Not the retry backoff. degreeFailAt is the zero time and degreeFailErr is
    nil at every slow round, so the 15 s reachDegreeRetryBackoff path never
    suppresses the refresh. [T]

Evidence, from an instrumented scratch copy (per-round duration, per-round refresh
count, singleflight path counters, goroutine dump on a long round), -race,
GOMAXPROCS=2, loaded host:

  • The round waits on exactly one load, and that load is what runs long. Slowest
    round captured: 276 ms, of which 272 ms inside loadDegreeSnapshot. [T]

  • Goroutine dump at that moment [A]: all 16 hammer goroutines parked in
    singleflight.(*Group).DoChan on the group mutex; the refresh goroutine parked
    inside the sqlite driver mid-query (newRows → columnCount, semacquire),
    reached via refreshDegreeSnapshot.func1 → loadDegreeSnapshot → scanRankRows.

  • Hammer volume: ~490k–640k refreshDegreeSnapshot calls per test process, up to
    ~30k in one round, each appending a singleflight result channel nobody reads. [T]

  • The round-duration tail scales with how hot the hammer is — same host, same
    load, interleaved, 300 rounds per run: [T]

    per-request pause requests/process dur p50 dur p99 dur max
    none (master) ~490k–560k 1.9–2.3 ms 17–43 ms 36–75 ms
    100 µs ~28k–34k 1.2–1.4 ms 2–11 ms 2.4–12.2 ms
    250 µs ~12k–20k 1.4–1.5 ms 2–12 ms 2.7–22 ms
    1 ms ~6k–8k 2.2–2.4 ms 6–13 ms 8–21 ms

This is scheduling starvation, so by the issue's own rule a raised deadline would
have been permitted. It is the test's own starvation, so the deadline stays at 3 s
and the hammer is fixed instead.

Fix

Test-only, two hunks in TestReachRank_ExpiredSnapshotAlwaysRefreshes, no production
change.

  1. Each caller pauses degreeHammerPause (100 µs) between requests. Every round
    still has 16 concurrent callers hitting the stale snapshot continuously — ~30k
    requests per process, over 100 per round; the test just stops competing with the
    refresh for the last free P. 100 µs was picked from the table above: best tail,
    and still 4× the requests of a 1 ms pause.
  2. awaitDegreeRefresh moves into t.Cleanup — the test(server): deflake reach_rank hook race and the #1008 not-ready window (#224, #227) #234 review item belonging to
    this test. A t.Fatalf in the round loop skipped the tail call, so a failing run
    could leave a stray refresh behind and add a misleading race report to the next
    test. Cleanup is LIFO, so it runs before resetReachState's, while the snapshot
    is still there.

The 3 s per-round deadline, the 300 rounds and the 16 callers are unchanged.

Measurements before and after [T]

go test -race -count=200 -run TestReachRank_ExpiredSnapshotAlwaysRefreshes ./... in
cmd/server, with three cmd/server -race suite loops plus eight CPU burners
alongside. The reported symptom reproduces on master.

failures load avg during the run
master, run 1 1/200 — round 150: expired snapshot never refreshed (3.63 s) 45
master, run 2 1/200 — round 128: expired snapshot never refreshed (3.62 s) 38
master, run 3 0/200 40
branch, run 1 0/200 38
branch, run 2 0/200 247
branch, run 3 0/200 257

Master: 2 failures in 600 at load ~40. Branch: 0 in 600, two of three runs at 6×
that load
. The literal acceptance command was then run three more times at default
GOMAXPROCS with the same load setup: 0/200, 0/200, 0/200 (162 s, 182 s, 172 s), the
last two at load 235–331 — so the acceptance criterion is met 3 times, not 2.

A 1/200 rate is a poor instrument, so the same comparison was also run with the 3 s
budget scaled down 100× to 30 ms, turning the rare tail event into a rate; 20
processes per cell, interleaved: [T]

failures
master's hammer, deadline 30 ms 9/20, 8/20, 17/20
100 µs pause, deadline 30 ms 0/20, 1/20, 1/20

Mutants

Mutant Result
A — pause removed (master's hammer), deadline scaled to 30 ms, 20 processes per cell, interleaved, load ~210 killed: mutant 13/20 and 11/20 missed the round deadline; fixed 0/20 and 0/20 [T]
B — awaitDegreeRefresh back as a tail call instead of t.Cleanup, with a round assertion forced to fail while one extra refresh is in flight (the exact condition the item protects against), run together with TestReachRank_RefreshHonoursBackoffAfterQueuedFailure, 20 processes per cell killed: mutant 12/20 -race reports and 1/20 "2 loads inside the backoff window"; fixed 0/20 and 0/20 [K]
B, unforced — same two shapes, a round forced to fail but no extra refresh injected, 40 processes per cell survived: 0/40 both ways. With the pause in place the #224 stray-refresh window is itself much harder to hit, so the t.Cleanup join is now defence in depth rather than something an unforced mutant can distinguish. Stated, not hidden. [T]

CI per job [K]

Run 37344078852, all green, nothing re-run:

Job Result
Go Build & Test success
Playwright E2E Tests success
Build & Publish Docker Image success
Release Artifacts skipped (fork guard)
Deploy Staging skipped
Publish Badges & Summary skipped

The known flaky Hash Stats sort test (#256) did not fire, so no job needed a retry.

Local checks [T]

  • cd cmd/server && go test -race -count=1 -timeout 40m ./... (whole package, once,
    unloaded): ok 486.069 s, 0 race reports.
  • go vet ./... clean. gofmt -l reach_rank_test.go clean.
  • cmd/server stays read-only — the change is one test function. No new
    map[string]interface{}. Fork guards unchanged: 9 in deploy.yml, 1 in
    release-fast-path.yml.

Remaining

The other two #234 review items are in index_ready_1008* and belong to a different
test, so they are deliberately not folded in here:

  1. TestIssue1008_PathHopIndexReadyFalseImmediatelyAfterLoad has the test: TestIssue1008_HandlerReturns503WhileSubpathIndexLoading races the background index build under load (-race flake) #227 race class
    — it asserts "not ready" right after Load() and the path-hop builder can win. It
    needs a gate like subpathBuildGate for the path-hop builder.
  2. store.subpathBuildGate = func() { close(building); <-release } closes building
    unguarded, so a second Load() on the same store would panic. It wants a
    sync.Once.

Worth one follow-up issue; not filed from here.

Also observed, not changed: the stale-while-revalidate path in getDegreeSnapshot
calls refreshDegreeSnapshot(ctx) and discards the channel, so every request
arriving during a refresh allocates a singleflight result channel that nobody reads
and that the finishing flight must still send to while holding the group mutex. The
test made that pathological (up to ~30k per round); at real request rates it is
negligible, which is why production is left alone here.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent3 PR#269 — head cfa4e0a

Dom: APPROVE with nits

Evidence tags: [T] = I ran it myself in this review · [A] = read/analysed from code or diff · [K] = taken from the author's report or CI, not re-run by me.

Independent, read-only review. The diff is test-only: one function plus one new constant in cmd/server/reach_rank_test.go (+19/−1). There is no production change.

Findings

# Severity Finding Evidence
1 nit The degreeHammerPause comment and the PR text say the delay "lands inside loadDegreeSnapshot". In my instrumented runs, the slow rounds under the no-pause hammer split their extra time between starting the flight (expire → onDegreeSnapshotLoad, up to 9.3 ms of a 17.0 ms round) and the load itself. With the 100 µs pause, the start delay drops to ≤ 0.8 ms. The cause is the same either way: the refresh goroutine is starved by the test's own spinning callers. "Delays the refresh (start and load)" would be more precise. No change required. [T]
2 info On this 4-core host, the 3 s symptom did not reproduce on master: 0/200 under load (1-min load up to 11.0). The before/after kill evidence therefore comes from a scaled-deadline mutant, the same method the author used. Master's run took 379 s vs 113–130 s for the branch, because the hot spin burns the CPU itself. [T]
3 info The pause does not weaken the test against the regression it was written for: the sticky in-flight flag from review round 2 (3b1a91a4). The kill rate is identical with and without the pause; see own mutant M2. [T]
4 info The PR is still a draft. [A]

No blocking findings.

Answers to the review points

1. Cause — named with evidence? Starvation or a leftover singleflight flight? Yes. It is starvation, and it comes from the test's own 16 spinning callers. I repeated the key measurement with an instrumented scratch copy: per-round total, expire→load-start, load duration, loads per round, and refreshDegreeSnapshot calls per round. It ran with -race, GOMAXPROCS=2, 1500 rounds per cell, interleaved, with two cmd/server -race suite loops alongside. [T]

hammer round p50 p99 max start-delay p99 / max load p99 / max calls/round p50 (max) calls/process
no pause (master) 1.8 ms 5.4 ms 17.0 ms 2.2 / 9.3 ms 2.9 / 8.3 ms 1005 (8945) ~312k
100 µs pause (PR) 1.7 ms 3.7 ms 5.1 ms 0.2 / 0.8 ms 3.0 / 4.8 ms 112 (288) ~29k
no pause, rounds isolated (awaitDegreeRefresh before every expire) 1.7 ms 5.3 ms 12.5 ms 2.3 / 8.1 ms 2.9 / 5.2 ms 900 (7241) ~300k
  • Leftover flight ruled out [T]: joining any leftover flight before each round (third row) leaves the tail essentially unchanged compared with master's hammer. Every one of the 4500 rounds had exactly one real load: 0 rounds with zero loads, 0 with more than one.
  • Backoff ruled out [A]: reachDegreeQueryTimeout is 15 s, longer than the 3 s round budget, so a slow load cannot fail and arm the 15 s reachDegreeRetryBackoff inside a round.
  • Hammer volume [T]: ~300k calls per process without the pause, against ~29k with it. This matches the author's order of magnitude and the "~30k per process, over 100 per round" in the new comment.

2. Fix — in the test or in production? Is the deadline raised? Does awaitDegreeRefresh run on the failure path? The fix is in the test only [A]. The 3 s deadline, the 300 rounds and the 16 callers are unchanged, so the issue's rule (raise the deadline only for genuine scheduling starvation) is not even invoked. awaitDegreeRefresh is now registered with t.Cleanup after newReachRankServer. Cleanups run LIFO, so it runs before resetReachState's cleanup and before the DB close registered in newReachRankDB, while the snapshot is still there [A]. My own mutant M3 shows deterministically that it now runs on the t.Fatalf path [T]. A t.Fatal inside the cleanup is legal.

3. Load test — the literal go test -race -count=200 -run TestReachRank_ExpiredSnapshotAlwaysRefreshes ./... in cmd/server of the merged tree, with two go test -race -count=1 ./... loops of cmd/server running alongside [T]:

run result wall max 1-min load
merged tree, run 1 ok, 0/200 116 s 4.5
merged tree, run 2 ok, 0/200 113 s 4.8
merged tree, run 3 ok, 0/200 130 s 7.5
master (baseline) ok, 0/200 (did not reproduce) 379 s 11.0

4. Mutant: reintroduce the cause, test goes red under load. See M1 below: killed.

Mutants (all own, [T])

Mutant Setup Result
M1 — cause reintroduced: time.Sleep(degreeHammerPause) removed (master's hammer); round budget scaled from 3 s down to 5 ms / 8 ms via env, otherwise the real test one process per run, interleaved with the fixed test at the same budget, -race, both load loops running killed: mutant 7/30, 1/30 (5 ms, GOMAXPROCS=4) and 2/30 (8 ms, GOMAXPROCS=2) = 10/90 red; fixed 0/90
M2 — production regression the test exists for: the round-2 sticky in-flight flag reintroduced in getDegreeSnapshot (CAS before refreshDegreeSnapshot, cleared in the flight's defer), with an optional sleep after the clear to widen the race window; real 3 s budget 20 processes per cell; the PR's hammer vs master's hammer window 200 µs: 20/20 vs 20/20; window 20 µs: 20/20 vs 20/20; window 0 (the original bug): 2/20 vs 2/20. The pause costs the liveness probe no sensitivity
M3 — t.Cleanup join reverted to the tail call: the round assertion is forced to fail at round 3 while a refresh with a 300 ms load is in flight; a probe registered first (so it runs last) records whether that refresh finished before the test ended 5 runs each PR shape: refresh finished before test end 5/5; tail-call shape: 0/5 (stray refresh outlives the test). Killed deterministically

Acceptance criteria of the issue vs red/green:

  • "-count=200 passes under parallel CPU load" — green 3/3 on the merged tree [T]. Red-before is shown via scaled-deadline M1 (10/90 vs 0/90) [T]. The literal 3 s failure on master was reproduced by the author (2/600 at load ~40) [K], not by me (finding 2).
  • "Cause named; deadline raised only for genuine starvation" — cause named and measured, deadline not raised [T][A].
  • The test(server): deflake reach_rank hook race and the #1008 not-ready window (#224, #227) #234 review item 1 taken here (t.Cleanup): red-before/green-after via M3 [T].

Tests on the merged tree

Merged tree git merge-tree --write-tree origin/master cfa4e0a5… = aad640ba against origin/master 6087a793. [T]

  • cmd/server: go test -count=1 ./... → ok (373 s); go vet ./... clean; gofmt -l reach_rank_test.go clean.
  • sh test-all.sh → 219 passed, 0 failed (219 files).
  • node test-frontend-helpers.js → 707 passed, 0 failed.
  • E2E against a local Go server with e2e-fixture.db prepared as in CI (freshen, inline seed SQL, corescope-migrate, seeds 2073, 199 and 245). The PR has no E2E of its own; I ran the reach-area E2Es: test-reach-rank-e2e.js 14/0, test-node-reach-e2e.js OK, test-issue-1630-reach-mobile-e2e.js 7/0. test-node-reach-coverage-e2e.js skipped itself (client RX coverage disabled in the fixture config). The server was stopped by its port's pid.
  • origin/master moved to 2e8deaed (feat(ingestor): accept client RX coverage only from configured sources (#265) #274, ingestor/docs only, no cmd/server change) during the review. It still merges cleanly (1ee2f7fb). On that newer merged tree: cmd/ingestor go test -count=1 -timeout 20m ./... → ok (1004 s; a first run at Go's default 10 min timeout timed out only because the suite takes longer than that, and CI uses -timeout 20m), and go test -race -count=20 -run TestReachRank in cmd/server → ok.

Invariants

Not verified

  • The literal 3 s round failure on master: not reproduced on this 4-core host (0/200 at load ≤ 11). The author's 2/600 at load ~40 is [K].
  • The author's goroutine dump (refresh parked in the sqlite driver, hammers on the singleflight mutex) and the 276 ms / 272 ms round: [K]. My own breakdown agrees on the cause, but with smaller absolute numbers and part of the delay before the load (finding 1).
  • Load was only the two -race suite loops plus the test itself. Extra CPU burners were not used, so my load (max 1-min ~11 on 4 cores) is lighter than the author's (~40–330).
  • The author's mutant B with -race reports in TestReachRank_RefreshHonoursBackoffAfterQueuedFailure: [K]. My M3 checks the same property directly instead.
  • The full Playwright suite and a -race run of the whole cmd/server package: not run by me (the non-race package run and the targeted -race runs above were).

@dborup
dborup marked this pull request as ready for review October 6, 2026 01:06
@dborup
dborup merged commit b837298 into master Oct 6, 2026
6 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.

3 participants