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:

  1. 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 one net/http knows about, and a streaming handler would otherwise make the drain wait out its whole deadline once per stream.
  2. The HTTP server stops accepting new connections and drains its in-flight requests.
  3. 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.

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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.