go-srvc / srvc

github.com/go-srvc/srvc

pkg.go.dev source
go get github.com/go-srvc/srvc@v1.13.0

README

Go Reference codecov main

Simple, Safe, and Modular Service Runner

srvc library provides a simple but powerful interface with zero external dependencies for running service modules.

The library itself stays dependency free: it ships the runner, the Module interface, and a handful of thin adapters (InitMod, RunMod, StopMod, CtxMod, IdleMod) that turn plain functions into modules. They are glue, nothing more. Modules that wrap real dependencies and carry their own domain logic, such as HTTP servers, signal handling, databases, and telemetry, live in github.com/go-srvc/mods.

Use Case

Normally Go services are composed of multiple "modules" which each run in their own goroutine such as http server, signal listener, kafka consumer, ticker, etc. These modules should remain alive throughout the lifecycle of the whole service, and if one goes down, graceful exit should be executed to avoid "zombie" services. srvc takes care of all this via a simple module interface.

Ready made modules with batteries included can be found under github.com/go-srvc/mods, while this repo keeps only the runner and the function adapters.

Usage

Main package

package main

import (
 "fmt"
 "net/http"

 "github.com/go-srvc/mods/httpmod"
 "github.com/go-srvc/mods/logmod"
 "github.com/go-srvc/mods/metermod"
 "github.com/go-srvc/mods/sigmod"
 "github.com/go-srvc/mods/sqlxmod"
 "github.com/go-srvc/mods/tracemod"
 "github.com/go-srvc/srvc"
)

func main() {
 db := sqlxmod.New()
 srvc.RunAndExit(
  logmod.New(),
  sigmod.New(),
  tracemod.New(),
  metermod.New(),
  db,
  httpmod.New(
   httpmod.WithAddr(":8080"),
   httpmod.WithHandler(handler(db)),
  ),
 )
}

func handler(db *sqlxmod.DB) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  if err := db.DB().PingContext(r.Context()); err != nil {
   http.Error(w, err.Error(), http.StatusInternalServerError)
   return
  }
  fmt.Fprint(w, "OK")
 })
}

Implementing custom modules

package main

import "github.com/go-srvc/srvc"

func main() {
 srvc.RunAndExit(&MyMod{})
}

type MyMod struct {
 done chan struct{}
}

func (m *MyMod) Init() error {
 m.done = make(chan struct{})
 return nil
}

// Run should block until the module is stopped.
// If you don't have a blocking operation, you can use done channel to block.
func (m *MyMod) Run() error {
 <-m.done
 return nil
}

func (m *MyMod) Stop() error {
 defer close(m.done)
 return nil
}

func (m *MyMod) ID() string { return "MyMod" }

Function modules

InitMod, RunMod, StopMod, CtxMod, and IdleMod wrap plain functions into a module when the full interface is not needed. They are deliberately minimal adapters with no logic of their own; anything that needs configuration, defaults, or dependency specific behaviour belongs in a real module, either your own or one from mods.

package main

import "github.com/go-srvc/srvc"

func main() {
 srvc.RunAndExit(
  // Called during init, Run blocks until shutdown.
  srvc.InitMod("migrations", runMigrations),
  // Called during stop, in reverse order like any other module.
  srvc.StopMod("flush", flushBuffers),
  // Called during run, service shuts down once it returns.
  srvc.RunMod("job", doWork),
  // Called during run with a context that is canceled on shutdown.
  srvc.CtxMod("consumer", consume),
  // Init and stop pair without any work of its own during run.
  srvc.IdleMod("db", openDB, closeDB),
 )
}

RunMod and CtxMod have no-op Init, and their function must return on its own for the service to shut down. CtxMod cancels the context passed to its function when Stop is called, so a function that respects the context returns on shutdown. InitMod, StopMod, and IdleMod have no work of their own during run, so their Run blocks until the service shuts down.

Optional modules

Optional toggles a module with a flag. A disabled module is dropped before the init phase, so none of its Init, Run, or Stop functions are called, and its ID is logged as disabled. The module is still constructed by the caller, so keep heavy work in Init. A disabled module may be nil.

srvc.RunAndExit(
  srvc.IdleMod("db", openDB, closeDB),
  srvc.Optional(cfg.MetricsEnabled, metrics.New()),
)

Lifecycle

Run executes modules through a deterministic lifecycle:

  1. Init is called sequentially in the order modules are passed. If any Init returns an error, the loop stops and Stop is called on already-initialized modules in reverse order. Uninitialized modules never get Init or Stop.
  2. Run is started for each successfully initialized module in its own goroutine. Start order is not guaranteed.
  3. When the first Run returns (with or without error), the service moves to shutdown.
  4. Stop is called sequentially in reverse order on every initialized module. Each module's Stop must cause its Run to return.
  5. Run blocks until every Run goroutine has returned, then returns the joined errors from Init, Run, and Stop (or nil).

Panic recovery

Panics inside Init, Run, or Stop are recovered. The stack trace is logged, and the panic is converted into an error wrapping srvc.ErrModulePanic so other modules can still shut down gracefully.

Exit behaviour

RunAndExit calls os.Exit(1) if Run returns any error, and returns normally on success.

Contracts modules must uphold

  • Stop must make Run return. If Run ignores Stop, srvc.Run will block in its final wait. There is no built-in shutdown timeout, so a stuck module hangs the service.
  • ID should return a stable, unique identifier used for log attribution.

Why no context.Context on lifecycle methods?

Init, Run, and Stop deliberately take no context.Context. Shutdown deadlines are already enforced by the platform the service runs on (Kubernetes terminationGracePeriodSeconds, systemd TimeoutStopSec, ECS stopTimeout), and a Go-side deadline cannot extend past the platform SIGKILL. Adding context plumbing to the interface would expand the surface without giving modules any extra safety. Modules that need a startup or shutdown budget for their own internal calls can take a context via their own options without changing Module. Functions that already take a context can be wrapped with CtxMod, which keeps the plumbing in the helper instead of the interface.

Acknowledgements

This library is something I have found myself writing over and over again in every project I been part of. One of the iterations can be found under https://github.com/elisasre/go-common.

Overview

Package srvc provides simple but powerful Run functionality on top of Module abstraction. Ready made modules can be found under: github.com/go-srvc/mods.

Constants

const ErrModulePanic = ErrStr("module recovered from panic")

Variables

var JoinErrors = errors.Join

JoinErrors is used by srvc to combine multiple errors into one. Override it to plug in custom multi-error formatting.

Functions

func Run

func Run(modules ...Module) error

Run will run all given modules using following control flow:

  1. Exec Init() for each module in order. If any Init() returns error the Init loop is stopped and Stop() will be called for already initialized modules in reverse order.
  2. Exec Run() for each module in own goroutine so order isn't guaranteed.
  3. Wait for any Run() function to return nil or an error and move to Stop loop.
  4. Exec Stop() for modules in reverse order.
  5. Wait for all Run() goroutines to return.
  6. Return all errors or nil

Panics inside modules are recovered so other modules can shut down gracefully. Recovered panics are returned as errors wrapping ErrModulePanic.

func RunAndExit

func RunAndExit(modules ...Module)

RunAndExit is convenience wrapper for Run that calls os.Exit with code 1 in case of an error. The common use case is to srvc.RunAndExit from main function and let the srvc handle the rest.

package main

import "github.com/go-srvc/srvc"

func main() {
	srvc.RunAndExit(
	// Add your modules here
	)
}

Types

type ErrGroup

type ErrGroup struct {
	// contains filtered or unexported fields
}

ErrGroup is a goroutine group that waits for all goroutines to finish and collects errors.

func (*ErrGroup) Go

func (eg *ErrGroup) Go(f func() error)

Go runs the given function in a goroutine.

func (*ErrGroup) Wait

func (eg *ErrGroup) Wait() error

Wait waits for all goroutines to finish and returns all errors that occurred.

type ErrStr

type ErrStr string

ErrStr adds Error method to string type.

func (ErrStr) Error

func (e ErrStr) Error() string

type Module

type Module interface {
	// ID should return identifier for logging purposes.
	ID() string
	// Init allows synchronous initialization of module.
	Init() error
	// Run should start the module and block until stop is called or error occurs.
	Run() error
	// Stop is called for synchronous cleanup and must cause Run to return.
	// If Init ran, Stop is guaranteed to run as part of cleanup.
	Stop() error
}

func CtxMod

func CtxMod(id string, runFn func(ctx context.Context) error) Module

CtxMod wraps runFn into a Module that calls runFn during the run phase with a context that is canceled when Stop is called. Init is a no-op and runFn must return once its context is done. Useful for run loops that already take a context.

func IdleMod

func IdleMod(id string, initFn, stopFn func() error) Module

IdleMod wraps initFn and stopFn into a Module that has no work of its own during the run phase. Run blocks until the service shuts down and nil functions are no-ops. Useful for resources that are set up on init and torn down on stop.

func InitMod

func InitMod(id string, initFn func() error) Module

InitMod wraps initFn into a Module that calls initFn during the init phase. Run blocks until the service shuts down and Stop is a no-op. Useful for one time setup that has no long running work of its own.

func Optional

func Optional(enabled bool, m Module) Module

Optional returns m when enabled is true. Otherwise m is skipped before the init phase, so none of its Init, Run or Stop functions are called. m is still constructed by the caller, so keep heavy work in Init. m may be nil when enabled is false.

func RunMod

func RunMod(id string, runFn func() error) Module

RunMod wraps runFn into a Module that calls runFn during the run phase. Init and Stop are no-ops, so runFn must return on its own. When it returns the whole service moves to the stop sequence.

func StopMod

func StopMod(id string, stopFn func() error) Module

StopMod wraps stopFn into a Module that calls stopFn during the stop phase. Init is a no-op and Run blocks until the service shuts down. Useful for cleanup that should always run when the service exits.

Examples

ExampleOptional

package main

import (
	"fmt"

	"github.com/go-srvc/srvc"
)

type printMod struct{}

func (m *printMod) ID() string  { return "printMod" }
func (m *printMod) Init() error { return nil }
func (m *printMod) Run() error  { fmt.Println("hello"); return nil }
func (m *printMod) Stop() error { return nil }

func main() {
	metricsEnabled := false
	_ = srvc.Run(
		&printMod{},
		srvc.Optional(metricsEnabled, srvc.IdleMod("metrics", nil, nil)),
	)
}

ExampleRun

package main

import (
	"fmt"

	"github.com/go-srvc/srvc"
)

type printMod struct{}

func (m *printMod) ID() string  { return "printMod" }
func (m *printMod) Init() error { return nil }
func (m *printMod) Run() error  { fmt.Println("hello"); return nil }
func (m *printMod) Stop() error { return nil }

func main() {
	_ = srvc.Run(&printMod{})
}