fix(engine): report a drained schema change's terminal outcome instead of pending - #1114
Conversation
…d of pending Drain now retains a completed or failed schema change's outcome - final state, error message, and per-table identity - when it releases the tracked state, and Progress serves that retained outcome until Apply accepts new work. A drain that lands between a change reaching its terminal state and the owner's next progress poll no longer makes the finished change look like one that never started. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a Spirit-engine edge case where Drain() could erase the terminal outcome of a just-finished schema change, causing subsequent Progress() polls to incorrectly return “pending” and potentially wedge the driver’s polling loop.
Changes:
- Retain the terminal outcome (completed/failed) across
Drain()via an engine-leveldrainedOutcomesnapshot, and serve it fromProgress()when no schema change is currently tracked. - Clear any retained drained outcome when
Apply()accepts new work to prevent cross-run bleed-through. - Add unit + integration coverage for drained completed/failed outcomes and for “fresh progress” after a drained failure.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| pkg/engine/spirit/spirit.go | Adds drained-outcome retention logic to Drain()/Progress(), clears it in Apply(), and extracts direct-statement progress rendering into a helper. |
| pkg/engine/spirit/drain_outcome_test.go | Unit tests for drained completed/failed outcomes, idle drain behavior, and non-retention of stopped changes. |
| pkg/engine/spirit/drain_outcome_integration_test.go | Integration tests exercising drained terminal outcomes through real Apply()/Progress() flows and ensuring drained failures don’t bleed into subsequent applies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…come The runners are closed by the time a drained outcome is served, so the snapshot previously carried zeroed row counters — the sync that records the terminal state would overwrite a failure's stored copy position with zero, rendering it as a failure before row copy started. Cache the last live poll's per-table progress on the tracked change and serve it from the drained outcome, clearing only the live-pacing fields (ETA, throttle) and the stale mid-copy detail on completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e untouched A drain that finds a different schema change tracked after its wait must not release that state or retain its own change's outcome over it. The race window sits between the drained goroutine's exit and the release of the tracked state, so a drainRaceWindow test seam makes the interleaving deterministic. Also reword the branch's debug line: it is also taken when a concurrent drain already released the state, so there is not always a newer tracked change in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1114, 3a742ab. Verdict: 8 findings — 0 blocking, 2 non-blocking (an unproven safety line, an identity-less outcome that now outlives the drain), 6 suggestions. Non-blocking1. // Accepting new work releases the previous change's drained outcome, so
// one schema change's result never bleeds into the next one's progress.
e.drainedOutcome = nilI mutated the PR head by deleting that one line and ran the package under It is structural, not accidental: The sequence the line actually protects is one nothing covers: A completes → 2. The retained outcome carries no apply/task identity and now survives past the drain, so the stale-task conflict check can stamp a different apply's resting task terminal.
if result.Message == "No active schema change" {
if !state.IsInFlightTaskState(t.State) {A retained outcome displaces that message with Marked non-blocking, not blocking, because I could only partly confirm it: the hazard is pre-existing, and the PR widens rather than creates it. Pre-PR, General suggestions3. if len(req.FlatDDL()) == 0 {
return &engine.ApplyResult{
Accepted: true,The field comment says "Apply releases it when it accepts new work" (:86-88), but this accepted path returns before 4. A drained completed outcome forces if rm.state == engine.StateCompleted {
tp.Progress = 100
5. Only return nil, engine.NewPermanentError("no active schema change to cancel")While the change is still tracked, 6. func retainsDrainedOutcome(s engine.State) bool {
return s == engine.StateCompleted || s == engine.StateFailed
}The doc says "cancelled changes resolve through the cancel call itself, so neither is retained", but only 7. Both walk 8.
The one thing that could have broken, verifiedThe riskiest mechanism is the new lifetime of engine state: the release point for a terminal answer moved from I bounded it in four ways and it holds inside the package, but not entirely outside it. (1) The one ingress that survives all four is Verified correct
This review was generated by Claude Code (claude-opus-5). |
…utcome A stop or cancel that arrives after a completed change was drained now gets the same typed already-completed rejection it would have received racing a tracked completion, so the caller reconciles to the completed outcome instead of being told nothing ever ran. A drained completed table reconciles its estimated row total to the copied count the way a live poll does, and an accepted no-op apply releases the previous change's retained outcome like any other accepted work. The tracked-state install is one helper that publishes the new change and releases the previous outcome in a single critical section — covered directly now, alongside the rule that cancelled changes are never retained. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks — addressed at e0b6d7b, finding by finding:
Every fix was mutation-verified: reverting each one (dropping the clear in This reply was generated by Claude Code (Claude Fable 5). |
…ombstone # Conflicts: # pkg/engine/spirit/spirit.go
…t-dialect-classify * origin/main: fix(github): keep the PR progress comment updating between operation dispatch waves (#1104) fix(tern): classify materialized change DDL with the target dialect parser (#1187) fix(engine): resolve a cancel or stop that arrives before remote dispatch (#1184) fix: default connect and write timeouts on managed database connections (#1182) fix(storage): index the apply-operation claim ordering (#1180) fix(tern): generalize control resume state and complete cancels with no live engine work (#1179) fix(github): name each table's outcome in unsuccessful apply summaries (#1186) ci: peel tern and webhook into a dedicated integration shard (#1166) fix(engine): report a drained schema change's terminal outcome instead of pending (#1114) feat(serve): contain gRPC handler panics with recovery interceptors (#1164) feat(observability): tell operators when a log window hides older entries (#1185) fix(tern): settle sequential tasks when the engine loses in-flight work (#1113) fix(github): align PR comment severity glyphs with the shared vocabulary (#1135) fix(tern): release a database held by a stopped schema change (#1175) fix(plan): canonicalize drift DDL with the target's dialect parser (#1177) fix(e2e): stop injecting connection kills once the k8s pause is observed (#1178) # Conflicts: # pkg/webhook/templates/plan.go
…lassify' into kiran01bm/apply-comment-dialect * origin/kiran01bm/plan-comment-dialect-classify: fix(github): line-break non-MySQL DDL, schema labels for postgres fix(github): keep the PR progress comment updating between operation dispatch waves (#1104) fix(tern): classify materialized change DDL with the target dialect parser (#1187) fix(engine): resolve a cancel or stop that arrives before remote dispatch (#1184) fix: default connect and write timeouts on managed database connections (#1182) fix(storage): index the apply-operation claim ordering (#1180) fix(tern): generalize control resume state and complete cancels with no live engine work (#1179) fix(github): name each table's outcome in unsuccessful apply summaries (#1186) ci: peel tern and webhook into a dedicated integration shard (#1166) fix(engine): report a drained schema change's terminal outcome instead of pending (#1114) feat(serve): contain gRPC handler panics with recovery interceptors (#1164) feat(observability): tell operators when a log window hides older entries (#1185) fix(tern): settle sequential tasks when the engine loses in-flight work (#1113) fix(github): align PR comment severity glyphs with the shared vocabulary (#1135) fix(tern): release a database held by a stopped schema change (#1175) fix(plan): canonicalize drift DDL with the target's dialect parser (#1177) fix(e2e): stop injecting connection kills once the k8s pause is observed (#1178) # Conflicts: # pkg/webhook/templates/apply.go
Why this matters
The Spirit engine's
Drain()clears a finished schema change and forgets its outcome entirely, so a progress poll arriving right after the drain gets back "pending — no active schema change" even though the change just completed or failed. The driver last recorded the task as running, treats "pending" as no-news, and keeps polling: a silently wedged apply with a healthy lease and no errors anywhere — and the window is easy to hit on multi-operation deployments, where a sibling operation's drive drains every engine on the instance before dispatching its own task. This PR makes the drained outcome survive until the next poll reads it, so finishing work is never erased between completion and observation.What it does
Drain()retains the final outcome of the change it clears when that change finished as completed or failed. Stopped changes are not retained, because they resume from their checkpoint rather than reporting a terminal result, and cancelled changes resolve through the cancel call itself.Progress()serves the retained outcome instead of falling back to "pending", so a poll after the drain sees the truth.Stop()andCancel()answer from the retained outcome too: a control call arriving after the drain gets the same typed already-completed rejection it would have received racing a tracked completion, so the caller reconciles to the completed outcome instead of being told nothing ever ran.Apply()clears the retained outcome when it accepts new work, so one change's result never bleeds into the next change's progress.Drain()'s blocking behavior — wait for goroutines to exit and release connections — is unchanged.Two safety properties worth calling out:
Drain()that lost a race to a newerApply()leaves the newer change untouched, using the same guardCancel()already uses.How it moves us toward the northstar
Engine progress is a display feed; outcomes belong to durable state. Today the drained outcome's only carrier is engine memory, and this PR stops the engine's own cleanup from destroying it before the driver records it durably. It narrows the window the drive loops are moving to eliminate entirely: an outcome should be written down the moment it exists, not held in memory waiting for a poll.
Opened by Claude (Fable 5).