Documentation
¶
Overview ¶
Package runnable manages the lifecycle of long-running processes.
Everything is a Runnable: a Run method that blocks until its context is cancelled or its work is done. Shutdown is driven by context cancellation.
Manager runs several runnables together and stops them in order: processes first, then the services they depend on. The entry points Run, RunFunc and RunGroup add signal handling for main.
Wrappers compose behavior around a runnable: HTTPServer, GRPCServer, Restart, Retry, Schedule, Timeout, Delay, Recover, Signal, Closer, Func and Named.
Names flow through the context. A parent Manager or Named assigns them, wrappers pass them through, and NameFromContext reads them.
Example ¶
The README "Example logs" block copies the output of this example, keep them in sync.
package main
import (
"context"
"fmt"
"time"
"github.com/pior/runnable"
)
// JobQueue is a long-running service that processes background jobs.
type JobQueue struct{}
func (q *JobQueue) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func (q *JobQueue) Enqueue(job string) {
fmt.Println("JobQueue: " + job)
}
// CleanupTask enqueues a cleanup job on each execution.
type CleanupTask struct {
jobs *JobQueue
runs int
done chan struct{}
}
func (t *CleanupTask) Run(_ context.Context) error {
t.runs++
t.jobs.Enqueue(fmt.Sprintf("cleanup-%d", t.runs))
if t.runs >= 3 {
close(t.done)
}
return nil
}
// The README "Example logs" block copies the output of this example, keep them in sync.
func main() {
jobs := &JobQueue{}
done := make(chan struct{})
cleanup := &CleanupTask{jobs: jobs, done: done}
m := runnable.NewManager()
m.RegisterService(jobs)
m.RegisterProcess(runnable.Schedule(cleanup, runnable.Every(500*time.Millisecond)))
m.RegisterProcess(runnable.Named("app", runnable.Func(func(_ context.Context) error {
<-done
return nil
})))
runnable.Run(m)
}
Output: level=INFO msg="manager/JobQueue: started" level=INFO msg="manager/schedule/CleanupTask: started" level=INFO msg="manager/app: started" JobQueue: cleanup-1 JobQueue: cleanup-2 JobQueue: cleanup-3 level=INFO msg="manager/app: stopped" level=INFO msg="manager: starting shutdown" reason="app completed" level=INFO msg="manager/schedule/CleanupTask: stopped" level=INFO msg="manager/JobQueue: stopped" level=INFO msg="manager: shutdown complete"
Index ¶
- Variables
- func ExponentialBackoff(base, maxDelay time.Duration) func(errors int) time.Duration
- func NameFromContext(ctx context.Context) string
- func Run(runner Runnable)
- func RunFunc(fn RunnableFunc)
- func RunGroup(runners ...Runnable)
- func SetLogger(l *slog.Logger)
- type ErrorOption
- type Manager
- type ManagerOption
- type PanicError
- type RestartOption
- type RetryOption
- type Runnable
- func Closer(c interface{ ... }) Runnable
- func CloserCtx(c interface{ ... }) Runnable
- func CloserCtxErr(c interface{ ... }) Runnable
- func CloserErr(c interface{ ... }) Runnable
- func Delay(d time.Duration, r Runnable) Runnable
- func Func(fn RunnableFunc) Runnable
- func GRPCServer(addr string, s interface{ ... }, opts ...ServerOption) Runnable
- func HTTPServer(s *http.Server, opts ...ServerOption) Runnable
- func Named(name string, r Runnable) Runnable
- func Noop() Runnable
- func Recover(runnable Runnable) Runnable
- func Restart(runnable Runnable, opts ...RestartOption) Runnable
- func Retry(runnable Runnable, opts ...RetryOption) Runnable
- func Schedule(runnable Runnable, spec ScheduleSpec, opts ...ScheduleOption) Runnable
- func Signal(runnable Runnable, signals ...os.Signal) Runnable
- func Timeout(d time.Duration, r Runnable) Runnable
- type RunnableFunc
- type ScheduleOption
- type ScheduleSpec
- type ServerOption
Examples ¶
- Package
- Closer
- Closer (Manager)
- CloserCtx
- CloserCtxErr
- CloserErr
- Cron
- DailyAt
- Delay
- Every
- ExponentialBackoff
- Func
- Func (Worker)
- GRPCServer
- HTTPServer
- HTTPServer (Error)
- HTTPServer (Manager)
- HTTPServer (Options)
- Hourly
- HourlyAt
- NameFromContext
- Named
- NewManager
- NewManager (Failure)
- NewManager (Nested)
- NewManager (Options)
- Noop
- Noop (ServicesOnly)
- Recover
- Recover (PanicError)
- Restart
- Restart (ErrorLimit)
- Restart (Options)
- Retry
- Run
- RunFunc
- RunGroup
- Schedule
- ScheduleContinueOnError
- Signal
- Signal (Signals)
- Timeout
Constants ¶
This section is empty.
Variables ¶
var ErrShutdownTimeout = errors.New("still running after shutdown timeout")
ErrShutdownTimeout is reported by Manager for each runnable still running when the timeout of its shutdown phase expires.
Functions ¶
func ExponentialBackoff ¶ added in v1.2.0
ExponentialBackoff returns a backoff for ErrorBackoff that doubles the delay with each consecutive error, from base up to maxDelay: base, 2·base, 4·base, and so on.
For example, with a base of 1s and a max of 1m, the delays for the 1st to 8th consecutive errors are 1s, 2s, 4s, 8s, 16s, 32s, 1m and 1m:
runnable.ErrorBackoff(runnable.ExponentialBackoff(time.Second, time.Minute))
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
backoff := runnable.ExponentialBackoff(time.Second, 10*time.Second)
for errors := range 5 {
fmt.Println(backoff(errors + 1))
}
}
Output: 1s 2s 4s 8s 10s
func NameFromContext ¶ added in v1.1.0
NameFromContext returns the full name assigned to the running runnable by its parents, such as "manager/restart/JobQueue". Only Manager and Named assign a name, other wrappers pass it through. Returns an empty string when unset.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
var name string
job := runnable.Func(func(ctx context.Context) error {
name = runnable.NameFromContext(ctx)
return nil
})
m := runnable.NewManager()
m.RegisterProcess(runnable.Named("job", job))
_ = runnable.Named("app", m).Run(context.Background())
fmt.Println(name)
}
Output: level=INFO msg="app/job: started" level=INFO msg="app/job: stopped" level=INFO msg="app: starting shutdown" reason="job completed" level=INFO msg="app: shutdown complete" app/job
func Run ¶ added in v0.4.0
func Run(runner Runnable)
Run runs a runnable until it returns or the process receives SIGINT or SIGTERM. On any error other than context.Canceled, it logs the error with the logger set by SetLogger and exits the process with status 1, without running deferred functions. It is intended as a main helper.
Example ¶
package main
import (
"context"
"github.com/pior/runnable"
)
// Mailer is a service that sends queued emails.
type Mailer struct{}
func (*Mailer) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
// Run is meant for main: it stops on SIGINT or SIGTERM and exits the process
// with status 1 on error, after logging it.
m := runnable.NewManager()
m.RegisterService(&Mailer{})
m.RegisterProcess(runnable.Named("app", runnable.Func(func(context.Context) error {
return nil
})))
runnable.Run(m)
}
Output: level=INFO msg="manager/Mailer: started" level=INFO msg="manager/app: started" level=INFO msg="manager/app: stopped" level=INFO msg="manager: starting shutdown" reason="app completed" level=INFO msg="manager/Mailer: stopped" level=INFO msg="manager: shutdown complete"
func RunFunc ¶ added in v0.5.0
func RunFunc(fn RunnableFunc)
RunFunc is Run for a function.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
runnable.RunFunc(func(context.Context) error {
fmt.Println("hello")
return nil
})
}
Output: hello
func RunGroup ¶ added in v0.1.0
func RunGroup(runners ...Runnable)
RunGroup runs the runnables as processes of a Manager under Run.
Example ¶
package main
import (
"context"
"github.com/pior/runnable"
)
func main() {
// RunGroup runs the runnables as processes of a manager, under Run.
runnable.RunGroup(
runnable.Named("migrate", runnable.Func(func(context.Context) error { return nil })),
)
}
Output: level=INFO msg="manager/migrate: started" level=INFO msg="manager/migrate: stopped" level=INFO msg="manager: starting shutdown" reason="migrate completed" level=INFO msg="manager: shutdown complete"
func SetLogger ¶ added in v0.9.0
SetLogger replaces the default logger with a *slog.Logger. Passing nil resets to slog.Default.
Types ¶
type ErrorOption ¶ added in v1.1.0
type ErrorOption interface {
RestartOption
RetryOption
}
ErrorOption configures how Restart and Retry handle errors.
func ErrorBackoff ¶ added in v1.1.0
func ErrorBackoff(fn func(errors int) time.Duration) ErrorOption
ErrorBackoff sets the function that determines the delay before running again after an error. It receives the current consecutive error count (starting at 1). The default backs off: immediate for the first 3 errors, 10s up to 10, then 1m.
func ErrorLimit ¶ added in v1.1.0
func ErrorLimit(n int) ErrorOption
ErrorLimit sets the maximum number of consecutive errors. When reached, the last error is returned. Zero means unlimited (the default).
func ErrorResetAfter ¶ added in v1.1.0
func ErrorResetAfter(d time.Duration) ErrorOption
ErrorResetAfter resets the consecutive error count when a single run lasted at least the given duration before failing. This prevents a long-running runnable that occasionally fails from accumulating stale errors into the backoff and the limit. Zero means never reset based on duration (the default).
func OnError ¶ added in v1.1.1
func OnError(fn func(err error)) ErrorOption
OnError sets a function called with each error of the runnable, including the last one when ErrorLimit is reached. A recovered panic is a *PanicError. It is not called when the run stops on context cancellation.
type Manager ¶ added in v0.4.0
type Manager struct {
// contains filtered or unexported fields
}
Manager coordinates the lifecycle of multiple runnables. Build it with NewManager.
Runnables are organized in two tiers: processes (foreground work) and services (infrastructure like databases or queues). Shutdown is triggered when the context is cancelled or any runnable completes. During shutdown, processes are cancelled first, then services, ensuring services remain available while processes drain.
Each runnable is wrapped with Recover to catch panics. Errors from runnables are collected, except context.Canceled which is ignored. Run returns them joined with errors.Join, each wrapped with the runnable name, so they can be inspected with errors.Is and errors.As. A runnable still running when its phase timeout expires is reported with ErrShutdownTimeout. A manager is itself a Runnable, so managers can be nested for independent shutdown ordering.
Each runnable runs with its full name in the context, such as "manager/JobQueue", see NameFromContext. A nested manager uses its assigned name as prefix, and leaves the error prefix to its parent.
Registering the same runnable twice, or as both a process and a service, panics.
func NewManager ¶ added in v0.11.0
func NewManager(opts ...ManagerOption) *Manager
NewManager returns a new Manager. Its name, used as a prefix in log messages and errors, is "manager" unless a parent Manager or Named assigns one.
The shutdown phases are bounded by ProcessShutdownTimeout and ServiceShutdownTimeout. The worst case shutdown is their sum, 25 seconds by default. It must stay below a platform grace period such as Kubernetes terminationGracePeriodSeconds, 30 seconds by default, to leave room for the process to exit. For nested managers, the inner sum must stay below the timeout of the phase the inner manager is registered in.
runnable.NewManager(runnable.ProcessShutdownTimeout(40*time.Second))
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
// Database is a service that the processes depend on.
type Database struct{}
func (*Database) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
app := runnable.Named("app", runnable.Func(func(context.Context) error {
return nil // the app completes, which shuts the manager down
}))
m := runnable.NewManager()
m.RegisterService(&Database{})
m.RegisterProcess(app)
fmt.Println(m.Run(context.Background()))
}
Output: level=INFO msg="manager/Database: started" level=INFO msg="manager/app: started" level=INFO msg="manager/app: stopped" level=INFO msg="manager: starting shutdown" reason="app completed" level=INFO msg="manager/Database: stopped" level=INFO msg="manager: shutdown complete" <nil>
Example (Failure) ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
// Database is a service that the processes depend on.
type Database struct{}
func (*Database) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
worker := runnable.Named("worker", runnable.Func(func(context.Context) error {
return errors.New("connection lost")
}))
m := runnable.NewManager()
m.RegisterService(&Database{})
m.RegisterProcess(worker)
fmt.Println(m.Run(context.Background()))
}
Output: level=INFO msg="manager/Database: started" level=INFO msg="manager/worker: started" level=INFO msg="manager/worker: stopped with error" error="connection lost" level=INFO msg="manager: starting shutdown" reason="worker died" level=INFO msg="manager/Database: stopped" level=INFO msg="manager: shutdown complete" manager: worker: connection lost
Example (Nested) ¶
package main
import (
"context"
"fmt"
"log/slog"
"github.com/pior/runnable"
)
func main() {
// Both managers log concurrently, keep the logs out of the example output.
runnable.SetLogger(slog.New(slog.DiscardHandler))
defer runnable.SetLogger(nil)
// The inner manager stops its own process before its own service, while the
// outer manager keeps its service running until the inner manager stopped.
inner := runnable.NewManager()
inner.RegisterService(runnable.Named("cache", runnable.Noop()))
inner.RegisterProcess(runnable.Named("api", runnable.Noop()))
outer := runnable.NewManager()
outer.RegisterService(runnable.Named("database", runnable.Noop()))
outer.RegisterProcess(runnable.Named("web", inner))
ctx, cancel := context.WithCancel(context.Background())
cancel()
fmt.Println(outer.Run(ctx))
}
Output: <nil>
Example (Options) ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/pior/runnable"
)
// Database is a service that the processes depend on.
type Database struct{}
func (*Database) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
// stuck ignores cancellation, like a process blocked on a call without a context.
unblock := make(chan struct{})
defer close(unblock)
stuck := runnable.Named("stuck", runnable.Func(func(context.Context) error {
<-unblock
return nil
}))
m := runnable.NewManager(
runnable.ProcessShutdownTimeout(100*time.Millisecond),
runnable.ServiceShutdownTimeout(100*time.Millisecond),
)
m.RegisterService(&Database{})
m.RegisterProcess(stuck)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := m.Run(ctx)
fmt.Println(err)
fmt.Println(errors.Is(err, runnable.ErrShutdownTimeout))
}
Output: level=INFO msg="manager/Database: started" level=INFO msg="manager/stuck: started" level=INFO msg="manager: starting shutdown" reason="context cancelled" level=INFO msg="manager/stuck: still running" level=INFO msg="manager/Database: stopped" level=INFO msg="manager: shutdown complete" manager: stuck: still running after shutdown timeout true
func (*Manager) RegisterProcess ¶ added in v1.1.0
RegisterProcess registers processes. Processes are the primary runnables of the application. They are cancelled first during shutdown. Panics if any runnable is already registered. Duplicate detection only applies to comparable runnables, in practice pointers.
func (*Manager) RegisterService ¶ added in v1.1.0
RegisterService registers services. Services are infrastructure runnables (databases, queues, etc.) that processes depend on. They are cancelled after all processes have stopped. Panics if any runnable is already registered. Duplicate detection only applies to comparable runnables, in practice pointers.
func (*Manager) Run ¶ added in v1.1.0
Run starts all services and processes, waits for the context to be cancelled or for any runnable to return, then shuts down processes and services in turn. It returns nil when every runnable stopped cleanly, otherwise the joined errors of the runnables that failed or hung.
type ManagerOption ¶ added in v0.11.0
type ManagerOption interface {
// contains filtered or unexported methods
}
ManagerOption configures NewManager.
func ProcessShutdownTimeout ¶ added in v1.1.0
func ProcessShutdownTimeout(d time.Duration) ManagerOption
ProcessShutdownTimeout sets how long processes have to stop, from the start of the shutdown. Defaults to 15 seconds.
func ServiceShutdownTimeout ¶ added in v1.1.0
func ServiceShutdownTimeout(d time.Duration) ManagerOption
ServiceShutdownTimeout sets how long services have to stop, from when they are cancelled after the processes stopped. Defaults to 10 seconds.
type PanicError ¶
type PanicError struct {
// Value is the value passed to panic.
Value any
// Stack is the goroutine stack trace captured when the panic was recovered.
Stack []byte
}
PanicError is returned by Recover when the wrapped runnable panics.
func (*PanicError) Error ¶
func (e *PanicError) Error() string
func (*PanicError) Format ¶ added in v1.1.0
func (e *PanicError) Format(f fmt.State, verb rune)
Format implements fmt.Formatter: %+v prints the error followed by the stack trace, %v and %s print the error, %q prints the error quoted.
func (*PanicError) Unwrap ¶
func (e *PanicError) Unwrap() error
type RestartOption ¶ added in v0.6.0
type RestartOption interface {
// contains filtered or unexported methods
}
RestartOption configures Restart.
func RestartDelay ¶ added in v0.6.0
func RestartDelay(d time.Duration) RestartOption
RestartDelay sets the time to wait before restarting after a successful exit. Defaults to zero (immediate restart).
func RestartLimit ¶ added in v0.6.0
func RestartLimit(n int) RestartOption
RestartLimit sets the maximum number of restarts after successful (nil) exits. When reached, Restart returns nil. Zero means unlimited (the default).
type RetryOption ¶ added in v1.1.0
type RetryOption interface {
// contains filtered or unexported methods
}
RetryOption configures Retry.
type Runnable ¶
Runnable is the contract for anything that runs with a Go context and expects the caller to handle errors.
Cancellation contract: when the context is cancelled, Run must stop and return either nil or ctx.Err(), which is context.Canceled under Manager and Run. Either is fine, both are a clean stop. Any other error, including context.DeadlineExceeded, is a failure.
func Closer ¶ added in v0.4.0
func Closer(c interface{ Close() }) Runnable
Closer returns a runnable that calls Close on context cancellation. There is no timeout on the call to Close.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
type Pool struct{}
func (Pool) Close() { fmt.Println("pool closed") }
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Close is called when the context is cancelled.
fmt.Println(runnable.Closer(Pool{}).Run(ctx))
}
Output: pool closed <nil>
Example (Manager) ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
type Pool struct{}
func (Pool) Close() { fmt.Println("pool closed") }
func main() {
// As a service, the pool is closed after all processes stopped.
m := runnable.NewManager()
m.RegisterService(runnable.Closer(Pool{}))
m.RegisterProcess(runnable.Named("app", runnable.Func(func(context.Context) error {
return nil
})))
fmt.Println(m.Run(context.Background()))
}
Output: level=INFO msg="manager/closer/Pool: started" level=INFO msg="manager/app: started" level=INFO msg="manager/app: stopped" level=INFO msg="manager: starting shutdown" reason="app completed" pool closed level=INFO msg="manager/closer/Pool: stopped" level=INFO msg="manager: shutdown complete" <nil>
func CloserCtx ¶ added in v0.12.0
CloserCtx returns a runnable that calls Close on context cancellation. The context passed to Close is not cancelled, so Close can perform graceful cleanup. There is no timeout on the call to Close.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
type Tracer struct{}
func (Tracer) Close(context.Context) { fmt.Println("tracer flushed") }
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Close receives a context that is not cancelled, to finish its cleanup.
fmt.Println(runnable.CloserCtx(Tracer{}).Run(ctx))
}
Output: tracer flushed <nil>
func CloserCtxErr ¶ added in v0.12.0
CloserCtxErr returns a runnable that calls Close on context cancellation. The context passed to Close is not cancelled, so Close can perform graceful cleanup. There is no timeout on the call to Close.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
type Client struct{}
func (Client) Close(context.Context) error {
fmt.Println("client closed")
return nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
fmt.Println(runnable.CloserCtxErr(Client{}).Run(ctx))
}
Output: client closed <nil>
func CloserErr ¶ added in v0.12.0
CloserErr returns a runnable that calls Close on context cancellation. There is no timeout on the call to Close.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
type DB struct{ err error }
func (db DB) Close() error {
fmt.Println("db closed")
return db.err
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
errBusy := errors.New("connection busy")
err := runnable.CloserErr(DB{err: errBusy}).Run(ctx)
fmt.Println(err)
fmt.Println(errors.Is(err, errBusy))
}
Output: db closed close: connection busy true
func Delay ¶ added in v1.1.0
Delay returns a runnable that waits for d before running the given runnable, for example to stagger the start of jobs. Context cancellation during the wait returns the context error without running it.
runnable.Delay(30*time.Second, warmup)
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
warmup := runnable.Named("warmup", runnable.Func(func(context.Context) error {
fmt.Println("warming up")
return nil
}))
fmt.Println(runnable.Delay(10*time.Millisecond, warmup).Run(context.Background()))
}
Output: level=INFO msg="delay/warmup: waiting" delay=10ms level=INFO msg="delay/warmup: starting" warming up <nil>
func Func ¶ added in v0.5.0
func Func(fn RunnableFunc) Runnable
Func returns a Runnable from a function. Its name is the function name, as reported by runtime.FuncForPC.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
hello := runnable.Func(func(context.Context) error {
fmt.Println("hello")
return nil
})
fmt.Println(hello.Run(context.Background()))
}
Output: hello <nil>
Example (Worker) ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
jobs := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
// A worker loops until cancelled, then returns ctx.Err(): a clean stop.
worker := runnable.Func(func(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job := <-jobs:
fmt.Println("processed", job)
}
}
})
go func() {
jobs <- "a"
jobs <- "b"
cancel()
}()
fmt.Println(worker.Run(ctx))
}
Output: processed a processed b context canceled
func GRPCServer ¶ added in v1.2.0
func GRPCServer(addr string, s interface { Serve(net.Listener) error GracefulStop() Stop() }, opts ...ServerOption, ) Runnable
GRPCServer returns a runnable that runs a *grpc.Server, or any server with the same Serve, GracefulStop and Stop methods. It listens on addr unless Listener is set.
On context cancellation, it calls GracefulStop to drain in-flight calls before returning, for at most DrainTimeout, 5 seconds by default. If the drain times out, it calls Stop to close the remaining connections.
runnable.GRPCServer(":50051", grpc.NewServer(), runnable.DrainTimeout(10*time.Second))
Example ¶
package main
import (
"context"
"fmt"
"net"
"time"
"github.com/pior/runnable"
)
// GreeterServer stands in for a *grpc.Server, to keep grpc out of the examples.
type GreeterServer struct{ stopped chan struct{} }
func (s *GreeterServer) Serve(ln net.Listener) error {
<-s.stopped
return ln.Close()
}
func (s *GreeterServer) GracefulStop() { close(s.stopped) }
func (s *GreeterServer) Stop() {}
func main() {
server := &GreeterServer{stopped: make(chan struct{})} // grpc.NewServer()
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(100*time.Millisecond, cancel)
fmt.Println(runnable.GRPCServer("127.0.0.1:18081", server).Run(ctx))
}
Output: level=INFO msg="grpcserver: listening" addr=127.0.0.1:18081 level=INFO msg="grpcserver: draining" timeout=5s level=INFO msg="grpcserver: drained" <nil>
func HTTPServer ¶ added in v0.3.0
func HTTPServer(s *http.Server, opts ...ServerOption) Runnable
HTTPServer returns a runnable that runs a *http.Server.
On context cancellation, it calls http.Server.Shutdown to gracefully drain in-flight requests before returning, for at most DrainTimeout, 5 seconds by default. The server listens on http.Server.Addr unless Listener is set.
runnable.HTTPServer(server, runnable.DrainTimeout(10*time.Second))
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pior/runnable"
)
func main() {
server := &http.Server{
Addr: "127.0.0.1:18080",
Handler: http.NotFoundHandler(),
}
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(100*time.Millisecond, cancel)
fmt.Println(runnable.HTTPServer(server).Run(ctx))
}
Output: level=INFO msg="httpserver: listening" addr=127.0.0.1:18080 level=INFO msg="httpserver: draining" timeout=5s level=INFO msg="httpserver: drained" <nil>
Example (Error) ¶
package main
import (
"context"
"fmt"
"net/http"
"github.com/pior/runnable"
)
func main() {
server := &http.Server{
Addr: "INVALID",
Handler: http.NotFoundHandler(),
}
fmt.Println(runnable.HTTPServer(server).Run(context.Background()))
}
Output: listen tcp: address INVALID: missing port in address
Example (Manager) ¶
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pior/runnable"
)
// Cache is a service that the server depends on.
type Cache struct{}
func (*Cache) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
server := &http.Server{
Addr: "127.0.0.1:18080",
Handler: http.NotFoundHandler(),
}
// The manager stops the server first, draining in-flight requests, then the
// job queue it depends on.
m := runnable.NewManager()
m.RegisterService(&Cache{})
m.RegisterProcess(runnable.HTTPServer(server))
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(100*time.Millisecond, cancel)
fmt.Println(m.Run(ctx))
}
Output: level=INFO msg="manager/Cache: started" level=INFO msg="manager/httpserver: started" level=INFO msg="manager/httpserver: listening" addr=127.0.0.1:18080 level=INFO msg="manager: starting shutdown" reason="context cancelled" level=INFO msg="manager/httpserver: draining" timeout=5s level=INFO msg="manager/httpserver: drained" level=INFO msg="manager/httpserver: stopped" level=INFO msg="manager/Cache: stopped" level=INFO msg="manager: shutdown complete" <nil>
Example (Options) ¶
package main
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"time"
"github.com/pior/runnable"
)
func main() {
// The listening address is random, keep it out of the example output.
runnable.SetLogger(slog.New(slog.DiscardHandler))
defer runnable.SetLogger(nil)
// Listener serves on a listener opened by the caller: port 0 in tests, a unix
// socket, a TLS listener, or a socket passed by the service manager.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "hello")
}),
}
r := runnable.HTTPServer(server,
runnable.Listener(ln),
runnable.DrainTimeout(10*time.Second),
)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error)
go func() { done <- r.Run(ctx) }()
resp, err := http.Get("http://" + ln.Addr().String())
if err != nil {
panic(err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
fmt.Println(string(body))
cancel()
fmt.Println(<-done)
}
Output: hello <nil>
func Named ¶ added in v1.1.0
Named returns a runnable that runs r under the given name. The name is used by a parent Manager for its log lines and errors, and when no parent assigns one, it is passed to r through the context (see NameFromContext).
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
func main() {
// A function is named after its Go symbol, Named gives it a readable name
// for log lines and errors.
api := runnable.Named("api", runnable.Func(func(context.Context) error {
return errors.New("port in use")
}))
m := runnable.NewManager()
m.RegisterProcess(api)
fmt.Println(m.Run(context.Background()))
}
Output: level=INFO msg="manager/api: started" level=INFO msg="manager/api: stopped with error" error="port in use" level=INFO msg="manager: starting shutdown" reason="api died" level=INFO msg="manager: shutdown complete" manager: api: port in use
func Noop ¶ added in v0.13.0
func Noop() Runnable
Noop returns a runnable that does nothing and returns when the context is cancelled.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Noop blocks until the context is cancelled.
fmt.Println(runnable.Noop().Run(ctx))
}
Output: context canceled
Example (ServicesOnly) ¶
package main
import (
"context"
"fmt"
"time"
"github.com/pior/runnable"
)
// Metrics is a service that exports metrics.
type Metrics struct{}
func (*Metrics) Run(ctx context.Context) error {
<-ctx.Done()
return nil
}
func main() {
// A manager shuts down when any runnable returns. Noop is a process that
// never does, so a manager of services runs until it is cancelled.
m := runnable.NewManager()
m.RegisterService(&Metrics{})
m.RegisterProcess(runnable.Named("idle", runnable.Noop()))
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(10*time.Millisecond, cancel)
fmt.Println(m.Run(ctx))
}
Output: level=INFO msg="manager/Metrics: started" level=INFO msg="manager/idle: started" level=INFO msg="manager: starting shutdown" reason="context cancelled" level=INFO msg="manager/idle: stopped" level=INFO msg="manager/Metrics: stopped" level=INFO msg="manager: shutdown complete" <nil>
func Recover ¶
Recover returns a runnable that recovers a panic in the given runnable and returns it as a *PanicError. Manager wraps every registered runnable with it, so it is only needed on runnables run directly.
Example ¶
package main
import (
"context"
"fmt"
"github.com/pior/runnable"
)
func main() {
r := runnable.Recover(runnable.Func(func(context.Context) error {
panic("boom")
}))
fmt.Println(r.Run(context.Background()))
}
Output: runnable panicked: boom
Example (PanicError) ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
func main() {
r := runnable.Recover(runnable.Func(func(context.Context) error {
panic(errors.New("nil map"))
}))
err := r.Run(context.Background())
// PanicError carries the panic value and the stack, printed by %+v.
if pe, ok := errors.AsType[*runnable.PanicError](err); ok {
fmt.Println("panic value:", pe.Value)
fmt.Println("has stack:", len(pe.Stack) > 0)
}
}
Output: panic value: nil map has stack: true
func Restart ¶
func Restart(runnable Runnable, opts ...RestartOption) Runnable
Restart returns a runnable that keeps running the given runnable, restarting it after both successful exits and errors. Panics are recovered and treated as errors.
On successful exit, the runnable is restarted after RestartDelay. On error, it is restarted after the backoff of ErrorBackoff. The error count tracks consecutive errors and resets to zero after any successful run, or after a long enough run with ErrorResetAfter.
It loops indefinitely unless limited by RestartLimit or ErrorLimit. When the restart limit is reached, Run returns nil. When the error limit is reached, Run returns the last error. Errors that are restarted are logged. OnError is called with each error. Context cancellation stops the loop and returns context.Canceled.
runnable.Restart(worker, runnable.ErrorLimit(5), runnable.ErrorResetAfter(time.Minute))
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// By default, Restart restarts forever, after success and after errors.
runs := 0
worker := runnable.Named("worker", runnable.Func(func(context.Context) error {
runs++
if runs == 3 {
cancel()
}
if runs == 2 {
return errors.New("failed")
}
return nil
}))
fmt.Println(runnable.Restart(worker).Run(ctx))
}
Output: level=INFO msg="restart/worker: starting" restart=0 errors=0 level=INFO msg="restart/worker: starting" restart=1 errors=0 level=INFO msg="restart/worker: failed, restarting" error=failed errors=1 delay=0s level=INFO msg="restart/worker: starting" restart=2 errors=1 context canceled
Example (ErrorLimit) ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/pior/runnable"
)
func main() {
connect := runnable.Named("connect", runnable.Func(func(context.Context) error {
return errors.New("connection refused")
}))
fmt.Println(runnable.Restart(connect, runnable.ErrorLimit(3)).Run(context.Background()))
}
Output: level=INFO msg="restart/connect: starting" restart=0 errors=0 level=INFO msg="restart/connect: failed, restarting" error="connection refused" errors=1 delay=0s level=INFO msg="restart/connect: starting" restart=1 errors=1 level=INFO msg="restart/connect: failed, restarting" error="connection refused" errors=2 delay=0s level=INFO msg="restart/connect: starting" restart=2 errors=2 level=INFO msg="restart/connect: not restarting" reason="error limit" limit=3 connection refused
Example (Options) ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
runs := 0
worker := runnable.Named("worker", runnable.Func(func(context.Context) error {
runs++
if runs <= 2 {
return errors.New("failed")
}
return nil
}))
r := runnable.Restart(worker,
// Stop after 2 restarts following a successful run, errors do not count.
runnable.RestartLimit(2),
runnable.RestartDelay(10*time.Millisecond),
// Give up after 5 consecutive errors, returning the last one.
runnable.ErrorLimit(5),
runnable.ErrorBackoff(func(errors int) time.Duration {
return time.Duration(errors) * 10 * time.Millisecond
}),
// A run lasting a minute before failing resets the error count.
runnable.ErrorResetAfter(time.Minute),
)
fmt.Println(r.Run(context.Background()))
}
Output: level=INFO msg="restart/worker: starting" restart=0 errors=0 level=INFO msg="restart/worker: failed, restarting" error=failed errors=1 delay=10ms level=INFO msg="restart/worker: starting" restart=1 errors=1 level=INFO msg="restart/worker: failed, restarting" error=failed errors=2 delay=20ms level=INFO msg="restart/worker: starting" restart=2 errors=2 level=INFO msg="restart/worker: starting" restart=3 errors=0 level=INFO msg="restart/worker: starting" restart=4 errors=0 level=INFO msg="restart/worker: not restarting" reason="restart limit" limit=2 <nil>
func Retry ¶ added in v1.1.0
func Retry(runnable Runnable, opts ...RetryOption) Runnable
Retry returns a runnable that runs the given runnable again after each error, until it succeeds. Panics are recovered and treated as errors.
On error, the runnable runs again after the backoff of ErrorBackoff, and Retry logs the error. It retries indefinitely unless limited by ErrorLimit: when the limit is reached, Run returns the last error. ErrorResetAfter resets the error count after a long enough run. OnError is called with each error. Context cancellation stops the loop and returns the context error.
Unlike Restart, a successful run is not restarted: Run returns nil.
runnable.Retry(migrate, runnable.ErrorLimit(5))
Example ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
// A migration that fails until the database is up.
runs := 0
migrate := runnable.Named("migrate", runnable.Func(func(context.Context) error {
runs++
if runs < 3 {
return errors.New("connection refused")
}
fmt.Println("migrated")
return nil
}))
r := runnable.Retry(migrate,
runnable.ErrorLimit(5),
runnable.ErrorBackoff(func(int) time.Duration { return 10 * time.Millisecond }),
)
fmt.Println(r.Run(context.Background()))
}
Output: level=INFO msg="retry/migrate: failed, retrying" error="connection refused" errors=1 delay=10ms level=INFO msg="retry/migrate: failed, retrying" error="connection refused" errors=2 delay=10ms migrated <nil>
func Schedule ¶ added in v1.0.0
func Schedule(runnable Runnable, spec ScheduleSpec, opts ...ScheduleOption) Runnable
Schedule returns a runnable that runs the given runnable according to spec.
If an execution outlasts the interval, missed ticks are skipped (not queued). On error from the inner runnable, Schedule stops and returns the error, unless ScheduleContinueOnError is set. On context cancellation, returns context.Canceled.
For example:
runnable.Schedule(cleanup, runnable.DailyAt(3, 0))
To run at several times, use several schedules, or a Cron schedule. For custom scheduling logic, pass a ScheduleSpec function directly, or adapt any type with a Next method using Cron. For example, to use github.com/robfig/cron/v3:
sched, _ := cron.ParseStandard("15 */6 * * *") // every 6h at :15
runnable.Schedule(worker, runnable.Cron(sched))
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
runs := 0
cleanup := runnable.Func(func(context.Context) error {
runs++
fmt.Println("cleanup", runs)
if runs == 3 {
cancel()
}
return nil
})
fmt.Println(runnable.Schedule(cleanup, runnable.Every(10*time.Millisecond)).Run(ctx))
}
Output: cleanup 1 cleanup 2 cleanup 3 context canceled
func Signal ¶
Signal returns a runnable that runs the given runnable and cancels its context when the process receives one of the signals, syscall.SIGINT and syscall.SIGTERM by default. It stops listening when Run returns, and after the first signal so that a second one terminates the process.
Example ¶
package main
import (
"context"
"fmt"
"os"
"syscall"
"github.com/pior/runnable"
)
// sendSignal returns a runnable that sends sig to its own process, like a user
// pressing Ctrl-C or Kubernetes stopping a pod, then waits for the cancellation.
func sendSignal(sig os.Signal) runnable.Runnable {
return runnable.Named("app", runnable.Func(func(ctx context.Context) error {
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(sig)
<-ctx.Done()
return ctx.Err()
}))
}
func main() {
// Signal cancels the context on SIGINT or SIGTERM. Run already does it.
r := runnable.Signal(sendSignal(syscall.SIGTERM))
fmt.Println(r.Run(context.Background()))
}
Output: level=INFO msg="signal/app: received signal" signal=terminated context canceled
Example (Signals) ¶
package main
import (
"context"
"fmt"
"os"
"syscall"
"github.com/pior/runnable"
)
// sendSignal returns a runnable that sends sig to its own process, like a user
// pressing Ctrl-C or Kubernetes stopping a pod, then waits for the cancellation.
func sendSignal(sig os.Signal) runnable.Runnable {
return runnable.Named("app", runnable.Func(func(ctx context.Context) error {
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(sig)
<-ctx.Done()
return ctx.Err()
}))
}
func main() {
// Cancel the context on SIGHUP only.
r := runnable.Signal(sendSignal(syscall.SIGHUP), syscall.SIGHUP)
fmt.Println(r.Run(context.Background()))
}
Output: level=INFO msg="signal/app: received signal" signal=hangup context canceled
func Timeout ¶ added in v1.1.0
Timeout returns a runnable that cancels the given runnable when it runs for longer than d.
The result of the runnable is returned as is, so a runnable that stops cleanly with nil on the timeout succeeds. When it returns the bare context.DeadlineExceeded of the timeout, Timeout returns "timed out after <d>: context deadline exceeded" instead, which still wraps it. A cancellation of the parent context is not a timeout.
runnable.Schedule(runnable.Timeout(time.Minute, job), runnable.Every(time.Hour))
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
// A job that is stuck, and stops when cancelled.
job := runnable.Named("report", runnable.Func(func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}))
err := runnable.Timeout(100*time.Millisecond, job).Run(context.Background())
fmt.Println(err)
}
Output: level=INFO msg="timeout/report: timed out" timeout=100ms timed out after 100ms: context deadline exceeded
type RunnableFunc ¶ added in v0.5.0
RunnableFunc is a function that implements the Runnable contract.
type ScheduleOption ¶ added in v1.1.0
type ScheduleOption interface {
// contains filtered or unexported methods
}
ScheduleOption configures Schedule.
func ScheduleContinueOnError ¶ added in v1.1.0
func ScheduleContinueOnError() ScheduleOption
ScheduleContinueOnError makes Schedule log an error returned by the runnable and run it again at the next tick, instead of stopping and returning the error.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/pior/runnable"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
runs := 0
report := runnable.Named("report", runnable.Func(func(context.Context) error {
runs++
if runs == 1 {
return errors.New("database unavailable")
}
fmt.Println("report", runs)
cancel()
return nil
}))
// The first run fails: the error is logged, and the report runs again at the next tick.
r := runnable.Schedule(report, runnable.Every(10*time.Millisecond), runnable.ScheduleContinueOnError())
fmt.Println(r.Run(ctx))
}
Output: level=INFO msg="schedule/report: failed, continuing" error="database unavailable" report 2 context canceled
type ScheduleSpec ¶ added in v1.0.0
ScheduleSpec computes the next execution time given the last execution start and the current time. Interval specs like Every use lastStart to account for execution time. Clock-aligned specs like DailyAt use now.
func Cron ¶ added in v1.1.0
func Cron(s interface{ Next(time.Time) time.Time }) ScheduleSpec
Cron returns a schedule spec that delegates to s.Next(now). It adapts any schedule exposing a Next method, such as github.com/robfig/cron/v3's cron.Schedule, without depending on it.
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
// now is a fixed time for the schedule spec examples: Friday 2026-01-02 10:15.
var now = time.Date(2026, 1, 2, 10, 15, 0, 0, time.UTC)
// weekdays fires at 09:00 on weekdays. Any type with a Next method works with
// Cron, such as the schedules of github.com/robfig/cron/v3.
type weekdays struct{}
func (weekdays) Next(now time.Time) time.Time {
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location())
for !next.After(now) || next.Weekday() == time.Saturday || next.Weekday() == time.Sunday {
next = next.AddDate(0, 0, 1)
}
return next
}
func main() {
spec := runnable.Cron(weekdays{})
// Friday 10:15, the next weekday 09:00 is Monday.
fmt.Println(spec(time.Time{}, now))
}
Output: 2026-01-05 09:00:00 +0000 UTC
func DailyAt ¶ added in v1.0.0
func DailyAt(hour, minute int) ScheduleSpec
DailyAt returns a schedule spec that triggers at the given hour and minute each day.
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
// now is a fixed time for the schedule spec examples: Friday 2026-01-02 10:15.
var now = time.Date(2026, 1, 2, 10, 15, 0, 0, time.UTC)
func main() {
fmt.Println(runnable.DailyAt(18, 0)(time.Time{}, now))
fmt.Println(runnable.DailyAt(3, 0)(time.Time{}, now))
}
Output: 2026-01-02 18:00:00 +0000 UTC 2026-01-03 03:00:00 +0000 UTC
func Every ¶ added in v0.11.0
func Every(d time.Duration) ScheduleSpec
Every returns a schedule spec that triggers at regular intervals, accounting for execution time. If the runnable takes longer than the interval, the next execution starts immediately (missed ticks are skipped, not queued).
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
// now is a fixed time for the schedule spec examples: Friday 2026-01-02 10:15.
var now = time.Date(2026, 1, 2, 10, 15, 0, 0, time.UTC)
func main() {
spec := runnable.Every(time.Hour)
lastStart := now.Add(-10 * time.Minute)
fmt.Println(spec(lastStart, now))
// A run that outlasted the interval is followed immediately.
lastStart = now.Add(-2 * time.Hour)
fmt.Println(spec(lastStart, now))
}
Output: 2026-01-02 11:05:00 +0000 UTC 2026-01-02 10:15:00 +0000 UTC
func Hourly ¶ added in v1.0.0
func Hourly() ScheduleSpec
Hourly returns a schedule spec that triggers at the top of every hour (:00).
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
// now is a fixed time for the schedule spec examples: Friday 2026-01-02 10:15.
var now = time.Date(2026, 1, 2, 10, 15, 0, 0, time.UTC)
func main() {
fmt.Println(runnable.Hourly()(time.Time{}, now))
}
Output: 2026-01-02 11:00:00 +0000 UTC
func HourlyAt ¶ added in v1.0.0
func HourlyAt(minute int) ScheduleSpec
HourlyAt returns a schedule spec that triggers at the given minute past each hour.
Example ¶
package main
import (
"fmt"
"time"
"github.com/pior/runnable"
)
// now is a fixed time for the schedule spec examples: Friday 2026-01-02 10:15.
var now = time.Date(2026, 1, 2, 10, 15, 0, 0, time.UTC)
func main() {
fmt.Println(runnable.HourlyAt(30)(time.Time{}, now))
fmt.Println(runnable.HourlyAt(5)(time.Time{}, now))
}
Output: 2026-01-02 10:30:00 +0000 UTC 2026-01-02 11:05:00 +0000 UTC
type ServerOption ¶ added in v1.1.0
type ServerOption interface {
// contains filtered or unexported methods
}
ServerOption configures HTTPServer and GRPCServer.
func DrainTimeout ¶ added in v1.1.0
func DrainTimeout(d time.Duration) ServerOption
DrainTimeout sets the maximum time allowed for the graceful shutdown of a HTTPServer or GRPCServer. Defaults to 5 seconds. Under a Manager, keep it below ProcessShutdownTimeout.
func Listener ¶ added in v1.1.0
func Listener(ln net.Listener) ServerOption
Listener makes a HTTPServer or GRPCServer accept connections on ln instead of listening on its address. Use it to listen on port 0 in tests, on a unix socket, on a TLS listener, or on a socket passed by the service manager (socket activation). The server takes ownership of ln and closes it on shutdown.