lifecycle

package
v0.26.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 3 Imported by: 0

README

lifecycle

Package lifecycle declares the SDK ordered start/stop port: the Start that brings a component up, the Stop that takes it down, and the Lifecycle that owns an ordered set of them and drives it in both directions — plus the ComponentValue / TransitionValue domain values, the two-valued Phase, and the typed registration sentinels (InvalidComponent / DuplicateComponent / LifecycleRunning / ComponentPanicked).

Both Start and Stop are function ports rather than interfaces, so neither can grow a method and break a downstream implementer (ADR 0039).

The order components are added in IS the dependency order, and shutdown is its exact reverse. There is no graph: a linear sequence already is a topological order. A Stop is called only for a component whose Start returned nil, so a teardown may assume its own construction succeeded.

The engine, the per-component shutdown budget and the opt-in signal / sd_notify wiring live in internal/service/app/lifecycle; facade: pkg/v1/app/lifecycle. ADR 0050. See CLAUDE.md.

The Lifecycle port is generated from design/app/lifecycle.yaml into design_gen.go (ADR 0163): it changes in the design, then kit gen.

Documentation

Overview

Package lifecycle — ranges 0.2.19.* (ADR 0050 core/app/lifecycle block) and 0.3.49.* (ADR 0050 service/app/lifecycle block, declared here since ADR 0160).

Package lifecycle — declares the sentinel *errs.Error outcomes of the domain: the registration refusals, and the engine's and the supervisor's verdicts on a run. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Package lifecycle declares the ordered start/stop port of the SDK: the Start that brings a component up, the Stop that takes it down, and the Lifecycle that owns an ordered set of them and drives it in both directions. A core sibling admitted by ADR 0050.

Both halves of a component are FUNCTION ports rather than interfaces — the shape internal/core/CLAUDE.md already admits for resilience.Operation and scheduler.Job. Each is a single behaviour, so a named func IS the contract and needs no adapter at the call site; it is also the narrowest thing pkg/v1 can publish. ADR 0039's lesson is that a published port cannot grow a method without breaking every downstream implementer at compile time, and a func type cannot grow one at all.

What the order means

The order components are added in IS the dependency order, and shutdown is its exact reverse. This package declares no graph, no edges and no DependsOn field: a linear sequence already IS a topological order, and the caller — who wrote the constructors — is the only party that knows it. The price is stated rather than hidden: the SDK cannot detect that the caller ordered them wrong, because it has nothing to check the order against.

What a component owns

A ComponentValue is a pairing of a name, a Start and a Stop. Stop is called ONLY for a component whose Start returned nil, so a Stop may assume its Start succeeded — which is what makes a double-close impossible on the failure path. A Start that fails owns whatever it acquired before failing, exactly as a Go constructor does.

The engine that drives the sequence, the shutdown budget, and the opt-in signal / sd_notify wiring live in internal/service/app/lifecycle; this package owns only the contract, the three domain values, and the typed sentinels a registration refuses with.

Package lifecycle — hosts ComponentValue, the registration a Lifecycle owns.

Package lifecycle — hosts Phase, the direction a transition moved in.

Index

Constants

View Source
const CodeComponentPanicked errs.Code = 0x00_02_13_04 // 0.2.19.4

CodeComponentPanicked identifies a component whose Start or Stop panicked; the Lifecycle recovered it and turned it into this component's failure.

View Source
const CodeDuplicateComponent errs.Code = 0x00_02_13_02 // 0.2.19.2

CodeDuplicateComponent identifies an Add refused because the component's name is already registered on this Lifecycle.

View Source
const CodeInvalidComponent errs.Code = 0x00_02_13_01 // 0.2.19.1

CodeInvalidComponent identifies an Add refused because the component could never run: an empty name, a nil Start, or a nil Stop.

View Source
const CodeLifecycleRunning errs.Code = 0x00_02_13_03 // 0.2.19.3

CodeLifecycleRunning identifies an Add or a second Start refused because the Lifecycle is already started.

View Source
const CodeReadinessFailed errs.Code = 0x00_03_31_05 // 0.3.49.5

CodeReadinessFailed identifies an opt-in sd_notify readiness or stopping datagram that could not be delivered.

View Source
const CodeRunPanicked errs.Code = 0x00_03_31_06 // 0.3.49.6

CodeRunPanicked identifies a supervised run that panicked; the supervisor recovered it, counted it as a failure, and restarts the run after its backoff.

View Source
const CodeStartFailed errs.Code = 0x00_03_31_01 // 0.3.49.1

CodeStartFailed identifies a Start aborted by one component's failure. The components already up have been stopped in reverse order by the time this is returned.

View Source
const CodeStopFailed errs.Code = 0x00_03_31_02 // 0.3.49.2

CodeStopFailed identifies a component whose Stop returned an error during an ordinary shutdown. The remaining components were still stopped.

View Source
const CodeStopTimeout errs.Code = 0x00_03_31_03 // 0.3.49.3

CodeStopTimeout identifies a component whose Stop had not returned when its budget expired. Its context was cancelled and the engine moved on; nothing was severed and the goroutine was not killed.

View Source
const CodeSupervisorGaveUp errs.Code = 0x00_03_31_09 // 0.3.49.9

CodeSupervisorGaveUp identifies a supervision that ended on its own because its function ended early more often than its GiveUpAfter bound allows.

View Source
const CodeSupervisorMisconfigured errs.Code = 0x00_03_31_07 // 0.3.49.7

CodeSupervisorMisconfigured identifies a supervisor refused at construction: no name, or no function to run.

View Source
const CodeSupervisorRunning errs.Code = 0x00_03_31_08 // 0.3.49.8

CodeSupervisorRunning identifies a Start refused because the supervisor is already supervising.

View Source
const CodeUnwindFailed errs.Code = 0x00_03_31_04 // 0.3.49.4

CodeUnwindFailed identifies a failure DURING the cleanup of a partial start. It travels alongside the start failure that triggered the unwind, so a broken teardown can never hide the reason startup aborted.

Variables

View Source
var (
	// InvalidComponent is returned by Add for a component that could never
	// run. The "missing" field names which half is absent.
	InvalidComponent = errs.Static(CodeInvalidComponent, "INVALID_COMPONENT",
		"The lifecycle component is not runnable and was refused",
		"core/app/lifecycle: component has an empty name, a nil Start or a nil Stop; the field names which",
		0,
		78)

	// DuplicateComponent is returned by Add when the name is already taken.
	// Names are how a transition and an error identify a component, so two
	// components sharing one would make every report ambiguous.
	DuplicateComponent = errs.Static(CodeDuplicateComponent, "DUPLICATE_COMPONENT",
		"A component is already registered under that name",
		"core/app/lifecycle: component names are unique per Lifecycle; the field carries the name",
		0,
		78)

	// LifecycleRunning is returned by Add, and by a second Start, while the
	// Lifecycle is started. The order is frozen for the duration of a run:
	// appending to it mid-flight would give the new component a start
	// position it never had, and no defensible place in the reverse order.
	LifecycleRunning = errs.Static(CodeLifecycleRunning, "LIFECYCLE_RUNNING",
		"The lifecycle is already started",
		"core/app/lifecycle: the component order is frozen once Start begins; add before Start, or after Stop returns",
		0,
		78)

	// ComponentPanicked is the failure of a component whose Start or Stop
	// panicked. The Lifecycle recovers it rather than letting it reach the
	// runtime, because a panic escaping a Start would skip the unwind
	// entirely and leak every component already up — the exact failure this
	// domain exists to make impossible. The recovered value travels as a
	// field; it is never the wrap origin, so a panic carrying an *errs.Error
	// cannot hijack this code.
	ComponentPanicked = errs.Static(CodeComponentPanicked, "COMPONENT_PANICKED",
		"The lifecycle component panicked and was recovered",
		"core/app/lifecycle: component panicked; the fields carry its name, the phase, and the panic value",
		0,
		0)

	// StartFailed is joined with the component's own error when a Start
	// aborts the sequence. It is a SEPARATE error in an errors.Join rather
	// than a wrapper around the component's error on purpose: errs.Wrap would
	// hit the origin-wins rule (CLAUDE.md rule 6) and inherit the component's
	// code, so the caller could no longer ask "did startup fail?" without
	// knowing every code a component might produce. Side by side, both
	// errs.HasCode(err, CodeStartFailed) and the caller's own errors.Is
	// answer.
	StartFailed = errs.Static(CodeStartFailed, "START_FAILED",
		"A component failed to start and the started components were stopped",
		"service/app/lifecycle: Start aborted; the fields name the component and how many were unwound",
		0,
		0)

	// StopFailed is joined with the component's own error when its Stop
	// returns one. The shutdown continues: a component that cannot close
	// cleanly must not prevent the ones before it in the order from trying.
	StopFailed = errs.Static(CodeStopFailed, "STOP_FAILED",
		"A component reported an error while stopping",
		"service/app/lifecycle: Stop returned an error; the field names the component and the shutdown continued",
		0,
		0)

	// StopTimeout is returned for a component whose Stop had not returned
	// when its budget expired. The engine cancels the context it handed that
	// Stop — an announcement — and stops WAITING; it does not kill the
	// goroutine, which Go cannot do, and it closes nothing on the component's
	// behalf, which is what severed sockets under live handlers before ADR
	// 0043. Every component after it in the reverse order still gets its own
	// full budget.
	StopTimeout = errs.Static(CodeStopTimeout, "STOP_TIMEOUT",
		"A component did not stop within its budget and was abandoned",
		"service/app/lifecycle: the per-component stop budget expired; the goroutine was abandoned, not killed",
		0,
		75)

	// UnwindFailed marks a failure that happened while cleaning up a partial
	// start. It is joined with — never substituted for — the start failure
	// that triggered the unwind: a teardown that also breaks is a second
	// defect, and reporting only it would hide the first.
	UnwindFailed = errs.Static(CodeUnwindFailed, "UNWIND_FAILED",
		"Cleaning up after a failed start did not complete cleanly",
		"service/app/lifecycle: one or more components failed to stop during the partial-start unwind",
		0,
		0)

	// ReadinessFailed is returned when the opt-in sd_notify datagram could
	// not be delivered. It is only ever reachable when the caller asked for
	// the notification: with NOTIFY_SOCKET unset every notifier call is a
	// documented no-op that returns nil, so an unsupervised binary never sees
	// this.
	ReadinessFailed = errs.Static(CodeReadinessFailed, "READINESS_FAILED",
		"The readiness notification could not be delivered",
		"service/app/lifecycle: sd_notify was requested and failed; the field names the state that was not delivered",
		0,
		0)

	// RunPanicked is the failure of a supervised run that panicked. The
	// supervisor recovers it on the run's goroutine, because a panic escaping
	// there would take the process down with every other goroutine in it;
	// the recovered value and the stack travel as FIELDS, never as the
	// origin, so a panic carrying an *errs.Error cannot hijack this code —
	// and never in the Public text, which a status page may show.
	RunPanicked = errs.Static(CodeRunPanicked, "RUN_PANICKED",
		"The supervised function panicked and was recovered",
		"service/app/lifecycle: a supervised run panicked; the fields carry the supervisor's name, the panic value and the stack, and the run is restarted after its backoff",
		0,
		0)

	// SupervisorMisconfigured refuses a supervisor that could never run.
	SupervisorMisconfigured = errs.Static(CodeSupervisorMisconfigured, "SUPERVISOR_MISCONFIGURED",
		"The supervisor cannot run as configured",
		"service/app/lifecycle: NewSupervisor needs a Name and a Run; the field names which is missing",
		0,
		78)

	// SupervisorRunning refuses a second Start while the first supervision
	// is still running: two loops over one function would be two owners of
	// whatever it drives.
	SupervisorRunning = errs.Static(CodeSupervisorRunning, "SUPERVISOR_RUNNING",
		"The supervisor is already running",
		"service/app/lifecycle: Start was called while a supervision is running; Stop it first",
		0,
		78)

	// SupervisorGaveUp reports a supervision that gave up: its function ended
	// early more than GiveUpAfter.Restarts times within GiveUpAfter.Within, so
	// it was not restarted again — Erlang's restart intensity, after which a
	// supervisor stops and lets the level above it decide. Supervisor.Check
	// returns it from then on, so a liveness check sees it; the last run's
	// error travels as a field.
	SupervisorGaveUp = errs.Static(CodeSupervisorGaveUp, "SUPERVISOR_GAVE_UP",
		"The supervised function kept failing and the supervisor gave up",
		"service/app/lifecycle: the supervised function ended early more often than GiveUpAfter allows; the fields name the supervisor, the bound and the last run's error",
		0,
		0)
)

Functions

This section is empty.

Types

type ComponentValue

type ComponentValue struct {
	// Name identifies the component in every [TransitionValue] and in every
	// error field. It must be non-empty and unique within one Lifecycle.
	Name string
	// Start brings the component up. It is called in registration order.
	Start Start
	// Stop takes the component down. It is called ONLY if Start returned nil,
	// and in reverse of the order the components started in.
	Stop Stop
}

ComponentValue is one named unit of the application's startup order: what brings it up, and what takes it down.

The zero value is not runnable and is refused by Add (InvalidComponent) rather than accepted and silently skipped. Both halves are required: a component with nothing to stop is still asked to declare that explicitly, because "I forgot the teardown" and "there is no teardown" are the same nil, and only one of them is a defect. `func(context.Context) error { return nil }` is two seconds of typing and it puts the claim in the diff.

type Lifecycle

type Lifecycle interface {
	// Add registers a component at the end of the order. It is refused while
	// the Lifecycle is started ([LifecycleRunning]), on a name already
	// registered ([DuplicateComponent]), and on a component that could never
	// run — empty name, nil Start, nil Stop ([InvalidComponent]).
	Add(component ComponentValue) error
	// Start runs every registered Start in registration order and returns nil
	// once all of them have. On the first failure it stops the components
	// already up, in reverse order, and reports the failure; the Lifecycle is
	// then back in its not-started state, so a caller may fix the cause, Add,
	// and Start again.
	Start(ctx context.Context) error
	// Stop runs the Stop of every component that started, in reverse of the
	// order they started in, and reports what failed. It is idempotent: a
	// Lifecycle that never started, or that has already stopped, returns nil
	// without calling anything.
	Stop(ctx context.Context) error
}

Lifecycle owns an ordered set of ComponentValue and drives it up and down. Implementations MUST be safe for concurrent use.

IFACE-PLUGIN: the concrete engine stays unexported behind its constructor in internal/service/app/lifecycle.

type Phase

type Phase uint8

Phase names which half of a component's contract a TransitionValue reports. It is a closed set of two: a component goes up or it goes down.

const PhaseStart Phase = 1

PhaseStart reports a call to a component's Start.

const PhaseStop Phase = 2

PhaseStop reports a call to a component's Stop.

func (Phase) String

func (p Phase) String() string

String renders the phase for a log line. An out-of-range value renders as "unknown" rather than a number, because a Phase reaching a log with a value this package never mints is a defect worth reading as one.

type Start

type Start func(ctx context.Context) error

Start brings one component up. It MUST honour ctx: the Lifecycle passes the context Start was called with, so cancelling that context is how a caller aborts a startup that is taking too long.

Returning a non-nil error means the component is NOT up. The Lifecycle then stops every component that IS up, in reverse order, and never calls this component's Stop — a Start that fails owns its own cleanup, which is the only rule under which a Stop may assume its Start succeeded.

type Stop

type Stop func(ctx context.Context) error

Stop takes one component down. It is called only for a component whose Start returned nil, and only once.

ctx carries this component's shutdown budget and is cancelled when that budget expires. Cancellation is an ANNOUNCEMENT, not a severance: the Lifecycle stops waiting, but it does not and cannot kill the goroutine, and it closes nothing on the component's behalf. A Stop that ignores ctx therefore delays nothing except its own report — the components after it in the reverse order each get their own full budget.

type TransitionValue

type TransitionValue struct {
	// Name is the component's registered name.
	Name string
	// Phase says whether this was the Start half or the Stop half.
	Phase Phase
	// Begun is the instant the call was made, read from the injected clock.
	Begun time.Time
	// Ended is the instant the Lifecycle stopped waiting for it. On a
	// TimedOut transition that is when the budget expired, NOT when the
	// component eventually returned — the Lifecycle does not know when that
	// was, and reporting a guess would be worse than reporting the fact.
	Ended time.Time
	// TimedOut reports that the component's Stop had not returned when its
	// budget expired. The goroutine was abandoned, not killed; see [Stop].
	TimedOut bool
	// Err is what the component returned, verbatim, so the caller's errors.Is
	// keeps working. A panic is the one exception: it becomes
	// [ComponentPanicked] with the recovered value in a field.
	Err error
}

TransitionValue reports one call the Lifecycle made into a component: which component, which half of its contract, when it began and ended, and what came back.

It reports DECISIONS, not only successes. A Stop abandoned at its budget produces one too, with TimedOut set and an Err naming the component — a lifecycle that reported only the calls that returned would make a component which never stops look exactly like one that stops instantly, and would leave "this component overran" provable only by timing the process.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL