lifecycle

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 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/lifecycle; facade: pkg/v1/lifecycle. ADR 0050. See CLAUDE.md.

Documentation

Overview

Package lifecycle — range 0.2.19.* (ADR 0050 core/lifecycle block).

Package lifecycle — declares the sentinel *errs.Error port outcomes. 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/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.

Package lifecycle — hosts TransitionValue, one reported component move.

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.

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.Define(CodeInvalidComponent, "INVALID_COMPONENT",
		"The lifecycle component is not runnable and was refused",
		"core/lifecycle: component has an empty name, a nil Start or a nil Stop; the field names which",
		errs.WithExitCode(exitConfig))

	// 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.Define(CodeDuplicateComponent, "DUPLICATE_COMPONENT",
		"A component is already registered under that name",
		"core/lifecycle: component names are unique per Lifecycle; the field carries the name",
		errs.WithExitCode(exitConfig))

	// 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.Define(CodeLifecycleRunning, "LIFECYCLE_RUNNING",
		"The lifecycle is already started",
		"core/lifecycle: the component order is frozen once Start begins; add before Start, or after Stop returns",
		errs.WithExitCode(exitConfig))

	// 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.Define(CodeComponentPanicked, "COMPONENT_PANICKED",
		"The lifecycle component panicked and was recovered",
		"core/lifecycle: component panicked; the fields carry its name, the phase, and the panic value")
)

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/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