Files
app/lifecycle.go
2025-06-04 14:03:53 -07:00

318 lines
9.0 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
)
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
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 {
// Ensure modules are unique
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),
}
}
// LifecycleToContext adds the Lifecycle to the context.
func LifecycleToContext(ctx context.Context, lifecycle *Lifecycle) context.Context {
if lifecycle == nil {
return ctx
}
return context.WithValue(ctx, lifecycleContextKey, lifecycle)
}
// 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
}
// 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 modules in the order they were defined and checks
// for dependencies. It returns an error if any module fails to initialize or
// if dependencies are not satisfied. Lifecycle.Teardown should always be run at the end
// of the application lifecycle to ensure all resources are cleaned up properly.
func (app *Lifecycle) Setup() error {
for _, mod := range app.modules {
if err := app.setupSingle(nil, mod); err != nil {
return err
}
}
app.Logger().Info("Lifecycle modules initialized, backend setup complete",
"modules", mapToString(app.setupTracker))
return nil
}
// Teardown runs all module teardown functions in reverse order of setup.
// Teardown should always be run at the end of the application lifecycle
// to ensure all resources are cleaned up properly. All module tear down
// errors are returned as a single error (non-blocking).
func (app *Lifecycle) Teardown() error {
var err error
for i := len(app.modules) - 1; i >= 0; i-- {
if singleErr := app.teardownSingle(app.modules[i]); singleErr != nil {
err = errors.Join(err, singleErr)
}
}
if err != nil {
app.Logger().Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err)
return err
}
app.Logger().Info("All modules torn down, backend teardown complete",
"modules", mapToString(app.teardownTracker))
return nil
}
// Require adds module(s) to the lifecycle and immediately runs any setup
// functions. Relevant when a module is not part of the main application
// but may still be conditionally necessary. Any modules that are already
// set up are ignored.
func (app *Lifecycle) Require(modules ...*Module) error {
return app.require(nil, false, modules...)
}
// RequireUnique is the same as Require, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUnique(modules ...*Module) error {
return app.require(nil, true, modules...)
}
// RequireL adds module(s) to the lifecycle with a specific logger and
// immediately runs any setup functions. See Require for more details.
// This variation is useful when you need to set up modules with a non-
// default logger.
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, false, modules...)
}
// RequireUniqueL is the same as RequireL, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, true, modules...)
}
// require is a helper function that attempts to add module(s) to the lifecycle
// and immediately run any setup functions.
func (app *Lifecycle) require(logger *slog.Logger, unique bool, modules ...*Module) error {
if len(modules) == 0 {
return fmt.Errorf("no modules to require")
}
for i, mod := range modules {
if mod == nil {
return fmt.Errorf("module %d is nil", i)
}
// Check if the module has already been set up
if _, ok := app.setupTracker[mod.name]; ok {
if unique {
return fmt.Errorf("module %s is already set up, cannot require again", mod)
}
app.Logger().Warn("module already set up, ignoring", "module", mod)
// Mark duplicate module as loaded
mod.loaded = true
mod.lifecycle = app
mod.logger = logger
continue
}
// Add the module to the lifecycle
app.modules = append(app.modules, mod)
// Run the setup function for the module
if err := app.setupSingle(logger, mod); err != nil {
return fmt.Errorf("error setting up required module %s: %w", mod, err)
}
}
app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker))
return nil
}
// setupSingle is a helper function to set up a single module. Returns an error
// if the module cannot be set up or if dependencies are not satisfied.
func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
// Set the parent lifecycle and logger override
mod.lifecycle = app
mod.logger = logger
// Check if all dependencies are satisfied
for _, dep := range mod.depends {
if _, ok := app.setupTracker[dep]; !ok {
if app.opts.DisableAutoload {
return fmt.Errorf("dependency %s not satisfied for '%s'", dep, mod)
} else {
// Attempt to set up the dependency
depmod, err := app.getModuleByName(dep)
if err != nil {
return fmt.Errorf("error getting dependency '%s' for %s: %w", dep, mod, err)
}
if err := app.setupSingle(logger, depmod); err != nil {
return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err)
}
}
}
}
if mod.setup != nil {
// Run the setup function for the module
if err := mod.setup(mod); err != nil {
return fmt.Errorf("error initializing %s: %w", mod, err)
}
}
// Mark this module as setup
app.setupTracker[mod.name] = app.setupCount
app.setupCount++
mod.loaded = true
return nil
}
// teardownSingle is a helper function to tear down a single module. Returns
// an error if anything goes wrong.
func (app *Lifecycle) teardownSingle(mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
// Check if the module was set up
if _, ok := app.setupTracker[mod.name]; !ok {
return fmt.Errorf("module %s is not set up, cannot tear down", mod)
}
// Run the teardown function for the module
if mod.teardown != nil {
if err := mod.teardown(mod); err != nil {
return fmt.Errorf("error tearing down %s: %w", mod, err)
}
}
// Mark this module as torn down
app.teardownTracker[mod.name] = app.teardownCount
app.teardownCount++
mod.loaded = false
return nil
}
// getModuleByName retrieves a module by its name from the lifecycle.
func (app *Lifecycle) getModuleByName(name string) (*Module, error) {
for _, mod := range app.modules {
if mod.name == name {
return mod, nil
}
}
return nil, ErrModuleNotFound
}
// mapToString converts a map to an ordered, opinionated string representation.
func mapToString(m map[string]int) string {
if len(m) == 0 {
return "[]"
}
// Sort the map by value
values := make([]int, 0, len(m))
reverseMap := make(map[int]string, len(m))
for k, v := range m {
values = append(values, v)
reverseMap[v] = k
}
sort.Ints(values)
result := make([]string, 0, len(m))
for _, v := range values {
if name, ok := reverseMap[v]; ok {
result = append(result, fmt.Sprintf("'%s'", name))
}
}
return "[" + strings.Join(result, " ") + "]"
}