feat(lifecycle,provisioning): StopAndWait + ApplyPlanLive (PR1 of #2588)#2597
Open
devarismeroxa wants to merge 2 commits into
Open
feat(lifecycle,provisioning): StopAndWait + ApplyPlanLive (PR1 of #2588)#2597devarismeroxa wants to merge 2 commits into
devarismeroxa wants to merge 2 commits into
Conversation
…e-server apply PR1 of the #2588 arc (engine only — no API RPCs, no CLI/MCP wiring; see docs/design-documents/20260708-live-server-deploy-apply.md, "Review outcome & required rework"). Adds the Tier-1 primitive that lets a future API/CLI surface apply a plan to a RUNNING pipeline instead of refusing it outright: - lifecycle.Service.StopAndWait(ctx, id): Stop(ctx,id,false) -> WaitPipeline (all node goroutines exited, i.e. fully drained) -> await the connector persister's durable flush (connector.Service.WaitPersisted -> connector.Persister.WaitPendingWrites, a new flushWg-only wait that doesn't couple to unrelated running pipelines the way Persister.Wait's connWg does). Only after all three is the pipeline safe to mutate. This closes blocker 1 from the design review: Stop(...,false) alone injects a stop-control-message and returns without waiting for drain or durability. - provisioning.Service.ApplyPlanLive(ctx, desired, hash): like ApplyPlan (recompute+verify hash, empty-diff no-op) but for a running pipeline calls StopAndWait -> transactionalImport (the #2595 transactional path, factored out of ApplyPlan so both share it) -> Start, instead of refusing. Any error after StopAndWait leaves the pipeline stopped; it is never auto-restarted into a half-applied state. The standalone ApplyPlan is unchanged and still refuses running pipelines. - lifecycle-poc (Preview.PipelineArchV2) gets a StopAndWait that always refuses with a coded error: its Stop/drain semantics haven't had the same audit, so ApplyPlanLive must never apply-to-running under it silently. StopAndWait is added to both LifecycleService interfaces (provisioning and runtime); pkg/provisioning/mock regenerated via go generate. Two gaps found during self-review and left explicitly flagged in code comments (not fixed here, not reachable until PR2 wires an API/CLI/MCP surface to ApplyPlanLive): no per-pipeline provisioning lock yet (concurrent apply/start race), and a pre-existing isRunningStatus/StatusDegraded dead end shared with ApplyPlan. Refs #2588. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd
The leave-stopped test proves AC-5 (partial-apply → pipeline left stopped) and the control-flow half of AC-6, not a real SIGKILL crash-recovery test. Per CLAUDE.md's process-maturity table the chaos suite is Phase-2, so the real kill-mid-apply test is deferred, not claimed. Crash safety here rests on the txn atomicity (#2595) + StopAndWait's durable checkpoint, both verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
Risk tier: Tier 1 (data path). Needs human sign-off — do not merge on this review alone.
PR1 of the #2588 arc (live-server apply to a running pipeline). Scope is the engine only: no API RPCs, no CLI/MCP wiring — that's PR2. Implements the corrected design from
docs/design-documents/20260708-live-server-deploy-apply.md, specifically the "Review outcome & required rework" section (the Tier-1 design review found the original §2 raced an in-flight drain).lifecycle.Service.StopAndWait(ctx, id) error—Stop(ctx,id,false)→WaitPipeline(id)(all node goroutines exited, i.e. genuinely drained — not just "stop signal injected") →connectors.WaitPersisted()(blocks until the connector position/state flush that draining triggered is durably committed). Only after all three does it return.provisioning.Service.ApplyPlanLive(ctx, desired, hash) (Diff, error)— likeApplyPlan(recompute+verify hash →plan_stale; empty diff → no-op) but for a running pipeline:StopAndWait→transactionalImport(the#2595transactional path, factored out soApplyPlanandApplyPlanLiveshare it) →Start. Any error afterStopAndWaitleaves the pipeline stopped — never auto-restarted into a half-applied state. For a stopped pipeline, it's identical toApplyPlan's tail. The standaloneApplyPlanis unchanged and still refuses running pipelines outright (it has no lifecycle to drive).pkg/lifecycle-poc(thePreview.PipelineArchV2/ funnel arch) gets aStopAndWaitthat always refuses with a coded error (lifecycle_v2.stop_and_wait_unsupported). Its Stop/drain semantics haven't had the auditpkg/lifecycle's got in the design review — silently building on an unaudited stop path would reintroduce the exact class of bug the review found.ApplyPlanLiveneeds no poc-specific branch: whateverStopAndWaitreturns is surfaced as-is.How StopAndWait awaits durability
connector.Persister.Wait()(existing) blocks onconnWg(every connector across the whole process reachingConnectorStopped) andflushWg. Using it from a single pipeline's stop-and-drain path would deadlock for as long as any other pipeline stays running, since the persister batches writes across all pipelines.New
Persister.WaitPendingWrites()blocks only onflushWg— the flush(es) already triggered. A connector's ownTeardown→ConnectorStopped→triggerFlushincrementsflushWgsynchronously before returning, and that happens strictly beforeSourceNode.Run()returns (Teardown is called fromRun's deferred cleanup, afteropenMsgTracker.Wait()— i.e. after every in-flight message has been acked end-to-end), which is strictly beforeWaitPipelineunblocks. So by the timeStopAndWaitcallsWaitPersisted, the flush for this pipeline's connectors has already been triggered, andflushWg.Wait()is guaranteed to observe it complete (sync.WaitGroupcan't miss anAddthat happened-before theWaitcall).connector.Service.WaitPersisted()exposes this via the connector service already held by the lifecycle service — no persister handle is passed to provisioning, per the design doc.Test results
Drain-no-loss (AC-3) —
TestServiceLifecycle_StopAndWait_DrainsAndPersists(pkg/lifecycle): runs a real pipeline (mocked plugins, real node goroutines, realconnector.Persister, realinmemory.DBwrapped in adelayingDBthat adds a 200ms delay to everyNewTransaction, so aStopAndWaitthat returned early would deterministically read stale/missing state, not just flake). AssertsStopAndWaitblocks for at least the flush delay and that the source's position, read directly back from the store, matches the last record. Verified this test actually catches the regression: temporarily removed theWaitPersisted()call fromStopAndWait— the test failed immediately (not flakily); restored — it passes.-race -count=20: clean, no flakes.Kill-mid-apply (AC-6) / restart-failure / rollback — proxied at the
provisioningcontrol-flow layer (real SIGKILL chaos infra isn't live yet perCLAUDE.md's Process maturity table — "Chaos suite nightly — by Phase 2"), since the actual durability guarantee is proven at thelifecyclelayer above and#2595's transaction already proves the store is all-or-nothing:TestApplyPlanLive_ImportFails_RunningPipeline_LeftStopped— import fails afterStopAndWaitsucceeds →Startis never called (no mock expectation set, so gomock fails the test if it is) → error surfaced.TestApplyPlanLive_RestartFails_LeftStoppedWithNewConfig— import commits,Startfails → error surfaced, pipeline left stopped with the new (valid) config, matching the design doc's "Restart failure" mode.TestApplyPlanLive_StopAndWaitFails_NoMutation—StopAndWaititself fails → no Create/Update/Delete/Start call happens at all (this is also the generic mechanism the lifecycle-poc guard relies on).Stale-hash / rollback / running-left-stopped —
TestApplyPlanLive_StaleHash_RefusedNoMutation_RunningPipeline(AC-4),TestApplyPlanLive_RunningPipeline_StopsAppliesRestarts(AC-3 control-flow: asserts call orderstop → import → start),TestApplyPlanLive_StoppedPipeline_NoLifecycleCalls/TestApplyPlanLive_Idempotent_NoOp(confirms parity withApplyPlanand that no lifecycle calls happen when there's nothing live to disrupt).Lifecycle-poc parity —
TestServiceLifecycle_StopAndWait_Unsupportedpins the refusal + its error code.Standalone
ApplyPlanstill refuses running pipelines — unchanged,TestApplyPlan_RunningPipeline_Refusedstill passes.-count=20 -raceon both thepkg/lifecycleStopAndWaittests and thepkg/provisioningApplyPlanLivetests: clean, no flakes. Full targeted suite (./pkg/lifecycle/... ./pkg/lifecycle-poc/... ./pkg/provisioning/... ./pkg/connector/...) green under-race. Full repogo test ./...green.golangci-lint run ./pkg/lifecycle/... ./pkg/lifecycle-poc/... ./pkg/provisioning/... ./pkg/connector/... ./pkg/conduit/...: 0 issues.go generate ./...: clean (only the expectedpkg/provisioning/mock/provisioning.godiff, +StopAndWait).Adversarial self-review findings
StopAndWaitactually achieve quiescence+durability, and is there a window whereimportPipelineruns against a not-fully-stopped pipeline? Traced the full happens-before chain:Stopinjects →SourceNode.Run'sopenMsgTracker.Wait()blocks until every in-flight message is acked end-to-end (pre-existing drain machinery) → deferredSource.Teardown()runs (still insideRun, before it returns) →Teardowncallspersister.ConnectorStopped()→triggerFlushsynchronously incrementsflushWgbeforeRun()can return → node'snodesWg.Done()→WaitPipeline'stomb.Wait()unblocks only after this →WaitPersisted/flushWg.Wait()is guaranteed to observe the already-incremented counter. No window found wheretransactionalImportcan run before the pipeline is both drained and its position writes are durable.ApplyPlanLive's doc comment from the function ingo doc. Fixed; verified withgo doc.StopAndWait(removed theWaitPersistedcall) and confirmed the test fails hard, then confirmed it passes again after restoring.Persister.WaitPendingWriteshas no timeout/ctx — inherited from the existingPersister.Wait()'s design (also no ctx, used unconditionally at process shutdown), not a new gap. A stuck store write for an unrelated pipeline could in principle block another pipeline'sStopAndWaitindefinitely, since the persister batches across all pipelines. Flagged, not fixed — would be scope creep for PR1 and is a pre-existing characteristic of the shared persister.ApplyPlanLivedoesn't take one. BetweenStopAndWaitreturning andtransactionalImport/Start, a concurrent caller could independentlyStartthe now-stopped pipeline, causing a config-drift (not data-loss — invariants 1-3 hold) condition. Not reachable in PR1 — no API/CLI/MCP surface callsApplyPlanLiveyet. Flagged in code comments and here; belongs in or before whichever PR (likely PR2) first exposes this to concurrent external callers.isRunningStatus/StatusDegradeddead end — aDegradedpipeline has already removed itself fromlifecycle.Service.runningPipelinesby the time its status is visible (seerunPipeline's cleanup goroutine), soisRunningStatuscorrectly reports it as "needs an explicit decision" butStopAndWait/Stopthen fails withErrPipelineNotRunningagainst it — a dead end. This is identical, pre-existing behavior inApplyPlan(not introduced here); flagged in a code comment for an explicit decision rather than left as silent archaeology.Test plan
go build ./...go test ./pkg/lifecycle/... ./pkg/lifecycle-poc/... ./pkg/provisioning/... ./pkg/connector/... -race -count=1— greengo test ./pkg/lifecycle/... -run TestServiceLifecycle_StopAndWait -race -count=20— green, no flakesgo test ./pkg/provisioning/... -run TestApplyPlanLive -race -count=20— green, no flakesgo test ./... -count=1(full repo) — greengolangci-lint runon all touched packages — 0 issues (36 pre-existing issues elsewhere in the repo, verified unrelated viagit stash -u)go generate ./...— clean, only the expected mock regenRoadmap
Phase 1/2 execution plan item: live-server deploy/apply (issue #2588), engine primitives half of the arc. PR2 will add the
PlanPipeline/ApplyPipelineAPI RPCs, CLI/MCP live-server-preferred client logic, and the enforced operator-authorization gate for data-path applies to running pipelines (design doc §3-4).🤖 Generated with Claude Code
https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd
Tier-1 review outcome (SOUND-WITH-CONCERNS) + follow-ups
An independent Tier-1 data-integrity review verified the core safety case in the code (not just the comments): every link of the StopAndWait happens-before chain checks out —
WaitPipelineunblocks only after all node goroutines incl. sourceTeardown;openMsgTracker.Wait()blocks teardown until every in-flight record is acked end-to-end; the positionPersisthappens-before the tracker decrement (reverse-order status handlers);ConnectorStopped→triggerFlush→flushWg.Add(1)runs synchronously beforeRunreturns; andWaitPersisted(flushWg) is the correct durability wait (the post-WaitPipeline call ordering eliminates the buffered-but-not-triggered window). No window found where a mutation races the drain (invariants 1/3 upheld).ApplyPlanLivefailure handling (leave-stopped, no auto-restart, transactional), the poc guard, and thedelayingDBdurability test (fails withoutWaitPersisted) all CONFIRMED.Concerns (documentation/process, addressed / tracked — not code):
ApplyPlanLivehas no external caller in PR1 (verified), so the concurrent-Start /!runningTOCTOU is unreachable now; it must land in PR2 (the API/CLI/MCP surface) before external exposure.isRunningStatus/StatusDegradeddead-end (fails closed, safe, pre-existing shared withApplyPlan).