test(lifecycle-poc): fix CI flake in TestServiceLifecycle_PipelineError/PipelineStop#2578
Merged
Merged
Conversation
… fix CI flake Fixes the CI flake noted on #2534 that failed the `test` job on PRs #2576 and #2577 (TestServiceLifecycle_PipelineError, TestServiceLifecycle_PipelineStop). The immediate trigger was a raw time.Sleep(100ms) used to let 10 records "finish flowing" before stopping the pipeline in TestServiceLifecycle_PipelineStop and its sibling TestServiceLifecycle_PipelineSuccess. Under CI load 100ms isn't always enough, so Stop/StopAll can race the data path, under-deliver records, and fail the source/destination mocks' record-count assertions in t.Cleanup. Both are replaced with waitForRecordsAcked, which polls the source connector's exported, lock-guarded State field (set by Source.Ack) until every record has actually been acked, instead of guessing a duration. Digging into *why* the flake also showed a bare status mismatch ("SystemStopped != Running") turned up four distinct, real concurrency bugs in pkg/lifecycle-poc, found and fixed via `-race -shuffle=on` repetition: 1. service.go WaitPipeline: a lookup-after-delete race could return a false nil instead of the pipeline's terminal error/status if the cleanup goroutine removed the pipeline from runningPipelines between the lookup and return. This is the identical bug already diagnosed and fixed in the sibling pkg/lifecycle package (see docs/design-documents/20260706-forceful-stop-test-determinism.md); ported the same terminalErrors-map fix here. 2. service.go runPipeline: the worker goroutine's tomb.Kill(err) for a fatal error only happened in tomb.v2's own post-return bookkeeping, which raced the cleanup goroutine waking from workersWg.Wait() and reading rp.t.Err() — losing the race made a fatal stop get reported as a clean graceful stop. Fixed by killing the tomb synchronously before returning. 3. service.go runPipeline (the actual root cause of the CI-observed "SystemStopped != Running"): the function's own initial UpdateStatus(StatusRunning) call and the cleanup goroutine's terminal UpdateStatus call are not safe to run concurrently on the same *pipeline.Instance (SetStatus is lock-guarded, but the Error field write and the store's JSON-encode of the whole struct for persistence are not). With mocks providing zero I/O latency, the cleanup goroutine could call its own UpdateStatus while the initial one was still mid-flight and win the race, clobbering a correct terminal status back to "running". Confirmed by a genuine `-race` data race report. Fixed by gating the cleanup goroutine's UpdateStatus on a channel closed only after the initial one fully completes (kept the two tomb.Go registrations adjacent to avoid a related tomb.Go-after-death panic that a naive reordering introduced). 4. funnel/worker.go Worker.Stop: tore down the source (nils the plugin) before setting w.stop, so a concurrent Read() racing the stop could observe plugin.ErrPluginNotRunning before doTask's graceful-stop check (w.stop.Load()) saw stop=true, misreporting a graceful stop as a real failure. Fixed by setting w.stop before tearing down. Also added the missing `defer persister.Wait()` to PipelineError/PipelineStop (PipelineSuccess already had it) — without it the persister's background flush goroutine can log via log.Test(t) after the test function returns, which panics and can corrupt whatever test happens to be running concurrently in the same process. Verification (pkg/lifecycle-poc/): - go test -run 'TestServiceLifecycle' -race -count=500: 0 failures - yes >/dev/null (x3) & go test -run 'TestServiceLifecycle' -count=300: 0 failures - go test ./... -race -shuffle=on -count=50: 0 failures - go build ./...: clean - golangci-lint run ./pkg/lifecycle-poc/...: 0 issues Self-review: re-read the diff for error paths, concurrency, and resource cleanup. Confirmed close(startupDone) is unconditional (both the success and UpdateStatus-error paths reach it), so the cleanup goroutine can never hang. Confirmed both tomb.Go registrations stay adjacent so tomb.alive can't reach 0 before the cleanup goroutine exists. Not fixed here (flagged for a follow-up, out of scope for a test-flake PR): pkg/lifecycle-poc's force-stop path kills the tomb with a bare pipeline.ErrForceStop instead of cerrors.FatalError(...) like pkg/lifecycle does, and the "recovery not implemented" branch in the cleanup switch is a complete no-op that leaves a dead pipeline's status stuck at "running" forever instead of reporting Degraded — both are real gaps but change lifecycle semantics beyond what a flaky-test fix should carry. Roadmap: Phase 0 stabilization — CI reliability for the arch-v2 (funnel) pipeline lifecycle package ahead of it replacing pkg/lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd
This was referenced Jul 8, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the CI flake noted on #2534 that failed the
testjob on PRs #2576 and#2577 (
TestServiceLifecycle_PipelineError,TestServiceLifecycle_PipelineStopin
pkg/lifecycle-poc/service_test.go).Risk tier: Tier 1 (pipeline lifecycle / status-reporting correctness in the
data-path-adjacent
pkg/lifecycle-pocpackage — the arch-v2 successor topkg/lifecycle). Human sign-off required before merge per the standing policy.Not merging this myself — opening for review as requested.
What was actually wrong
The starting hypothesis (a raw
time.Sleep(100ms)used as synchronization) wascorrect but incomplete. Digging into why the flake also produced a bare status
mismatch (
SystemStopped != Running) turned up four distinct, realconcurrency bugs in product code, each found and fixed via
-race -shuffle=onrepetition — not just a test-timing issue.1. Test-side: sleep-based sync (the originally-suspected cause)
TestServiceLifecycle_PipelineStop(and siblingPipelineSuccess) slept 100msto let 10 records "finish flowing" before calling
Stop/StopAll. Under CIload 100ms isn't always enough, so the stop call races the data path,
under-delivers records, and fails the source/destination mocks' record-count
assertions in
t.Cleanupwith an unrelated-looking error.Fix: replaced both sleeps with
waitForRecordsAcked, which polls thesource connector's exported, lock-guarded
Statefield (set bySource.Ack)until every record has actually been acked, instead of guessing a duration.
2.
WaitPipelinelookup-after-delete race (service.go)WaitPipelinelooked up the pipeline inrunningPipelinesand returned a barenilif not found — but the cleanup goroutine deletes the entry afterfinishing, so a caller racing that cleanup could get a false
nilinstead ofthe pipeline's terminal error. This is the identical bug already diagnosed
and fixed in the sibling
pkg/lifecyclepackage (seedocs/design-documents/20260706-forceful-stop-test-determinism.md, filed as#2521) — it just hadn't been ported to
pkg/lifecycle-poc. Ported the sameterminalErrors-map fix here.3. tomb.Kill ordering race on a fatal worker error (service.go)
The worker goroutine's error only got recorded on the tomb via tomb.v2's own
post-return bookkeeping (
t.run, afterf()returns) — which races thecleanup goroutine waking from
workersWg.Wait()and readingrp.t.Err().Losing that race made a fatal stop get reported as a clean graceful stop
(reproduced locally as
Degraded != UserStopped). Fixed by callingrp.t.Kill(err)synchronously before returning from the worker goroutine.4.
UpdateStatusconcurrency on the same*pipeline.Instance— the actual root cause of the CI-observed "SystemStopped != Running"runPipeline's own initialUpdateStatus(StatusRunning)call and the cleanupgoroutine's terminal
UpdateStatuscall are not safe to run concurrentlyon the same instance:
SetStatusis lock-guarded, but theErrorfield writeand the store's JSON-encode of the whole struct for persistence are not. With
mocks providing zero I/O latency, the cleanup goroutine could reach its own
UpdateStatuscall while the initial one was still mid-flight (both goroutineswere already registered via
tomb.Gobefore the initial call ran) and winthe race — clobbering a correct terminal status back to "running". This is
confirmed by an actual
-raceDATA RACE report (not just a logic bug), andit's the mechanism that best explains the exact CI symptom.
Fix: gate the cleanup goroutine's
UpdateStatuscall on a channel closed onlyafter the initial one fully completes. Both
tomb.Goregistrations stayadjacent (nothing slow between them) so
tomb.alivereaches 2 before eithergoroutine can finish — a naive first attempt that moved the initial
UpdateStatuscall between the twotomb.Gocalls introduced a differentbug (
tomb.Go called after all goroutines terminatedpanic) when the workerfinished before that call returned.
5.
Worker.Stopteardown-before-stop-flag race (funnel/worker.go)Stoptore down the source (which nils the plugin) before settingw.stop, so a concurrentRead()racing the stop could observeplugin.ErrPluginNotRunningbeforedoTask's graceful-stop check(
w.stop.Load()) sawstop=true, misreporting a graceful stop as a realfailure (reproduced as
"failed to read from source: plugin is not running").Fixed by setting
w.stopbefore tearing down.6. Missing
persister.Wait()(test hygiene)PipelineErrorandPipelineStopwere missing thedefer persister.Wait()that
PipelineSuccessalready had. Without it, the persister's backgroundflush goroutine can log via
log.Test(t)after the test function returns,which panics (
Log in goroutine after ... has completed) and can corruptwhatever test happens to be running concurrently in the same process — this
alone could plausibly explain some of the wider "unrelated" CI flakiness.
Confirmed by an actual panic + race report during repro.
Verification (exact commands, all
pkg/lifecycle-poc/)Each of the four product-side bugs was independently reproduced before its
fix (bare status mismatches, a real data race report, and a real panic — logs
available on request) and confirmed gone after. All three commands above were
also run a second time for confidence, still clean.
Failure-mode analysis (Tier 1 requirement)
pkg/lifecycle-poc,which is gated behind a feature flag and not yet the default engine
(
pkg/lifecycleis still primary). Blast radius is limited to that packageand its direct consumer in
pkg/conduit/runtime.go.(
StatusRunning/StatusDegraded/etc.) reported via the API/CLI would bewrong, or
WaitPipelinecallers (including graceful shutdown) would hang orreturn stale results. No production traffic depends on
pkg/lifecycle-pocyet, so live-system risk is effectively zero right now; the value is
fixing arch-v2 before it becomes the default.
changes.
Not fixed here (flagged, out of scope for a flaky-test PR)
pkg/lifecycle-poc's force-stop path kills the tomb with a barepipeline.ErrForceStopinstead ofcerrors.FatalError(...)likepkg/lifecycledoes — meaning a force-stopped pipeline in lifecycle-pochits the "recovery not implemented" no-op branch below instead of being
reported
Degraded.complete no-op (all commented out,
TODO) — a pipeline that stops with anon-fatal, non-recognized-as-graceful error is left at status
Runningforever, with no terminal status ever recorded. This is a real correctness
gap (an operator would see a dead pipeline reported as running) but fixing
it means either implementing recovery or deciding what the interim
behavior should be — a design decision beyond this PR's scope.
yesloaders +-shuffle=on -count=1500, well beyond the specified bar) I intermittentlysaw a mock-helper assertion (
pkg/plugin/connector/mock/source.go:116,done.Load()) fail — a background mock goroutine's last few non-blockinginstructions hadn't been scheduled by the time
t.Cleanupran. This didnot reproduce at the requested stress levels (all three verification
commands above, run twice, were clean) and touches shared mock
infrastructure used well beyond this package, so I left it alone rather
than risk a wider, rushed change.
Self-review
Re-read the diff hunting for error paths, concurrency, and resource cleanup:
close(startupDone)inrunPipelineis unconditional (both the success andUpdateStatus-error paths reach it), so the cleanup goroutine — alreadyblocked on
<-startupDone— can never hang waiting for it.tomb.Goregistrations for the worker and cleanup goroutines staytextually adjacent with nothing slow between them, so
tomb.alivecan'treach 0 (and panic on the next
Gocall) before the cleanup goroutine isregistered.
rp.t.Kill(err)called manually from inside the worker goroutine beforereturning is idempotent with tomb.v2's own subsequent
kill(err)call forthe same goroutine (verified against the vendored tomb.v2 source: a second
killcall on an already-non-ErrStillAlivereason is a no-op).Worker.Stop's reordering (w.stop.Store(true)beforetearDownSource)only tightens an existing signal; nothing else in the codepath depended on
the old order (checked all
w.stop.Load()call sites).🤖 Generated with Claude Code
https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd