fix(wfengine): worker-lifecycle hardening — e.stopped guard + generation/Stop docs (bookshelf-289dd) #1458
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-289dd"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes a pre-existing P3 latent race in
internal/wfengine:Engine.StartWorkeronlychecked
e.workerStarted, note.stopped, so a racingStartWorkercall could start aNEW worker generation after
Engine.Stop()had begun closingwfDB/diagBackend.StartWorkeris now a permanent no-op oncee.stoppedis set.Also documents (does not change behavior for) two pre-existing latent MINORs surfaced in
round-3 reviews of PR #1388 (bookshelf-81hq):
engine.go'sworkerGenandworker_set.go'sgenerationare two independent,uncoordinated counters at two layers. Rather than a risky unification for a P3, both
sites now cross-reference the invariant between them in doc comments.
Engine.Stop()'s doc now notes the accepted limitation that it does not guarantee anorphaned older-generation drain goroutine finishes before closing
wfDB/diagBackend— this is intentional (a cross-generation test asserts the orphaned generation keeps
running) and is now documented the way
DrainWorker's doc documents its own behavior.No workflow command-sequence changes — this is engine-internal worker lifecycle plumbing,
not a workflow definition, so no
gowf.Versiongate applies.Test plan
internal/wfengine/worker_lifecycle_test.go:StartWorkercalled afterStop()returns nil, does not invoke the underlyingstartWorkerclosure, and leavesworkerStartedfalse.make test— full suite green.make lint— 0 issues, all policy checks pass.make coverage—internal/wfengineat 100.0%;check-coverage: OK.Closes bead bookshelf-289dd on merge.
StartWorker now checks e.stopped in addition to e.workerStarted, so a racing StartWorker call that arrives after Engine.Stop has begun no longer starts a new worker generation against pools Stop is closing. Also documents two pre-existing, accepted latent MINORs from the #1388/bookshelf-81hq review rounds: - engine.go's workerGen and worker_set.go's generation are two independent, uncoordinated counters at two layers; doc comments on both now cross-reference the invariant (or lack thereof) between them instead of unifying them (lower-risk for a P3). - Engine.Stop's doc now notes it does not guarantee an orphaned older-generation drain goroutine finishes before wfDB/diagBackend are closed — an accepted limitation already covered by a cross-generation test, mirroring how DrainWorker's doc documents its own generation-scoped behavior. Closes bead bookshelf-289dd on merge.REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Reviewed the wfengine worker-lifecycle hardening diff (internal/wfengine/engine.go, internal/wfengine/worker_set.go, internal/wfengine/worker_lifecycle_test.go).
Summary: Clean. This is a genuine race-window fix, not new risk.
e.stopped ||to the existing guard while holding e.mu, returning immediately with no additional lock acquisition in that branch. Stop() sets e.stopped = true under the same e.mu before releasing it and beginning the drain. No new lock is taken inside worker_set.go from this guarded path, so there is no lock-order inversion and no new deadlock/hang surface.No findings.
Code Review: PR #1458 (bookshelf-289dd)
Reviewed the diff only (engine.go, worker_set.go, worker_lifecycle_test.go). No re-run of tests; CI is source of behavioral truth.
Phase 1 — Spec compliance: matches the bead exactly —
e.stoppedguard added toStartWorker(engine.go:701-706), doc notes added for the two independent generation counters (engine.go:145-158, worker_set.go:94-101) and Stop()'s orphan-drain limitation (engine.go:810-819). No extra scope.Phase 2 — Code quality:
Concurrency correctness (checked):
e.stoppedande.workerStarted/e.workerGenare read/written inside the samee.mu.Lock()/Unlock()critical section inStartWorker(engine.go:701-712), andStop()setse.stopped=trueunder the samee.mu(engine.go:822-826) before releasing the lock. No TOCTOU — a racingStartWorkereither observesstopped=trueand no-ops, or wins the lock first and completes its state transition beforeStopcan flipstopped. Correct.No workflow command-sequence change — confirmed via grep: no
ExecuteActivity/CreateSubWorkflowInstance/gowf.Select/gowf.Sleep/ContinueAsNewtouched in this diff. Purely engine-internal lifecycle guard; nogowf.Versiongate needed, correctly omitted.Guard semantics: post-Stop
StartWorkersilently returnsnil, consistent with the existingworkerStartedidempotency no-op one line above it (engine.go:702). All three production call sites (cmd/pergamum/worker.go:153,internal/app/app.go:1210,internal/app/app.go:1401) callStartWorkeronly at startup, beforeStopis ever invoked, so the silent no-op doesn't mask a real caller misuse today. Acceptable.Test quality (worker_lifecycle_test.go:257-299): the new
Context("when StartWorker is called after Stop")callsengine.Stop()inBeforeEach, thenStartWorkerinJustBeforeEach, and asserts viaatomic.Int32startCallsthat the injectedstartWorkerstub was never invoked, plusWorkerStarted()stays false. Verified this fails without the guard: removinge.stopped ||from theifwould letStartWorkercall the stub (startCalls→ 1,workerStarted→ true), failing 2 of the 3Its. The thirdIt("returns nil without error") is weak in isolation (the stub itself returns nil, so it can't distinguish guarded vs unguarded) but the other twoIts in the sameContextprovide real regression coverage — not a can't-fail test overall. One assertion perIt, matches package Ginkgo style.NewTestEnginepositional args used correctly.Docs: both new comment blocks (engine.go:145-158 on
workerGen, worker_set.go:94-101 ongeneration, and theStop()ACCEPTED LIMITATION block at engine.go:810-819) accurately describe the real invariant per the source read —workerGenincrements on everyStartWorkercall,workerSet.generationonly increments whenstart()findsdrained==true, and Stop's bounded drain doesn't force-join an orphaned older-generation drain goroutine. Matches the code and the bead's stated limitation. Not misleading.No blockers, no majors found.
[MINOR] internal/wfengine/worker_lifecycle_test.go:290 — the "returns nil without error"
Itis self-validating in isolation (stub always returns nil regardless of the guard) — harmless here since siblingIts in the sameContextcarry the real regression signal, but worth noting for future copy-paste.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Reviewed the wfengine worker-lifecycle hardening diff (internal/wfengine/engine.go, internal/wfengine/worker_set.go, internal/wfengine/worker_lifecycle_test.go).
Summary: Clean. This is a genuine race-window fix, not new risk.
e.stopped ||to the existing guard while holding e.mu, returning immediately with no additional lock acquisition in that branch. Stop() sets e.stopped = true under the same e.mu before releasing it and beginning the drain. No new lock is taken inside worker_set.go from this guarded path, so there is no lock-order inversion and no new deadlock/hang surface.No findings.