feat(launcher): initial stable release v0.9.0
Application lifecycle manager enforcing a three-phase init/wire/start sequence with reverse-order graceful shutdown and per-component stop timeouts. What's included: - `Component` interface (OnInit / OnStart / OnStop) and `Hook` type for BeforeStart wiring functions - `Launcher` interface with Append, BeforeStart, Run (blocks on SIGINT/SIGTERM), and idempotent Shutdown(ctx) - `New(logger, opts...)` constructor with configurable ComponentStopTimeout (default 15 s); no global state Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
49
docs/adr/ADR-001-three-phase-lifecycle.md
Normal file
49
docs/adr/ADR-001-three-phase-lifecycle.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# ADR-001: Three-Phase Lifecycle
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
Application bootstrap has distinct concerns that must happen in a specific order:
|
||||
resource allocation (open database connections, bind ports), dependency wiring
|
||||
(connect components to each other after all are ready), and service activation
|
||||
(start accepting requests, launch background goroutines). Conflating these phases
|
||||
leads to ordering bugs: a component trying to use a dependency that has not been
|
||||
initialised yet, or a service starting to accept requests before the database
|
||||
connection pool is ready.
|
||||
|
||||
## Decision
|
||||
|
||||
The `Launcher` orchestrates four sequential phases, implemented in `Run()`:
|
||||
|
||||
1. **OnInit** — called for all registered components in registration order. Opens
|
||||
connections, allocates resources. No component is started yet.
|
||||
2. **BeforeStart** — all `Hook` functions registered via `BeforeStart(hooks ...Hook)`
|
||||
are called in registration order, after all `OnInit` calls have succeeded. This
|
||||
is the dependency injection wiring phase: all components are initialised and can
|
||||
be queried, but none are serving yet.
|
||||
3. **OnStart** — called for all components in registration order. Starts goroutines,
|
||||
begins serving. If any `OnStart` fails, `stopAll` is called immediately and `Run`
|
||||
returns an error.
|
||||
4. **Wait** — `Run` blocks on either an OS signal (`SIGINT`, `SIGTERM`) or a
|
||||
programmatic `Shutdown()` call.
|
||||
5. **OnStop (shutdown)** — `stopAll` is called, running `OnStop` for all components
|
||||
in reverse registration order.
|
||||
|
||||
Each phase gate is explicit: if any step returns an error, the lifecycle halts and
|
||||
the error is returned to the caller. The caller (typically `main`) decides whether
|
||||
to call `os.Exit(1)`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `OnInit` can safely assume nothing is running yet — safe to block on slow
|
||||
operations like initial DNS resolution or schema migration.
|
||||
- `BeforeStart` hooks see a fully initialised set of components, making it the
|
||||
correct place for wiring that requires cross-component knowledge.
|
||||
- `OnStart` can safely assume all dependencies are wired and all peers are
|
||||
initialised — it is safe to start serving or spawning goroutines.
|
||||
- The three-phase split eliminates an entire class of "component not ready" race
|
||||
conditions without requiring any synchronisation between components.
|
||||
- The `Component` interface requires all three methods to be implemented. Components
|
||||
with no meaningful action for a phase return `nil`.
|
||||
48
docs/adr/ADR-002-reverse-order-shutdown.md
Normal file
48
docs/adr/ADR-002-reverse-order-shutdown.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# ADR-002: Reverse-Order Shutdown
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
When stopping an application, components must be stopped in the reverse of the order
|
||||
they were started. A component that depends on another (e.g. an HTTP server that
|
||||
uses a database connection pool) must be stopped before the component it depends on.
|
||||
If the database pool were closed first, any in-flight request handled by the HTTP
|
||||
server would fail with a connection error rather than completing gracefully.
|
||||
|
||||
## Decision
|
||||
|
||||
`stopAll` iterates `l.components` from the last index to the first:
|
||||
|
||||
```go
|
||||
for i := len(l.components) - 1; i >= 0; i-- {
|
||||
// stop l.components[i]
|
||||
}
|
||||
```
|
||||
|
||||
Components are typically registered in dependency order (dependencies first, then
|
||||
dependents). Reverse-order shutdown therefore stops dependents before dependencies.
|
||||
|
||||
Each component's `OnStop` runs in its own goroutine. A `time.After` ticker enforces
|
||||
the per-component `ComponentStopTimeout` (default 15 seconds). If a component does
|
||||
not return within the timeout, the launcher logs an error and moves on to the next
|
||||
component — it does not block the remainder of the shutdown sequence.
|
||||
|
||||
`Shutdown(ctx context.Context) error` provides a caller-side wait: it closes
|
||||
`shutdownCh` (idempotent via `sync.Once`) and then blocks until `doneCh` is closed
|
||||
(when `Run` returns) or until `ctx` is done. The caller's context controls how long
|
||||
the caller is willing to wait for the entire shutdown; `ComponentStopTimeout`
|
||||
controls how long each individual component gets.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shutdown order mirrors startup order in reverse, which is correct for any DAG of
|
||||
component dependencies without requiring an explicit dependency graph.
|
||||
- Each component's timeout is independent — a single slow component does not block
|
||||
others from stopping.
|
||||
- `Shutdown` is idempotent: calling it multiple times from multiple goroutines (e.g.
|
||||
an OS signal handler and a health-check endpoint) is safe.
|
||||
- If `ComponentStopTimeout` is exceeded, the component is abandoned — resources may
|
||||
not be fully released. This is the correct trade-off for a graceful shutdown with
|
||||
a deadline; the alternative (waiting indefinitely) is worse.
|
||||
43
docs/adr/ADR-003-before-start-hooks.md
Normal file
43
docs/adr/ADR-003-before-start-hooks.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ADR-003: BeforeStart Hooks for Dependency Injection Wiring
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
After all components have been initialised (`OnInit`), some wiring cannot be
|
||||
expressed at construction time because it requires the initialised state of multiple
|
||||
components simultaneously. For example, an HTTP server may need to register routes
|
||||
that reference handler functions which themselves hold references to a database
|
||||
client — but the database client only becomes ready after `OnInit` runs, which is
|
||||
after all components are constructed.
|
||||
|
||||
One alternative is to wire everything in `main` before calling `Run`, but that
|
||||
requires `main` to know the internal structure of every component, defeating
|
||||
encapsulation. Another alternative is to wire in `OnStart`, but at that point other
|
||||
components may already be running and the window for setup errors is narrower.
|
||||
|
||||
## Decision
|
||||
|
||||
`Launcher.BeforeStart(hooks ...Hook)` registers functions of type `func() error`
|
||||
that are called after all `OnInit` calls succeed and before any `OnStart` call
|
||||
begins. Hooks are called in registration order. If any hook returns an error, `Run`
|
||||
returns that error immediately without proceeding to `OnStart`.
|
||||
|
||||
`Hook` is a plain function type with no parameters beyond what closures capture.
|
||||
This allows hooks to close over the components they wire, without `launcher` needing
|
||||
to know anything about those components beyond the `Component` interface.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Dependency injection wiring is expressed as closures registered with
|
||||
`BeforeStart`, keeping `main` as the composition root without exposing internals.
|
||||
- All `OnInit` guarantees (connections open, ports bound, resources allocated) are
|
||||
satisfied before any hook runs — hooks can safely call methods on initialised
|
||||
components.
|
||||
- Hook errors abort the lifecycle cleanly. No components have started yet, so no
|
||||
cleanup of running services is needed (though `OnStop` will still run for any
|
||||
components that successfully ran `OnInit`, consistent with ADR-001's behaviour
|
||||
when `OnStart` fails).
|
||||
- The pattern scales to any number of wiring steps without adding methods to the
|
||||
`Component` interface.
|
||||
Reference in New Issue
Block a user