Files
go/docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md
T
end e5298cff37
Documentation / docs (push) Successful in 25s
ci / check (push) Successful in 1m43s
feat(lifecycle): supervise services and bounded shutdown (#13)
Add ordered setup, reverse rollback and teardown, concurrent services,
and managed background workers using only the standard library.

Coordinate context cancellation, injectable signals and joined errors within
one configurable shutdown deadline. Include race tests, a two-service example,
and the accepted lifecycle ADR.

Closes #5
2026-10-27 15:01:50 -07:00

10 KiB
Raw Blame History

title, date, status, implementation, summary, tags, supersedes, superseded_by, related, tracking, implemented_by
title date status implementation summary tags supersedes superseded_by related tracking implemented_by
Supervise module lifecycle and managed workers within one shutdown budget 2026-10-27 accepted implemented Order module startup and reverse cleanup while supervising services and background workers under one bounded shutdown.
lifecycle
/docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
#5
#13

Supervise module lifecycle and managed workers within one shutdown budget

Context and Problem Statement

Issue 5 needs ordered startup and supervised concurrent services for Lark Relay's daemons and muxer. Its follow-up also requires explicit coordination of background workers: canceling service contexts alone cannot establish that the entire app has stopped.

The owner approved a standard-library-only root package, registration-order setup, reverse rollback/teardown, injected signals and logging, joined errors, and one configurable shutdown budget (default 30 seconds) covering teardown and worker completion. Cancellation precedes teardown, which may unblock workers; a second signal abandons shutdown. The owner accepted the API and ownership details after independent review, including the R1/R2/R3 clarifications. It is a clean-room design; no go-toolkit/app source is used.

Considered Options

  • Keep coordination in each application's main function: least library code, but duplicates signal, rollback and worker ownership rules across the initial consumers.
  • Provide only ordered hooks and service supervision: smaller package, but callers still need a second worker coordinator to satisfy the follow-up and share its shutdown deadline.
  • Provide ordered hooks plus a managed-worker launch method: one owner for cancellation, registration closure, errors and the shutdown budget, without a dependency graph or framework.

Decision Outcome

Choose the third option in go.auvem.com/corestack/lifecycle, sharing root-module versions under the accepted module-boundary decision. No CLI integration, dependency DAG, external dependency, restart policy or UI is included.

The public surface is:

type Module struct {
    Name     string
    Setup    func(context.Context) error
    Teardown func(context.Context) error
    Run      func(context.Context) error // optional
}
type Config struct {
    Logger          *slog.Logger
    Signals         <-chan os.Signal
    ShutdownTimeout time.Duration
}
type App struct { /* private state; no stored context */ }
func New(config Config) (*App, error)
func (a *App) Register(module Module) error
func (a *App) Go(ctx context.Context, name string, fn func(context.Context) error) error
func (a *App) Run(ctx context.Context) error

The accepted signatures have the following edge semantics:

  • New requires a non-nil logger; zero timeout selects 30 seconds and negative durations fail. Register requires a unique nonempty module name and non-nil setup/teardown. Registration closes when Run begins; an App runs once. Invalid or late calls return errors without launching work. Configuration is captured by New and modules by Register, with no global state or initialization side effects.
  • Run sets up modules sequentially in registration order using a shared child of its parent context. Only after every setup succeeds does it launch the optional service callbacks concurrently. Dependencies are expressed by registration order and ordinary Go wiring, not lookup or dependency injection APIs. Service scheduling order is unspecified.
  • Go is the explicit tooling for background work. A setup or service callback can capture the App and call Go; the supplied context must be that callback's lifecycle context or a descendant. Context ancestry is a caller precondition that App cannot generally enforce; detached contexts forfeit graceful cancellation. It launches fn(ctx) immediately, tracks completion and errors, and returns once admitted; admission is atomic with shutdown closure. It is available only during setup/running, accepts a nonempty diagnostic name and non-nil function, and is safe for concurrent callers. Worker names need not be unique. Callers must handle admission errors. Raw goroutines remain the caller's responsibility and cannot be included in the completion guarantee.
  • Once startup completes, normal completion means every service and admitted worker has returned nil; no services/workers means immediate normal shutdown. A nil return from just one service/worker does not stop the others. Normal completion atomically closes worker admission so a racing launch is either tracked or rejected. Before startup completes, temporarily having no workers does not end the run.
  • The first setup/service/worker failure, parent cancellation, or received SIGINT/SIGTERM starts shutdown and closes worker admission. Cancel the shared context first, then tear down successfully completed setups in reverse order while services/workers unwind concurrently. Do not wait for workers before teardown: closing a listener or queue may be what releases them. A failing setup owns cleanup of its partial acquisition and is not torn down by App.
  • One fresh shutdown context retains parent values but is detached from parent cancellation and has the configured deadline, measured from the shutdown trigger. Reverse teardown hooks run sequentially with that context; all admitted services/workers share the same remaining completion budget. An in-flight setup is canceled and awaited only within this budget before reverse rollback, avoiding concurrent setup and teardown. Its successful return before abandonment adds it to rollback; failure does not. No further setup starts after shutdown begins.
  • The deadline or a second signal cancels the shutdown context and returns without starting remaining teardown hooks or waiting further for callbacks. App must observe setup/teardown results asynchronously so an uncooperative hook cannot block its return. App will not later call teardown for an abandoned setup, even if it later succeeds; its acquisitions may leak until caller-owned process exit. A cancellation-aware setup must clean up partial acquisitions and return an error when it observes cancellation. Completion reporting must not block after App returns. Go cannot forcibly kill uncooperative goroutines: the bound covers App's coordination and return, not guaranteed resource release or goroutine termination. Callbacks must honor cancellation, and the caller owns process exit and must not reuse abandoned resources. Injected logging must not block shutdown.
  • The caller owns OS signal subscription, filtering to SIGINT/SIGTERM, buffering (at least two), and subscription cleanup. App only receives from the injected channel, never closes it or calls signal.Notify, signal.Stop, or os.Exit. Nil disables signals and a closed channel is disabled. If shutdown began for another reason, the first subsequently received signal counts as the first signal; the next abandons shutdown. Signal counting spans startup and shutdown.
  • Wrap callback failures with module/worker name and phase and combine observed errors using errors.Join; retain every completed teardown error, including cancellation errors. A setup/service/worker error solely wrapping its callback context's cancellation/deadline caused by an already-established shutdown is expected and omitted; independent failures and non-cancellation components of joined errors remain errors. An instance-private cancellation cause proves App-initiated cancellation; retain ambiguous parent-propagated cancellation conservatively, including independently canceled descendants. The runtime context remains a direct child of its parent and preserves its deadline. The initiating setup failure remains an error. Parent cancellation returns its context error; graceful signal/normal completion returns nil if cleanup succeeds. Deadline abandonment adds context.DeadlineExceeded; signal abandonment adds an error wrapping context.Canceled with the signal as context. Retain errors already observed on abandonment, with no promise to collect later results or deterministic concurrent-error order. Do not recover panics.

Consequences

  • One coordinator owns admission, cancellation, hook ordering and completion accounting. Applications must route background work through Go and supply cooperative callbacks to claim graceful whole-app completion; this is an explicit boundary, not automatic goroutine discovery.
  • The shutdown bound requires asynchronous hook invocation and synchronized worker admission/completion, even when a consumer has no background workers. A blocked hook can consume the entire budget and leave earlier modules without teardown; a second signal deliberately accepts that same incomplete cleanup sooner.
  • Delivery should remain approximately 300–500 production lines plus focused race tests, a two-service package Example and public/package docs, in one PR. Verify startup/reverse ordering, partial rollback, service and worker failure propagation, normal completion, cancellation/error classification (including a first signal during setup with successful partial cleanup and rollback returning nil, and an initiating setup failure remaining observable), admission races, teardown unblocking workers, a shared deadline (including an uncooperative hook), second signals and joined errors. Test with injected signals, release blocked test goroutines explicitly, and use no real process signals.
  • No plan tier is needed. The accepted decision is implemented by PR 13, including managed workers, bounded shutdown, focused race tests, the two-service example and public documentation. Independent standard and high implementation reviews are complete with no outstanding findings, and required local checks passed on the reviewed code. Publication is complete; CI and merge readiness are tracked in the PR. Delivery was reconciled against main at 46c4416ed44b4c8cfc69570b9bcb572b624d765e plus the PR; this records implementation evidence, not a merge or release.