Files
app/lifecycle.go
T
end 4cd218f658 fix: correct lifecycle setup and teardown semantics
Guard against double setup when autoload runs dependencies early, tear down
in reverse setup order, roll back on partial failure, and detect circular
Depends chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:58:00 -07:00

145 lines
3.9 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"log/slog"
)
type contextKey string
const lifecycleContextKey contextKey = "lifecycle"
// LifecycleOpts contains user-exposed options when defining a lifecycle.
type LifecycleOpts struct {
// DisableAutoload disables dependency autoloading (enabled by default).
DisableAutoload bool
// Logger is the logger for the lifecycle. If not set, it will be initialized
// by the lifecycle using the default logger (slog.Default).
Logger *slog.Logger
}
// Lifecycle represents the core application structure. Lifecycle manages
// resources providing an orchestrator for setup and teardown of modules.
type Lifecycle struct {
modules []*Module
opts LifecycleOpts
setupOrder []*Module
setupCount int
setupTracker map[string]int
teardownCount int
teardownTracker map[string]int
}
// NewLifecycle creates a new Lifecycle instance with a default logger and the
// given modules. It panics if any module has a duplicate name.
func NewLifecycle(modules ...*Module) *Lifecycle {
unique := make(map[string]bool)
for _, mod := range modules {
if _, exists := unique[mod.name]; exists {
panic(fmt.Sprintf("duplicate module: %s", mod))
}
unique[mod.name] = true
}
return &Lifecycle{
modules: modules,
setupTracker: make(map[string]int),
teardownTracker: make(map[string]int),
}
}
// LifecycleFromContext retrieves the Lifecycle from the context. Returns nil if not found.
func LifecycleFromContext(ctx context.Context) *Lifecycle {
if lifecycle, ok := ctx.Value(lifecycleContextKey).(*Lifecycle); ok {
return lifecycle
}
return nil
}
// Context adds the Lifecycle to a context and returns the new context.
func (app *Lifecycle) Context(ctx context.Context) context.Context {
if app == nil {
return ctx
}
return context.WithValue(ctx, lifecycleContextKey, app)
}
// WithOpts sets the options for the lifecycle.
func (app *Lifecycle) WithOpts(opts LifecycleOpts) *Lifecycle {
app.opts = opts
return app
}
// WithLogger sets the logger for the lifecycle. Panics if the logger is nil.
func (app *Lifecycle) WithLogger(logger *slog.Logger) *Lifecycle {
if logger == nil {
panic("logger cannot be nil")
}
app.opts.Logger = logger
return app
}
// Logger returns the logger for the lifecycle.
func (app *Lifecycle) Logger() *slog.Logger {
if app == nil {
panic("lifecycle is nil, cannot get logger")
}
if app.opts.Logger == nil {
app.opts.Logger = slog.Default()
}
return app.opts.Logger
}
// Setup initializes all registered modules, resolving dependencies via autoload
// when enabled. Modules run setup in dependency order; among independent modules,
// registration order is preserved. Teardown runs in reverse setup order.
func (app *Lifecycle) Setup() error {
if app.setupCount > 0 {
return fmt.Errorf("lifecycle already set up, cannot set up again")
}
setupBefore := len(app.setupOrder)
for _, mod := range app.modules {
if err := app.setupSingle(nil, mod, nil); err != nil {
app.rollbackFrom(setupBefore)
return err
}
}
app.Logger().Info("Lifecycle modules initialized, backend setup complete",
"modules", mapToString(app.setupTracker))
return nil
}
// Teardown runs teardown for all set-up modules in reverse setup order.
// All module teardown errors are joined and returned (non-blocking).
func (app *Lifecycle) Teardown() error {
if app.teardownCount > 0 {
return fmt.Errorf("lifecycle already torn down, cannot tear down again")
}
var err error
var failureCount int
for i := len(app.setupOrder) - 1; i >= 0; i-- {
if singleErr := app.teardownSingle(app.setupOrder[i]); singleErr != nil {
err = errors.Join(err, singleErr)
failureCount++
}
}
if err != nil {
app.Logger().Error("Error tearing down modules", "failures", failureCount, "error", err)
return err
}
app.Logger().Info("All modules torn down, backend teardown complete",
"modules", mapToString(app.teardownTracker))
return nil
}