Skip to content

fix(serve): stop when told to, at every stage of the lifecycle - #1389

Draft
aparajon wants to merge 2 commits into
mainfrom
armand/bounded-shutdown
Draft

aparajon wants to merge 2 commits into
mainfrom
armand/bounded-shutdown

Conversation

@aparajon

Copy link
Copy Markdown
Collaborator

A SchemaBot instance had three ways to ignore being told to stop, and they
stacked: the serve command ran the server on a context of its own, so the
CLI's signal handler cancelled a context nobody was watching; the standalone
path installed its own handler only once the server was already built, leaving
the whole of startup uncovered; and the close path waited on background
goroutines with no bound, so one goroutine that never returned kept the process
alive indefinitely. Because the CLI traps SIGTERM for every command, Go's
default terminate-on-signal was already out of the way — so an instance
signalled mid-bootstrap did not stop, did not log, and was eventually killed.

An instance is now signal-scoped from before it starts building, and every wait
on the way down carries a bound and says what it walked away from.

Establishes AV-11 (an instance that is told to stop, stops). Upholds
AV-3 and AV-9; upholds OW-3, which is what decides the shape of the
driver drain below — an abandoned drive's claim is deliberately left registered,
because releasing it would invite a peer onto a target this instance has not let
go of. Left stale, it is reclaimed on the same window that covers any driver
that disappears.

Startup

An instance is 40 seconds into its storage bootstrap, against a database that
accepts connections and never answers, when the platform sends SIGTERM.

Before                                            After

┌──────────────────────────┐                      ┌──────────────────────────┐
│ SIGTERM at t+40s,        │                      │ SIGTERM at t+40s,        │
│ inside the storage boot  │                      │ inside the storage boot  │
└─────────────┬────────────┘                      └─────────────┬────────────┘
              │ CLI cancels its context                         │ CLI cancels its context
              ▼                                                 ▼
┌──────────────────────────┐                      ┌──────────────────────────┐
│ serve.Run built the      │                      │ serve.Run built the      │
│ server on a context of   │                      │ server on that context,  │
│ its own                  │  ✗ nothing watches   │ watcher installed first  │  ✓ signal honored
└─────────────┬────────────┘                      └─────────────┬────────────┘
              │ boot continues                                  │ boot returns
              ▼                                                 ▼
┌──────────────────────────┐                      ┌──────────────────────────┐
│ boot keeps retrying the  │                      │ boot reports the         │
│ unresponsive database    │                      │ cancellation, not the    │
│ for its 8m budget        │                      │ 5m bootstrap timeout     │
└─────────────┬────────────┘                      └─────────────┬────────────┘
              │ grace period expires                            │ Build fails
              ▼                                                 ▼
┌──────────────────────────┐                      ┌──────────────────────────┐
│ SIGKILL                  │  ✗                   │ exit 1 at t+40s          │  ✓
│ no shutdown logs at all  │                      │ with the cause logged    │
└──────────────────────────┘                      └──────────────────────────┘

Two independent gaps closed here, either of which alone would have been enough:
the command now passes the CLI's signal-scoped context to serve.Run, and
serve.Run installs its own watcher before Build rather than after it. The
embedding seam (Build, RegisterGRPC, Start, Close) still installs no
signal handler at all — a library that traps signals out from under its host is
a worse defect than the one being fixed — so an embedder keeps getting
cancellation as its only stop signal, and that now works.

Shutdown

An operator rolls the deployment while one driver is inside an engine call that
does not return.

Before                                             After

┌───────────────────────────┐                      ┌───────────────────────────┐
│ stop pending drops        │                      │ stop pending drops        │
│ wait: unbounded           │                      │ bound: 5s                 │
└─────────────┬─────────────┘                      └─────────────┬─────────────┘
              │                                                  │
              ▼                                                  ▼
┌───────────────────────────┐                      ┌───────────────────────────┐
│ stop durable dispatch     │                      │ stop durable dispatch     │
│ wait: unbounded           │                      │ bound: 10s                │
└─────────────┬─────────────┘                      └─────────────┬─────────────┘
              │                                                  │
              ▼                                                  ▼
┌───────────────────────────┐                      ┌───────────────────────────┐
│ reconcile pass            │                      │ reconcile pass            │
│ not waited on at all      │  ✗ storage closes    │ bound: 5s + 2s grace      │
└─────────────┬─────────────┘                      └─────────────┬─────────────┘
              │                                                  │
              ▼                                                  ▼
┌───────────────────────────┐                      ┌───────────────────────────┐
│ drain in-process work     │                      │ drain in-process work     │
│ wait: 25s                 │                      │ bound: 25s                │
└─────────────┬─────────────┘                      └─────────────┬─────────────┘
              │                                                  │
              ▼                                                  ▼
┌───────────────────────────┐                      ┌───────────────────────────┐
│ stop operator             │                      │ stop operator             │
│ wait: unbounded,          │                      │ bound: 10s, then          │
│ never returns             │  ✗ stuck here        │ abandon and log           │  ✓ claim left stale
└─────────────┬─────────────┘                      └─────────────┬─────────────┘
              │                                                  │
              ▼                                                  ▼
┌───────────────────────────┐                      ┌───────────────────────────┐
│ telemetry, then storage   │                      │ telemetry, then storage   │
│ never reached             │  ✗                   │ closed                    │  ✓
└───────────────────────────┘                      └───────────────────────────┘

Each bound is stated where it is declared, together with what its stage gives up
by expiring. None of them gives up anything another instance cannot redo or
reclaim: a claimed webhook delivery is redelivered, a repair pass that did not
finish is rerun by the next instance to start, a monitor recomputes its whole
view on its next pass, and an apply whose driver never returned is reclaimed by
a peer's stranded reaper. Waiting longer buys none of that back — it only delays
the exit the recovery is waiting on.

The worst case is now the sum of the stage bounds, a little over a minute,
against a previous worst case that did not exist. The in-process webhook drain
is by far the largest term and is unchanged here; the stage constants are what
make lowering the total a question that can now be answered.

Also in here

  • A bootstrap that reaches the front of the advisory-lock queue with its
    deadline already consumed by the wait is a named, logged refusal rather than a
    pass that starts online DDL it cannot finish. It releases the lock to the next
    instance immediately, and the boot retry loop opens the next attempt on a
    fresh deadline.
  • The sites that branch on ctx.Err() during the bootstrap now separate a
    caller that cancelled from a deadline that expired. The two mean different
    things to whoever reads the log: one is an instance that was told to stop, the
    other is a database that was too slow.
  • pkg/drain holds the one bounded-wait helper the shutdown path uses, so no
    stage can reintroduce an unbounded Wait() by copying its neighbour.

No operator-facing surface changes: the PR comments, check summaries, and CLI
output are all untouched. What changes is the logs, which now name the cause of
every early exit.

This PR was prepared by Claude Code (Claude Opus 5).

aparajon and others added 2 commits September 11, 2026 14:26
…trap

api.EnsureSchema took no context and both dialect bootstrappers rooted
their deadline at context.Background(), so nothing outside the call could
end a storage bootstrap once it started. The boot retry loop honors
cancellation between attempts, but it cannot reach that select until
EnsureSchema returns, so a signalled instance kept converging storage for
up to the full EnsureSchemaTimeout of the attempt in flight — and where
the deployment allows a long termination grace period, it stayed in
Terminating that whole time, holding its scheduling slot against the
rollout replacing it.

EnsureSchema now takes a leading context and both bootstrappers derive
their deadline from it, so an attempt ends at whichever comes first: the
caller cancelling, or EnsureSchemaTimeout firing. The timeout remains an
upper bound on the attempt; nothing about what the bootstrap executes
changes.

The sites that read ctx.Err() now separate the two causes, which
previously could only be the deadline. A cancelled bootstrap is the
caller's own decision and reports as one at info level; only a spent
deadline still reports the timeout and points at a backend throttling the
online DDL, so a shutdown no longer sends an operator looking for a
throttled database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SchemaBot instance had three ways to ignore being told to stop. The serve
command ran the server on a context of its own, so the CLI's signal handler
cancelled a context nobody was watching; the standalone path installed its own
handler only once the server was built, leaving the whole of startup uncovered;
and the close path waited on background goroutines with no bound, so a driver
that never returned kept the process alive indefinitely.

An instance is now signal-scoped from before it starts building, so a signal
that arrives mid-bootstrap ends the boot instead of being spent waiting out a
storage budget measured in minutes, and the process exits non-zero rather than
lingering. On the way down, every wait carries a bound and says what it walked
away from: abandoned drives keep their claims so a peer's stranded reaper
reclaims them on the usual staleness window, claimed deliveries are redelivered,
and an unfinished repair pass is rerun by the next instance to start.

A bootstrap granted the advisory lock with its deadline already consumed by the
wait is now a named, logged refusal rather than a pass that starts online DDL it
cannot finish.

Establishes AV-11. Upholds AV-3, AV-9, and OW-3 — an abandoned drive's claim is
deliberately left registered, since releasing it would invite a peer onto a
target this instance has not let go of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 11, 2026 18:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical shutdown and API compatibility issues block approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR makes server startup cancellation-aware and adds bounded shutdown drains.

Changes:

  • Propagates signal-aware contexts through serve and schema bootstrap.
  • Adds bounded waits for monitors, webhooks, reconciliation, and drivers.
  • Adds cancellation, drain, lock-budget, and signal integration coverage.
File summaries
File Review summary
pkg/webhook/durable_dispatch.go Nit (1 vote): Timeout logs lack delivery or repository identity needed to triage abandoned claims.
pkg/webhook/durable_dispatch_drain_test.go No final findings.
pkg/serve/serve.go Critical (3 votes): GracefulStop is unbounded. Moderate (3 votes): Signal cause reporting is racy. Critical (1 vote): Database closure can still block indefinitely on abandoned queries.
pkg/serve/serve_shutdown_test.go No final findings.
pkg/drain/drain.go Critical (1 vote): The helper bounds only waiting, not work or resources, so storage closure can still hang.
pkg/drain/drain_test.go No final findings.
pkg/cmd/serve_signal_integration_test.go Moderate (2 votes): Binary compilation has no independent deadline.
pkg/cmd/commands/storage_integration_test.go No final findings.
pkg/cmd/commands/serve.go No final findings.
pkg/api/webhook_inbox_metrics.go No final findings.
pkg/api/shutdown.go Nit (1 vote): Missing focused coverage for bounded monitor shutdown.
pkg/api/resync_postgres_identity_integration_test.go No final findings.
pkg/api/remote_deployment_health.go No final findings.
pkg/api/pending_drops_cleaner.go Moderate (1 vote): May log that the cleaner stopped after abandoning it, while it can race storage closure.
pkg/api/operator.go Moderate (1 vote): Reaper goroutines can skip engine and claim cleanup. Moderate (2 votes): Abandoned drivers can race dependency closure and violate lease safety.
pkg/api/operator_stuck_applies.go No final findings.
pkg/api/operator_shutdown_drain_test.go No final findings.
pkg/api/mysql_shared_integration_test.go No final findings.
pkg/api/ensure_schema.go Critical (2 votes): The exported EnsureSchema signature is source-incompatible for external embedders.
pkg/api/ensure_schema_test.go No final findings.
pkg/api/ensure_schema_postgres.go No final findings.
pkg/api/ensure_schema_postgres_test.go No final findings.
pkg/api/ensure_schema_postgres_pooling_integration_test.go No final findings.
pkg/api/ensure_schema_postgres_integration_test.go No final findings.
pkg/api/ensure_schema_lock_budget_test.go No final findings.
pkg/api/ensure_schema_lock_budget_integration_test.go No final findings.
pkg/api/ensure_schema_integration_test.go No final findings.
pkg/api/canonicalize_postgres_identity_integration_test.go No final findings.
docs/invariants.md No final findings.
Review details

Suppressed comments (4)

pkg/api/operator.go:198

  • recoveryWg also contains the stranded and retryable-expiry reaper goroutines started in StartOperator, so this is not a driver-only drain. If a reaper is the goroutine that ignores cancellation while all drivers have returned, this branch skips haltEnginesForShutdown and drainHeldClaims; an in-process engine can remain running and healthy claims can be left stale, unnecessarily blocking peers. Separate maintenance draining from driver draining or still run the cleanup for completed drivers (OW-3).
	if !drain.Wait(&s.recoveryWg, driverDrainTimeout) {
		s.logAbandonedDrives()
		return

pkg/api/pending_drops_cleaner.go:101

  • drainMonitor can return after its timeout while the cleaner goroutine is still running, but the next line unconditionally logs pending drops cleaner stopped. During the failure mode this reports a false lifecycle state, and the still-running cleaner can race the storage close that follows. Return whether the wait completed and avoid the stopped message (or log abandonment) when it did not.
	s.drainMonitor(&s.pendingDropsWg, "pending_drops_cleaner")

pkg/api/shutdown.go:32

  • This timeout branch is now shared by four monitor stop methods, but the added tests only exercise the operator-specific drain; no test blocks a monitor past cancellation and verifies the bounded return and log. A regression that restores an unbounded monitor wait would therefore pass. Add a focused test through a monitor stop method for this branch.
	if drain.Wait(wg, monitorDrainTimeout) {
		return
	}
	s.logger.Warn("background monitor did not return within the shutdown drain; the close continues without it and its next pass runs in whichever process starts next",
		"monitor", monitor,
		"drain_timeout", monitorDrainTimeout)

pkg/webhook/durable_dispatch.go:126

  • On timeout this log has no delivery/event identity or repo/PR information, so an operator cannot identify which acknowledged inbox rows were left claimed or correlate their later redelivery. The driver timeout path enumerates abandoned apply identities; durable dispatch should snapshot and log the claimed delivery IDs before returning so AV-7 remains triageable.
	if !drain.Wait(&h.durableWebhookWg, durableWebhookDrainTimeout) {
		h.logger.Error("durable webhook deliveries did not return within the shutdown drain; their inbox rows stay claimed until the claim goes stale and the next process to run the pool redelivers them",
			"drain_timeout", durableWebhookDrainTimeout)
		return
  • Files reviewed: 29/29 changed files
  • Comments generated: 7
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/api/ensure_schema.go
// adding a dialect means adding a bootstrapper here, not threading
// dialect-conditionals through the MySQL flow.
func EnsureSchema(dsn string, logger *slog.Logger, opts ...EnsureSchemaOption) error {
func EnsureSchema(ctx context.Context, dsn string, logger *slog.Logger, opts ...EnsureSchemaOption) error {
Comment thread pkg/drain/drain.go
Comment on lines +22 to +27
func Wait(wg *sync.WaitGroup, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
Comment thread pkg/serve/serve.go
Comment on lines +263 to +267
if err := srv.RegisterGRPC(runCtx, grpcServer); err != nil {
return fmt.Errorf("register grpc tern service: %w", err)
}
var lc net.ListenConfig
listener, err := lc.Listen(ctx, "tcp", ":"+grpcPort)
listener, err := lc.Listen(runCtx, "tcp", ":"+grpcPort)
Comment thread pkg/serve/serve.go
Comment on lines +799 to +802
// The reconciliation pass runs on a context of its own, so nothing above has
// asked it to stop and nothing below would wait for it. Bound it here, while
// the storage it reads is still open.
s.reconcile.stop(s.logger)
Comment thread pkg/api/operator.go
Comment on lines +196 to +198
if !drain.Wait(&s.recoveryWg, driverDrainTimeout) {
s.logAbandonedDrives()
return
require.NoError(t, err)

binary := filepath.Join(t.TempDir(), "schemabot")
build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "./pkg/cmd")
Comment thread pkg/serve/serve.go
Comment on lines +196 to +204
runCtx, cancel := context.WithCancel(ctx)
signalled := make(chan os.Signal, 1)
go func() {
select {
case sig := <-sigCh:
signalled <- sig
cancel()
case <-runCtx.Done():
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants