Repository navigation
test(ingestor): stop background work from outliving the test that started it - #71
Conversation
…rted it
Two independent leaks let a goroutine from one test keep running into
later, unrelated tests. Both were found while reviewing the PR queue; each
is proven by direct evidence from a full `go test -race ./...` run on
master, not inferred.
1. Async migrations racing the test's own seeding.
OpenStore starts RunAsyncMigration in the background so boot is not
blocked. In a test that is a race against the test's own setup:
backfillTxLastSeen only selects rows with last_seen = 0, so if it lands
after seedTxWithObs has inserted the transmission but before all of its
observations, it resolves the row from a partial set and marks it done.
The test's later synchronous call then skips the row.
That is exactly the intermittent
tx_last_seen_backfill_test.go:63: last_seen = 100, want 300 (MAX of 100,300,200)
reported against TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp
in the CI of several queue PRs. Measured on master: 10 failures in 40
runs (25%). With this change: 0 in 40.
The same goroutine also calls log.Printf, which races
TestHandleNeighborsReportInvalidTimestampLogsEvenWithoutScopeEvidence —
that test swaps the log sink for its own bytes.Buffer and then reads it.
Fix: the two store helpers wait on s.backfillWg before returning. That
is the synchronization point Close() already uses (db.go:1470) and the
one backfill_default_scope_test.go already waits on. Production is
untouched: main.go still never blocks on it.
2. StartStatsFileWriter leaked an unstoppable goroutine.
The writer loop was `for range t.C` with no way out, so a writer started
by a test ran for the rest of the binary. Evidence from the master run:
[stats-file] write /var/.../TestStatsFileWriter_...749730707/001/
ingestor-stats.json: ... no such file or directory
logged repeatedly during unrelated later tests, i.e. the writer was
still ticking into a t.TempDir() that had already been removed. It also
kept reading readProcSelfIOFn every tick, racing the t.Cleanup that
restores it.
Fix: StartStatsFileWriter returns a stop function that signals the loop
and blocks until the goroutine has returned. Stopping asynchronously
would not help — the stop has to be ordered before the hook restore, so
it waits. Both tests register it with t.Cleanup AFTER the restore, so
LIFO ordering runs stop first. main.go calls the function as a statement
and is unchanged; the writer still runs for the process lifetime there.
No assertion was changed, and no test was skipped or weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Korrektion: mine før/efter-tal i PR-beskrivelsen var målt forkertDen uafhængige reviewer fandt fejlen, og jeg har verificeret den. Baseline var ikke master. Jeg kørte "master"-siden af min A/B i hoved-checkoutet Konkret hvad det betød:
Hvad der står tilbage — målt mod rigtig master
|
| scenarie | master 16ffe105 |
denne branch |
|---|---|---|
fuld pakke med -race, ×3 |
0/3 bestået — 4 races, 4 testfejl | 3/3 bestået, 0 races |
TestHandleNeighborsReport* -race -count=24 |
4 races, 2 fejl | 0 races, 0 fejl |
lækkede StartStatsFileWriter-goroutiner efter stats-testene |
1 | 0 |
fuld pakke uden -race, ×3 |
3/3 bestået, 88 s | 3/3 bestået, 88–89 s |
Master-fejlene, ordret: kørsel 1 TestHandleNeighborsReportInvalidTimestampLogsEvenWithoutScopeEvidence + TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp; kørsel 2 TestBackfillTxLastSeen_…; kørsel 3 TestHandleNeighborsReportInvalid…. Branchen: PASS alle tre.
Den rigtige årsagsforklaring: det er del (B) — den lækkede stats-writer, der læser readProcSelfIOFn (stats_file.go:263) og kolliderer med t.Cleanup-gendannelsen (stats_file_timestamp_test.go:52) — der fælder TestBackfillTxLastSeen under -race. Go tilskriver racen den test, der tilfældigvis kører bagefter, og fejlen er testing.go:1712: race detected during execution of test, ikke last_seen = 100, want 300. Del (A) er det, der fjerner racen i TestHandleNeighborsReport*.
Nettoresultatet er uændret og er grunden til at merge: cmd/ingestor går fra aldrig at bestå en fuld -race-kørsel til at bestå den konsekvent. (CI kører i dag cmd/ingestor uden -race, deploy.yml:68, så det har ikke fældet CI direkte — men det betyder også, at ingen -race-dækning af ingestoren er brugbar før dette.)
Rettet i denne runde
main_test.go: kommentaren påstod at rette en assertion, den ikke retter. Omskrevet til det, den faktisk køber.stats_file_test.go/stats_file_timestamp_test.go:defer store.Close()kørte ført.Cleanup(stop)— test-defers kører før cleanups — så writeren kunne ticke mod et lukket Store. Ufarligt i dag, fordi tick-kroppen kun rører atomics og globale registre, men ét DB-baseret felt i snapshottet ville gøre det til use-after-close. Begge steder er Close nu ent.Cleanupregistreret før writerens stop, så LIFO giver stop → hook-gendannelse → Close.
Reviewerens øvrige fund, som jeg ikke retter her
stats_file_test.go:62(TestStatsFileWriter_PublishesProcIO) skippes på darwin (/proc/self/io), så den nye stop-sti er kun lokalt dækket af timestamp-testen. Den kører i CI på Linux.- To andre usporede goroutiner findes stadig i
cmd/ingestor:mqtt_watchdog.go:676(maybeForceReconnectspawner uden stop-sti, WaitGroup eller recover) ogmqtt_watchdog_1853_test.go:27(kasserer stop-funktionen franewAsyncEmit). Revieweren kunne ikke fremtvinge den farlige sammenfald i den første og markerer den som uverificeret. Uden for denne PR's scope — påstanden om at have rettet "problemet" er altså mildt overdrevet, og det siges her frem for at blive stående. - Et hang, revieweren konstruerede (
WaitForAsyncMigrationsblokeret af en åben*sql.Rowspå den enkelte forbindelse), reproducerer identisk på master og hører tilStore.Close()(db.go:1470). Pre-eksisterende; denne PR reducerer faktisk eksponeringen, fordi WaitGroup'en allerede er drænet inden testkroppen.
🤖 Generated with Claude Code
Independent review found no blocker but two real problems in what I wrote. 1. main_test.go's comment claimed the added backfillWg.Wait() fixes the intermittent `last_seen = 100, want 300` failure in TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp. It does not. That test already calls store.WaitForAsyncMigrations() itself on master, with a comment describing the same mechanism — so the migration is already drained at seeding time and the Wait here is redundant for it. What actually fells that test under -race is the leaked stats writer racing the hook restore, i.e. the other half of this change. The Wait still earns its place: it makes a settled database the default for every test using the helper rather than something each new test has to remember, and the same addition in openNeighborsStore is what removes the log-sink race in TestHandleNeighborsReport*. Comment rewritten to say that instead. 2. Both stats tests used `defer store.Close()`. Test defers run BEFORE t.Cleanup, so Close landed before the writer's stop and the writer could tick against a closed Store. Benign today only because the tick body touches atomics and package-level registries and no DB; one DB-backed field in the snapshot would make it use-after-close. Close is now a t.Cleanup registered before the writer's stop, so LIFO ordering runs stop -> hook restore -> Close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Egen bekræftende måling mod korrekt baselineJeg har gentaget A/B'en selv, denne gang med et rent
Fejlende test på master: Bemærk at min rate (1/3) og reviewerens (3/3, 4 races) ikke er ens. Det er forventeligt for en race og er grunden til, at det er antallet af kørsler uden fejl, der tæller, ikke en enkelt kørsel. Begge målinger viser det samme: master er ikke pålideligt grøn under 🤖 Generated with Claude Code |
Én rerun af Playwright-jobbet — dokumenteret som urelateret flakeCI-kørsel 35537523535 på Grundlag for at kalde den urelateret — ikke en antagelse, men filomfang og sammenligning:
Derfor én — og kun én — rerun af de fejlede jobs, jf. reglen om at urelaterede flakes må genkøres én gang efter dokumenteret sammenligning med master. Går den ikke grøn, diagnosticerer jeg den i stedet for at genkøre igen. Sidebemærkning: "Multibyte only"-toggle er i forvejen på min opfølgningsliste fra #41-arbejdet af en anden grund (gaten skjuler replayede pakker). Denne fejl er en anden — persistens hen over reload — men samme flade, så den er værd at kigge på separat. 🤖 Generated with Claude Code |
Separate minimal test-fix, split out of the PR-queue work the way #70 was. It fixes three failing/flaky ingestor tests that have been showing up in the CI of unrelated PRs (#27, #28, #39 name them in their own descriptions as "intermittent" or "fails on master").
Root cause: background work outliving the test that started it
Two independent leaks, both proven by direct evidence from a full
go test -race ./...run on master — not inferred.1.
OpenStorestartsRunAsyncMigrationin the backgroundThat is correct for production (boot must not block), but in a test it races the test's own seeding.
backfillTxLastSeenonly selects rows withlast_seen = 0.seedTxWithObsinserts the transmission first, then its observations one at a time. If the async migration lands in between, it resolves the row from a partial set and marks it done — and the test's later synchronousbackfillTxLastSeencall then skips the row entirely.That is exactly the intermittent failure:
The same goroutine also calls
log.Printf, which racesTestHandleNeighborsReportInvalidTimestampLogsEvenWithoutScopeEvidence— that test swaps the log sink for its ownbytes.Bufferand then reads it with.String():Fix: the two store helpers (
newTestStore,openNeighborsStore) wait ons.backfillWgbefore returning. That is the synchronization pointClose()already uses (db.go:1470) and the onebackfill_default_scope_test.go:30already waits on, with the comment "same synchronization point Close() uses". Production is untouched —main.gostill never blocks on it.2.
StartStatsFileWriterleaked an unstoppable goroutineThe writer loop was
for range t.Cwith no way out, so a writer started by a test ran for the rest of the test binary. From the master run:logged repeatedly during unrelated later tests — the writer was still ticking into a
t.TempDir()that had already been removed. It also kept reading the package-levelreadProcSelfIOFnhook every tick, racing thet.Cleanupthat restores it.Fix:
StartStatsFileWriterreturns a stop function that signals the loop and blocks until the goroutine has returned. Stopping asynchronously would not help: the stop has to be ordered before the hook restore, so it waits. Both tests register it witht.Cleanupafter the restore, so LIFO ordering runs stop first.main.gocalls the function as a statement and is unchanged — the writer still runs for the process lifetime there.Measured, before and after
Same machine, same conditions, run back to back.
TestBackfillTxLastSeenandTestPruneOldNeighborMetricswithout-race(what CI runs forcmd/ingestor); the neighbors test with-race.16ffe105TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestampTestPruneOldNeighborMetricsTestHandleNeighborsReport…(-race)DATA RACEDATA RACEI was wrong about the second row and am correcting it here. I expected
TestPruneOldNeighborMetricsto be an unrelated, environment-specific failure — several PR descriptions in this queue, including #39's, document it as a baseline failure that "already fails on master", and upstream #33 exists to make it deterministic. It is not unrelated: it usesopenNeighborsStore, so the same async migration was perturbing it, and waiting onbackfillWgfixes it outright.Scope and what this deliberately does not do
StartStatsFileWriter. It is required — the goroutine is otherwise unstoppable by construction — and it does not alter runtime behaviour, becausemain.goignores the returned value and the writer still runs for the process lifetime.gofmtflagsmain_test.go,stats_file_test.goand ~45 other files in this repo under Go 1.26's comment-reformatting rule. That is pre-existing on unmodified master and is left alone.Note for context: CI runs
cmd/serverwith-racebutcmd/ingestorwithout it (deploy.yml:59vs:68), so the data races above never failed CI directly. What failed CI was the assertion failure in row 1.🤖 Generated with Claude Code