Lifecycle
Anything expensive to create, shared by every request and needing an orderly release fits one shape.
type Lifecycle interface {
// Name identifies the component in start-up and shutdown logs. Keep it
// short and lowercase, such as "redis" or "database".
Name() string
// Start acquires the resource. It is called once, before the server begins
// accepting requests, and must return only when the resource is ready to
// use. The context is cancelled when a sibling component fails, so a
// long-running dial should honour it and give up.
Start(ctx context.Context) error
// Stop releases the resource. It is called once, after the HTTP server has
// finished draining in-flight requests, and is called even for a start-up
// that failed part way, for every component that did start.
Stop(ctx context.Context) error
}
Publish the value with WithSingleton and, if it implements Lifecycle, Muzak takes it
from there.
A component that implements the interface
// ModelRegistry holds the prediction models the service serves.
//
// It implements muzak.Lifecycle, so publishing it with muzak.WithSingleton is
// enough for Muzak to load it before the server accepts traffic and release it
// after the server has drained.
type ModelRegistry struct {
mu sync.RWMutex
models map[string]func(float64) float64
}
// NewModelRegistry returns an empty registry. The models themselves are loaded
// in Start, not here, so that construction stays cheap and failure has a place
// to be reported.
func NewModelRegistry() *ModelRegistry {
return &ModelRegistry{models: map[string]func(float64) float64{}}
}
// Name identifies the component in start-up and shutdown logs.
func (r *ModelRegistry) Name() string { return "ml-model" }
// Start loads the models. A real implementation would read weights from disk
// or object storage and should honour ctx, which Muzak cancels as soon as a
// sibling component fails.
func (r *ModelRegistry) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
r.models["answer_to_everything"] = func(x float64) float64 { return x * 42 }
return nil
}
// Stop releases the models. It runs only after the HTTP server has finished
// draining, so a request that is still predicting keeps working right up to the
// end.
func (r *ModelRegistry) Stop(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
clear(r.models)
return nil
}
// Predict runs the named model and reports whether it was loaded.
func (r *ModelRegistry) Predict(name string, x float64) (float64, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
model, ready := r.models[name]
if !ready {
return 0, false
}
return model(x), true
}
A component built from two closures
A type you do not own cannot implement the interface, and a small resource does not
warrant a type of its own. LifecycleFunc attaches start and stop functions to a value
published with WithSingleton.
// Lifecycle returns the option that registers the store's seeding and teardown
// with the application.
//
// The closures capture the store by pointer, so what they mutate is what
// handlers later read.
func (s *ItemStore) Lifecycle() muzak.SingletonOption {
return muzak.LifecycleFunc("item-store",
func(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items["foo"] = Item{ID: "foo", Name: "Foo"}
s.items["bar"] = Item{ID: "bar", Name: "Bar"}
return nil
},
func(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
clear(s.items)
return nil
},
)
}
The value itself is published unchanged, so the handler still retrieves it by type with
muzak.From[*core.ItemStore](ctx). The closures must capture something whose contents
can be mutated in place, such as a map, a struct pointer or a slice header held behind
one. Reassigning a captured variable inside Start will not change what handlers see.
muzak.NewLifecycle(name, start, stop) builds a standalone Lifecycle from the same two
closures, for a component that is not published as a value at all. Either function may be
nil, which makes that half a no-op.
Components with no published value
A background worker or a metrics exporter has nothing a handler would ask for. Register it directly.
app := muzak.New(muzak.AppOptions{Title: "Awesome API"},
muzak.WithLifecycle(
muzak.NewLifecycle("outbox-worker", worker.Start, worker.Stop),
),
)
Components registered this way are started and stopped exactly like those discovered
through WithSingleton.
Start-up
Components start in parallel, so start-up costs the slowest one rather than the sum.
14:32:07.482 INFO [Server] Starting Muzak application...
14:32:07.483 INFO [Server] Starting 3 lifecycle components in parallel: redis, database, ml-model
14:32:07.501 INFO [Server] Started "redis" (18ms)
14:32:07.512 INFO [Server] Started "ml-model" (29ms)
14:32:07.544 INFO [Server] Started "database" (61ms)
14:32:07.545 INFO [Server] All lifecycle components ready (61ms total)
If one fails, the others are cancelled immediately through the context they were given, everything that did start is stopped, and the failures are reported together. A failed start-up never leaks a connection pool.
That is why Start should honour its context. A dial that ignores cancellation holds up a
start-up that has already failed.
Shutdown
Shutdown runs in the order that matters:
- Open WebSocket connections are closed with
1001 Going Away, and open event streams are ended. Both are tracked by the application precisely so this can happen: a hijacked connection is no longer onenet/httpknows about, and a streaming handler would otherwise make the drain wait out its whole deadline once per stream. - The HTTP server stops accepting new connections and drains its in-flight requests.
- Only then are the lifecycle components stopped.
Pulling a database connection out from under a request that is still running would turn an orderly shutdown into a burst of errors, which is why components stay usable to the very end.
ServerOptions.ShutdownTimeout bounds the wait for in-flight requests, defaulting to 15
seconds. Set it below whatever grace period the platform allows, or the platform kills a
drain that is still running: Cloud Run allows about ten seconds after SIGTERM, and
Kubernetes and ECS both default to thirty.
A component that panics
A panic in Start or Stop becomes that component's error rather than the end of the
process, and the components either side of it are still started or released.
This matters most in Stop, which runs after the drain, on the framework's goroutine,
when every request has already been answered. A crash there takes the process down at the
one moment where doing so achieves nothing and the stack points at the framework rather
than at the component that caused it. A handler that panics already becomes a 500 rather
than a dead process; this is the same bargain for the same reason.
It is a backstop, not permission. A component whose Stop can panic is usually one that
dereferences something Start never set, and the fix is to check it:
func (d *Deps) Stop(ctx context.Context) error {
// Every field is checked, because a partially constructed Deps is exactly
// what exists when the constructor failed halfway.
if d.Pool != nil {
d.Pool.Close()
}
if d.Cache != nil {
return d.Cache.Close(ctx)
}
return nil
}
Driving it by hand
Run, RunContext and RunSignals call StartLifecycle between building the application
and opening the socket, and Shutdown calls StopLifecycle after the server has drained.
A program that serves the application some other way, such as a test wiring it into
httptest, calls them itself.
if err := app.Build(); err != nil {
return err
}
if err := app.StartLifecycle(ctx); err != nil {
return err
}
defer app.StopLifecycle(context.Background())
Both are idempotent: starting twice is a no-op, and stopping components that were never started returns nil.
The muzak.dev/framework/testclient package does all of this for you, including releasing everything
through the test's cleanup. See Testing.
Components the framework registers
MemoryRateLimitStorage implements Lifecycle, so an application that uses the built-in
rate limiter starts and stops it as part of its own start-up and shutdown. Stopping
releases every counter it holds, so no key outlives the server that was counting it. The
same applies to a RateLimitStorage of your own: implement Lifecycle and it is started
before serving and stopped after draining, with no separate registration.
Where to go next
Configuration covers reading the settings those components are built from, and Server Configuration covers the shutdown timeout and the signals that trigger it.