Compare commits
3 Commits
b7adafed67
...
58e5e33e18
| Author | SHA1 | Date | |
|---|---|---|---|
| 58e5e33e18 | |||
| a07d9d06ed | |||
| 4cd218f658 |
@@ -0,0 +1,6 @@
|
|||||||
|
# Test coverage
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
*.test
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
# app
|
# app
|
||||||
|
|
||||||
app is a simple and lightweight app lifecycle management library.
|
app orchestrates modular application setup and teardown for Go services.
|
||||||
|
|
||||||
## Example
|
## Install
|
||||||
|
|
||||||
Keep your `main.go` as simple as possible, all it's responsible for is creating the lifecycle, defining a default logger, and orchestrating setup and teardown.
|
```bash
|
||||||
|
go get gitea.auvem.com/go-toolkit/app
|
||||||
|
```
|
||||||
|
|
||||||
**main.go**
|
## Quick start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@@ -16,75 +18,74 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/applog"
|
||||||
"gitea.auvem.com/go-toolkit/app"
|
"gitea.auvem.com/go-toolkit/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create a new lifecycle, defaulting to no printed logs
|
lifecycle := app.NewLifecycle(
|
||||||
lifecycle := app.NewLifecycle().WithLogger(slog.New(slog.DiscardHandler))
|
applog.AppLogOpts{ConsoleOutput: os.Stderr}.Module(),
|
||||||
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := lifecycle.Teardown(); err != nil {
|
if err := lifecycle.Teardown(); err != nil {
|
||||||
fmt.Println("Error during shutdown", err)
|
fmt.Println("shutdown error:", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Encodes the lifecycle into a context to be used downstream
|
if err := lifecycle.Setup(); err != nil {
|
||||||
ctx := lifecycle.Context(context.Background())
|
panic(err)
|
||||||
|
|
||||||
// Off to you, call your entrypoint here.
|
|
||||||
Hello(ctx)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, let's define a basic logging module and handle loading that module.
|
|
||||||
|
|
||||||
**logger.go**
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"gitea.auvem.com/go-toolkit/app"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ModuleLog(setDefault bool) *app.Module {
|
|
||||||
return app.NewModule("logger", app.ModuleOpts{
|
|
||||||
Setup: func(m *app.Module) error {
|
|
||||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})
|
|
||||||
if setDefault {
|
|
||||||
slog.SetDefault(handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.Lifecycle().WithLogger(handler)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Logging can, however, be somewhat of a complex operation in itself, so I'd highly recommend checking out my [applog](https://gitea.auvem.com/go-toolkit/applog) project, which features tight integration with this app.
|
|
||||||
|
|
||||||
Finally, lets put our new lifecycle (and logger) to work!
|
|
||||||
|
|
||||||
**hello.go**
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.auvem.com/go-toolkit/app"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Hello(ctx context.Context) {
|
|
||||||
app := app.LifecycleFromContext(ctx)
|
|
||||||
if app == nil {
|
|
||||||
panic("hello must run within an app lifecycle")
|
|
||||||
}
|
}
|
||||||
lifecycle.Require(ModuleLog(true))
|
|
||||||
|
|
||||||
app.Logger().Info("Hello world!", "foo", "bar")
|
ctx := lifecycle.Context(context.Background())
|
||||||
|
_ = ctx
|
||||||
|
lifecycle.Logger().Info("ready")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **Module** — a named subsystem with optional `Setup`, `Teardown`, and `Depends`
|
||||||
|
- **Lifecycle** — holds modules and runs setup/teardown in dependency order
|
||||||
|
- **Setup** — initializes all modules registered via `NewLifecycle`
|
||||||
|
- **Require** — adds and initializes modules on demand (typical for CLI subcommands via [appcli](https://gitea.auvem.com/go-toolkit/appcli))
|
||||||
|
- **Autoload** — when enabled (default), `Depends` names are set up automatically before the dependent module
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
List dependency module **names** in `ModuleOpts.Depends`. Dependent modules must be registered in the same lifecycle (via `NewLifecycle` or an earlier `Require`). Circular dependencies return an error.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
Always defer `lifecycle.Teardown()`. Teardown runs in **reverse setup order** so dependents shut down before their dependencies.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Use `lifecycle.Context(ctx)` so downstream code and `appcli` commands can call `app.LifecycleFromContext(ctx)`.
|
||||||
|
|
||||||
|
## Companion packages
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---------|------|
|
||||||
|
| [applog](https://gitea.auvem.com/go-toolkit/applog) | slog + tint logging module |
|
||||||
|
| [appcli](https://gitea.auvem.com/go-toolkit/appcli) | urfave/cli v3 integration |
|
||||||
|
| [dbx](https://gitea.auvem.com/go-toolkit/dbx) | database module |
|
||||||
|
| [migrate](https://gitea.auvem.com/go-toolkit/migrate) | goose migrations module |
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
| Symbol | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `NewLifecycle(modules...)` | Create lifecycle with optional initial modules |
|
||||||
|
| `Lifecycle.Setup()` | Set up all registered modules |
|
||||||
|
| `Lifecycle.Teardown()` | Tear down in reverse setup order |
|
||||||
|
| `Lifecycle.Require(modules...)` | Set up modules on demand |
|
||||||
|
| `Lifecycle.RequireWithOpts(opts, modules...)` | Require with custom logger or unique-name enforcement |
|
||||||
|
| `Lifecycle.GetModule(name)` | Look up a registered module |
|
||||||
|
| `NewModule(name, opts)` | Define a module |
|
||||||
|
| `LifecycleFromContext(ctx)` | Retrieve lifecycle from context |
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Partial setup failure rolls back already-initialized modules
|
||||||
|
- `Teardown` joins errors from all modules but attempts every teardown
|
||||||
|
- A second `Setup` or `Teardown` on the same lifecycle returns an error
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// Package app provides the core setup and teardown functions for the
|
|
||||||
// backend application. It handles the initialization of all sub-systems
|
|
||||||
// and the logger. app should never be imported except by a main package.
|
|
||||||
// Examples of suitable main packages are cmd and test.
|
|
||||||
package app
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package app orchestrates modular application setup and teardown.
|
||||||
|
//
|
||||||
|
// Register subsystems as [Module] values, attach them to a [Lifecycle], and call
|
||||||
|
// [Lifecycle.Setup] or [Lifecycle.Require] to initialize resources in dependency
|
||||||
|
// order. Always call [Lifecycle.Teardown] (typically via defer) to release
|
||||||
|
// resources in reverse setup order.
|
||||||
|
//
|
||||||
|
// Module authors (dbx, applog, migrate, etc.) and main/cmd packages both import
|
||||||
|
// app. Use [Lifecycle.Context] to propagate the lifecycle through context.Context
|
||||||
|
// for CLI commands via [gitea.auvem.com/go-toolkit/appcli].
|
||||||
|
//
|
||||||
|
// # Concepts
|
||||||
|
//
|
||||||
|
// - [Module] — named unit with optional Setup, Teardown, and Depends
|
||||||
|
// - [Lifecycle] — registry and orchestrator for modules
|
||||||
|
// - Setup order — dependencies first; autoload resolves Depends when enabled
|
||||||
|
// - Require — lazy/conditional setup (common in CLI subcommands)
|
||||||
|
//
|
||||||
|
// # API overview
|
||||||
|
//
|
||||||
|
// Lifecycle — [NewLifecycle], [Lifecycle.Setup], [Lifecycle.Teardown],
|
||||||
|
// [Lifecycle.Require], [Lifecycle.RequireWithOpts], [Lifecycle.GetModule],
|
||||||
|
// [Lifecycle.Context], [LifecycleFromContext]
|
||||||
|
//
|
||||||
|
// Module — [NewModule], [Module.Lifecycle], [Module.Logger], [Module.Loaded]
|
||||||
|
package app
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrModuleNotFound is returned when a module is not found in the lifecycle.
|
||||||
|
var ErrModuleNotFound = errors.New("module not found")
|
||||||
+14
-195
@@ -5,8 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -29,6 +27,7 @@ type Lifecycle struct {
|
|||||||
modules []*Module
|
modules []*Module
|
||||||
opts LifecycleOpts
|
opts LifecycleOpts
|
||||||
|
|
||||||
|
setupOrder []*Module
|
||||||
setupCount int
|
setupCount int
|
||||||
setupTracker map[string]int
|
setupTracker map[string]int
|
||||||
teardownCount int
|
teardownCount int
|
||||||
@@ -38,7 +37,6 @@ type Lifecycle struct {
|
|||||||
// NewLifecycle creates a new Lifecycle instance with a default logger and the
|
// NewLifecycle creates a new Lifecycle instance with a default logger and the
|
||||||
// given modules. It panics if any module has a duplicate name.
|
// given modules. It panics if any module has a duplicate name.
|
||||||
func NewLifecycle(modules ...*Module) *Lifecycle {
|
func NewLifecycle(modules ...*Module) *Lifecycle {
|
||||||
// Ensure modules are unique
|
|
||||||
unique := make(map[string]bool)
|
unique := make(map[string]bool)
|
||||||
for _, mod := range modules {
|
for _, mod := range modules {
|
||||||
if _, exists := unique[mod.name]; exists {
|
if _, exists := unique[mod.name]; exists {
|
||||||
@@ -96,17 +94,18 @@ func (app *Lifecycle) Logger() *slog.Logger {
|
|||||||
return app.opts.Logger
|
return app.opts.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup initializes all modules in the order they were defined and checks
|
// Setup initializes all registered modules, resolving dependencies via autoload
|
||||||
// for dependencies. It returns an error if any module fails to initialize or
|
// when enabled. Modules run setup in dependency order; among independent modules,
|
||||||
// if dependencies are not satisfied. Lifecycle.Teardown should always be run at the end
|
// registration order is preserved. Teardown runs in reverse setup order.
|
||||||
// of the application lifecycle to ensure all resources are cleaned up properly.
|
|
||||||
func (app *Lifecycle) Setup() error {
|
func (app *Lifecycle) Setup() error {
|
||||||
if app.setupCount > 0 {
|
if app.setupCount > 0 {
|
||||||
return fmt.Errorf("lifecycle already set up, cannot set up again")
|
return fmt.Errorf("lifecycle already set up, cannot set up again")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupBefore := len(app.setupOrder)
|
||||||
for _, mod := range app.modules {
|
for _, mod := range app.modules {
|
||||||
if err := app.setupSingle(nil, mod); err != nil {
|
if err := app.setupSingle(nil, mod, nil); err != nil {
|
||||||
|
app.rollbackFrom(setupBefore)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,24 +116,24 @@ func (app *Lifecycle) Setup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teardown runs all module teardown functions in reverse order of setup.
|
// Teardown runs teardown for all set-up modules in reverse setup order.
|
||||||
// Teardown should always be run at the end of the application lifecycle
|
// All module teardown errors are joined and returned (non-blocking).
|
||||||
// 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 {
|
func (app *Lifecycle) Teardown() error {
|
||||||
if app.teardownCount > 0 {
|
if app.teardownCount > 0 {
|
||||||
return fmt.Errorf("lifecycle already torn down, cannot tear down again")
|
return fmt.Errorf("lifecycle already torn down, cannot tear down again")
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
for i := len(app.modules) - 1; i >= 0; i-- {
|
var failureCount int
|
||||||
if singleErr := app.teardownSingle(app.modules[i]); singleErr != nil {
|
for i := len(app.setupOrder) - 1; i >= 0; i-- {
|
||||||
|
if singleErr := app.teardownSingle(app.setupOrder[i]); singleErr != nil {
|
||||||
err = errors.Join(err, singleErr)
|
err = errors.Join(err, singleErr)
|
||||||
|
failureCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err)
|
app.Logger().Error("Error tearing down modules", "failures", failureCount, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,183 +142,3 @@ func (app *Lifecycle) Teardown() error {
|
|||||||
|
|
||||||
return nil
|
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, " ") + "]"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/app"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupTeardownIntegration(t *testing.T) {
|
||||||
|
var order []string
|
||||||
|
modA := app.NewModule("a", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { order = append(order, "setup:a"); return nil },
|
||||||
|
Teardown: func(m *app.Module) error { order = append(order, "teardown:a"); return nil },
|
||||||
|
})
|
||||||
|
modB := app.NewModule("b", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { order = append(order, "setup:b"); return nil },
|
||||||
|
Teardown: func(m *app.Module) error { order = append(order, "teardown:b"); return nil },
|
||||||
|
Depends: []string{"a"},
|
||||||
|
})
|
||||||
|
|
||||||
|
lc := app.NewLifecycle(modB, modA)
|
||||||
|
assert.NoError(t, lc.Setup())
|
||||||
|
assert.Equal(t, []string{"setup:a", "setup:b"}, order)
|
||||||
|
|
||||||
|
order = nil
|
||||||
|
assert.NoError(t, lc.Teardown())
|
||||||
|
assert.Equal(t, []string{"teardown:b", "teardown:a"}, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupNoDoubleSetupWithAutoload(t *testing.T) {
|
||||||
|
var count int
|
||||||
|
modA := app.NewModule("a", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { count++; return nil },
|
||||||
|
})
|
||||||
|
modB := app.NewModule("b", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { count++; return nil },
|
||||||
|
Depends: []string{"a"},
|
||||||
|
})
|
||||||
|
|
||||||
|
lc := app.NewLifecycle(modB, modA)
|
||||||
|
assert.NoError(t, lc.Setup())
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupPartialFailureRollback(t *testing.T) {
|
||||||
|
var tornDown bool
|
||||||
|
modA := app.NewModule("a", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { return nil },
|
||||||
|
Teardown: func(m *app.Module) error { tornDown = true; return nil },
|
||||||
|
})
|
||||||
|
modB := app.NewModule("b", app.ModuleOpts{
|
||||||
|
Setup: func(m *app.Module) error { return fmt.Errorf("fail b") },
|
||||||
|
})
|
||||||
|
|
||||||
|
lc := app.NewLifecycle(modA, modB)
|
||||||
|
err := lc.Setup()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, tornDown)
|
||||||
|
assert.False(t, modA.Loaded())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupCircularDependency(t *testing.T) {
|
||||||
|
modA := app.NewModule("a", app.ModuleOpts{Depends: []string{"b"}})
|
||||||
|
modB := app.NewModule("b", app.ModuleOpts{Depends: []string{"a"}})
|
||||||
|
lc := app.NewLifecycle(modA, modB)
|
||||||
|
err := lc.Setup()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "circular dependency")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModule(t *testing.T) {
|
||||||
|
mod := app.NewModule("db", app.ModuleOpts{})
|
||||||
|
lc := app.NewLifecycle(mod)
|
||||||
|
got, err := lc.GetModule("db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, mod, got)
|
||||||
|
_, err = lc.GetModule("missing")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *Lifecycle) require(opts RequireOpts, modules ...*Module) error {
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return fmt.Errorf("no modules to require")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Logger != nil && opts.Unique {
|
||||||
|
// unique with custom logger is valid
|
||||||
|
}
|
||||||
|
if opts.Logger == nil && opts.Unique {
|
||||||
|
// valid
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBefore := len(app.setupOrder)
|
||||||
|
|
||||||
|
for i, mod := range modules {
|
||||||
|
if mod == nil {
|
||||||
|
app.rollbackFrom(setupBefore)
|
||||||
|
return fmt.Errorf("module %d is nil", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := app.setupTracker[mod.name]; ok {
|
||||||
|
if opts.Unique {
|
||||||
|
app.rollbackFrom(setupBefore)
|
||||||
|
return fmt.Errorf("module %s is already set up, cannot require again", mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Logger().Warn("module already set up, ignoring", "module", mod)
|
||||||
|
mod.loaded = true
|
||||||
|
mod.lifecycle = app
|
||||||
|
mod.logger = opts.Logger
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules = append(app.modules, mod)
|
||||||
|
|
||||||
|
if err := app.setupSingle(opts.Logger, mod, nil); err != nil {
|
||||||
|
app.rollbackFrom(setupBefore)
|
||||||
|
return fmt.Errorf("error setting up required module %s: %w", mod, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module, visiting map[string]bool) error {
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("module is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := app.setupTracker[mod.name]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if visiting == nil {
|
||||||
|
visiting = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if visiting[mod.name] {
|
||||||
|
return fmt.Errorf("circular dependency detected involving %s", mod)
|
||||||
|
}
|
||||||
|
visiting[mod.name] = true
|
||||||
|
defer delete(visiting, mod.name)
|
||||||
|
|
||||||
|
mod.lifecycle = app
|
||||||
|
mod.logger = logger
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, visiting); err != nil {
|
||||||
|
return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mod.setup != nil {
|
||||||
|
if err := mod.setup(mod); err != nil {
|
||||||
|
return fmt.Errorf("error initializing %s: %w", mod, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setupTracker[mod.name] = app.setupCount
|
||||||
|
app.setupOrder = append(app.setupOrder, mod)
|
||||||
|
app.setupCount++
|
||||||
|
mod.loaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Lifecycle) teardownSingle(mod *Module) error {
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("module is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := app.setupTracker[mod.name]; !ok {
|
||||||
|
return fmt.Errorf("module %s is not set up, cannot tear down", mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mod.teardown != nil {
|
||||||
|
if err := mod.teardown(mod); err != nil {
|
||||||
|
return fmt.Errorf("error tearing down %s: %w", mod, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.teardownTracker[mod.name] = app.teardownCount
|
||||||
|
app.teardownCount++
|
||||||
|
mod.loaded = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Lifecycle) rollbackFrom(startIndex int) {
|
||||||
|
for i := len(app.setupOrder) - 1; i >= startIndex; i-- {
|
||||||
|
mod := app.setupOrder[i]
|
||||||
|
if mod.teardown != nil {
|
||||||
|
_ = mod.teardown(mod)
|
||||||
|
}
|
||||||
|
delete(app.setupTracker, mod.name)
|
||||||
|
mod.loaded = false
|
||||||
|
}
|
||||||
|
app.setupOrder = app.setupOrder[:startIndex]
|
||||||
|
app.setupCount = startIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Lifecycle) getModuleByName(name string) (*Module, error) {
|
||||||
|
for _, mod := range app.modules {
|
||||||
|
if mod.name == name {
|
||||||
|
return mod, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrModuleNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToString(m map[string]int) string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
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, " ") + "]"
|
||||||
|
}
|
||||||
+22
-9
@@ -246,10 +246,16 @@ func TestLifecycle_Teardown(t *testing.T) {
|
|||||||
|
|
||||||
lc := NewLifecycle(tc.modules...)
|
lc := NewLifecycle(tc.modules...)
|
||||||
|
|
||||||
// Fake setup for all modules
|
if len(tc.modules) > 0 {
|
||||||
for _, mod := range tc.modules {
|
setupBefore := len(lc.setupOrder)
|
||||||
mod.loaded = true // Mark as loaded
|
for i, mod := range tc.modules {
|
||||||
lc.setupTracker[mod.name] = 0 // Mark as set up
|
lc.setupOrder = append(lc.setupOrder, mod)
|
||||||
|
lc.setupTracker[mod.name] = i
|
||||||
|
mod.loaded = true
|
||||||
|
mod.lifecycle = lc
|
||||||
|
}
|
||||||
|
lc.setupCount = len(lc.setupOrder)
|
||||||
|
_ = setupBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
err := lc.Teardown()
|
err := lc.Teardown()
|
||||||
@@ -283,6 +289,8 @@ func TestLifecycle_Teardown(t *testing.T) {
|
|||||||
// Fake setup for the module
|
// Fake setup for the module
|
||||||
lc.modules[0].loaded = true
|
lc.modules[0].loaded = true
|
||||||
lc.setupTracker[lc.modules[0].name] = 0
|
lc.setupTracker[lc.modules[0].name] = 0
|
||||||
|
lc.setupOrder = []*Module{lc.modules[0]}
|
||||||
|
lc.setupCount = 1
|
||||||
|
|
||||||
err := lc.Teardown()
|
err := lc.Teardown()
|
||||||
assert.NoError(err, "expected first Teardown to succeed")
|
assert.NoError(err, "expected first Teardown to succeed")
|
||||||
@@ -418,7 +426,7 @@ func TestLifecycle_require(t *testing.T) {
|
|||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
lc := NewLifecycle()
|
lc := NewLifecycle()
|
||||||
err := lc.require(tc.logger, tc.unique, tc.modules...)
|
err := lc.require(RequireOpts{Logger: tc.logger, Unique: tc.unique}, tc.modules...)
|
||||||
|
|
||||||
if tc.expectedErr == "" {
|
if tc.expectedErr == "" {
|
||||||
assert.NoError(err, "expected require to succeed")
|
assert.NoError(err, "expected require to succeed")
|
||||||
@@ -550,7 +558,7 @@ func TestLifecycle_setupSingle(t *testing.T) {
|
|||||||
lc = NewLifecycle(tc.modules...)
|
lc = NewLifecycle(tc.modules...)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := lc.setupSingle(l, tc.targetModule)
|
err := lc.setupSingle(l, tc.targetModule, nil)
|
||||||
|
|
||||||
if tc.expectedErr == "" {
|
if tc.expectedErr == "" {
|
||||||
assert.NoError(err, "expected no error from setupSingle")
|
assert.NoError(err, "expected no error from setupSingle")
|
||||||
@@ -616,12 +624,17 @@ func TestLifecycle_teardownSingle(t *testing.T) {
|
|||||||
|
|
||||||
lc := NewLifecycle()
|
lc := NewLifecycle()
|
||||||
|
|
||||||
// Fake setup for all modules
|
|
||||||
var setupCount int
|
var setupCount int
|
||||||
for _, mod := range tc.modules {
|
for _, modName := range tc.modules {
|
||||||
lc.setupTracker[mod] = setupCount
|
lc.setupTracker[modName] = setupCount
|
||||||
|
for _, m := range lc.modules {
|
||||||
|
if m.name == modName {
|
||||||
|
lc.setupOrder = append(lc.setupOrder, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
setupCount++
|
setupCount++
|
||||||
}
|
}
|
||||||
|
lc.setupCount = setupCount
|
||||||
|
|
||||||
err := lc.teardownSingle(tc.targetModule)
|
err := lc.teardownSingle(tc.targetModule)
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrModuleNotFound is returned when a module is not found in the lifecycle.
|
|
||||||
var ErrModuleNotFound = errors.New("module not found")
|
|
||||||
|
|
||||||
// ModuleFn is a function type for module setup and teardown functions.
|
// ModuleFn is a function type for module setup and teardown functions.
|
||||||
type ModuleFn func(*Module) error
|
type ModuleFn func(*Module) error
|
||||||
|
|
||||||
// GenericModule is an interface that allows modules to be extended with custom
|
|
||||||
// functionality, as the module can return a pointer to the underlying Module.
|
|
||||||
type GenericModule interface {
|
|
||||||
// Module returns the underlying Module instance.
|
|
||||||
Module() *Module
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module represents a sub-system of the application, with its setup and
|
// Module represents a sub-system of the application, with its setup and
|
||||||
// teardown functions and dependencies.
|
// teardown functions and dependencies.
|
||||||
type Module struct {
|
type Module struct {
|
||||||
@@ -60,7 +49,8 @@ func (s *Module) Lifecycle() *Lifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Logger returns the logger for the module. Uses the lifecycle's logger unless
|
// Logger returns the logger for the module. Uses the lifecycle's logger unless
|
||||||
// a specific logger has been set during module load.
|
// a specific logger has been set during module load. Panics if the module is
|
||||||
|
// not associated with a lifecycle and no override logger was set.
|
||||||
func (s *Module) Logger() *slog.Logger {
|
func (s *Module) Logger() *slog.Logger {
|
||||||
if s.logger != nil {
|
if s.logger != nil {
|
||||||
return s.logger
|
return s.logger
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireOpts configures [Lifecycle.RequireWithOpts] behavior.
|
||||||
|
type RequireOpts struct {
|
||||||
|
// Logger overrides the lifecycle logger for the required modules. If nil,
|
||||||
|
// the lifecycle logger is used.
|
||||||
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
// Unique, when true, returns an error if any requested module name is
|
||||||
|
// already set up instead of ignoring duplicate instances.
|
||||||
|
Unique bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require adds module(s) to the lifecycle and immediately runs setup.
|
||||||
|
// Modules that are already set up (by name) are ignored.
|
||||||
|
func (app *Lifecycle) Require(modules ...*Module) error {
|
||||||
|
return app.require(RequireOpts{}, modules...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireWithOpts is like [Lifecycle.Require] with additional options.
|
||||||
|
func (app *Lifecycle) RequireWithOpts(opts RequireOpts, modules ...*Module) error {
|
||||||
|
return app.require(opts, modules...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModule returns a registered module by name.
|
||||||
|
func (app *Lifecycle) GetModule(name string) (*Module, error) {
|
||||||
|
mod, err := app.getModuleByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get module %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return mod, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireL loads modules using a specific logger. Deprecated: use
|
||||||
|
// RequireWithOpts(RequireOpts{Logger: logger}, modules...).
|
||||||
|
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
||||||
|
if logger == nil {
|
||||||
|
return fmt.Errorf("logger cannot be nil")
|
||||||
|
}
|
||||||
|
return app.require(RequireOpts{Logger: logger}, modules...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireUnique loads modules and errors if any name is already set up.
|
||||||
|
// Deprecated: use RequireWithOpts(RequireOpts{Unique: true}, modules...).
|
||||||
|
func (app *Lifecycle) RequireUnique(modules ...*Module) error {
|
||||||
|
return app.require(RequireOpts{Unique: true}, modules...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireUniqueL combines RequireL and RequireUnique.
|
||||||
|
// Deprecated: use RequireWithOpts(RequireOpts{Logger: logger, Unique: true}, modules...).
|
||||||
|
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
|
||||||
|
if logger == nil {
|
||||||
|
return fmt.Errorf("logger cannot be nil")
|
||||||
|
}
|
||||||
|
return app.require(RequireOpts{Logger: logger, Unique: true}, modules...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user