Documentation
¶
Overview ¶
Package beat schedules one Job in a long-lived process. Job calls are sequential within a Beat. The package accepts configuration and emits Records; it does not load configuration, export telemetry, or coordinate replicas.
Scheduling ¶
ModeFixedRate is the default: targets lie on an absolute grid of Period since the Unix epoch, shifted by an offset. The first target is strictly after startup. After each run, unavailable points are bypassed without queuing runs. If a pending target is already past when the loop wakes, it runs once at once. ModeFixedDelay starts after the initial offset, then waits at least Period from the end of each Job. Only fixed-rate mode preserves the grid offset.
Config.Jitter draws an offset once in [0, Jitter); zero disables it. WithOffset supplies an explicit offset and OffsetFor derives one from an identity. Offsets spread starts but do not guarantee separation or ownership of work. The application owns queue claiming and idempotency.
WithBackoff sets a minimum pause from Job completion, outside JobTimeout and Record.Duration. It cannot shorten the configured schedule. Non-positive values add no pause. Backoff callbacks and Handlers execute inline.
Jobs and records ¶
Job returns a processed count and an error. JobTimeout defaults to one minute and cancels the Job context; it cannot interrupt a function. Jobs must observe cancellation and join their own goroutines before returning.
Handler receives a Record after each completed Job. Duration includes Job middleware, but not Handler or backoff. Outcome is authoritative even when Err is nil. Missed carries previously computed unintentional grid losses, excluding backoff; it is always zero in fixed-delay mode. Delivery waits for the next completed Job, so the final losses can go unreported. A single Record cannot identify their cause. Handler gets a context without cancellation or deadline; keep it short and bound any I/O separately. MultiHandler calls sinks in order.
Lifecycle ¶
MakeBeat builds a runner; Start calls OnStart and launches its loop. Start's context bounds startup only. A second Start fails; a stopped Beat cannot be restarted. Call Stop explicitly to stop scheduling and cancel the active Job, or use WithGracefulStop to drain within Stop's context. JobTimeout still applies.
Stop calls OnStop only after startup succeeded and the loop ended. If waiting expires first, it returns ErrStillRunning with the context error and skips OnStop permanently. Done closes when startup/loop activity has ended, not when OnStop has completed. Use Done for fallback cleanup only after ErrStillRunning. Concurrent Stop callers share the first result unless their own wait expires.
A failing OnStart is responsible for its partial cleanup; OnStop is not run. If OnStart succeeds but startup's context is cancelled, Start initiates cleanup with a fresh 15-second context unless concurrent Stop already owns cleanup. All hooks run synchronously and must respect their contexts.
Integration ¶
The beatfx package connects the runner to Fx. The recovery middleware catches panics in the wrapped Job call as PanicError; panics otherwise propagate. It does not recover Handler, hook, backoff, or child-goroutine panics. github.com/uchaloop/otelbeat implements Handler for OpenTelemetry metrics.
beat supports frequent polling with reusable process state. It provides no calendar scheduling, persistence, replay after downtime, or delivery guarantee for business work. See the README for diagrams and usage examples.
Index ¶
- Variables
- func OffsetFor(id string, max time.Duration) time.Duration
- type Beat
- type Config
- type Handler
- type HandlerFunc
- type Job
- type Middleware
- type Mode
- type Option
- func WithBackoff(fn func(Record) time.Duration) Option
- func WithGracefulStop() Option
- func WithHandler(h Handler) Option
- func WithMiddleware(mw ...Middleware) Option
- func WithMode(m Mode) Option
- func WithOffset(d time.Duration) Option
- func WithOnStart(h func(context.Context) error) Option
- func WithOnStop(h func(context.Context) error) Option
- type Outcome
- type PanicError
- type Record
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrAlreadyStarted reports a second Start on a Beat that is already // running. One Beat runs one loop: a second one would break the guarantee // that runs never overlap. ErrAlreadyStarted = errors.New("beat is already started") // ErrStopped reports a Start on a Beat that has been stopped. A stopped // Beat is not restartable - build a new one with MakeBeat. ErrStopped = errors.New("beat is stopped") // ErrStillRunning reports that startup or the run loop had not finished // when Stop stopped waiting. In-flight Job contexts are cancelled; an // in-progress OnStart still follows the context supplied to Start. // OnStop is not run in that case, and [Beat.Done] is how // an application learns when the run finally ends. ErrStillRunning = errors.New("stop deadline elapsed before the run finished") )
Functions ¶
func OffsetFor ¶ added in v0.4.0
OffsetFor derives an offset in [0, max) from id, so a replica keeps the same slot for as long as that id is the same, instead of drawing a new one every time it boots:
beat.WithOffset(beat.OffsetFor(os.Getenv("POD_NAME"), 30*time.Second))
The result is stable but still arbitrary, so two replicas can land close together. When the replicas are numbered - a StatefulSet, or any deployment that can tell a pod its index - spacing them by hand is strictly better, because it is the only way to guarantee a gap:
beat.WithOffset(time.Duration(ordinal) * max / time.Duration(replicas))
An empty id or a non-positive max yields 0.
Example ¶
package main
import (
"context"
"time"
"github.com/uchaloop/beat"
)
func main() {
cfg := beat.Config{Period: 5 * time.Minute, Jitter: 5 * time.Minute}
offset := beat.OffsetFor("orders/cluster-a/worker-0", cfg.Jitter)
runner, err := beat.MakeBeat(cfg,
func(ctx context.Context) (int, error) { return 0, ctx.Err() },
nil, beat.WithOffset(offset),
)
if err != nil {
panic(err)
}
_ = runner
}
Output:
Types ¶
type Beat ¶
type Beat struct {
// contains filtered or unexported fields
}
Beat is a scheduled runner for one Job. Build it with MakeBeat and drive it with Start and Stop; the beatfx subpackage does that from the Fx lifecycle.
It keeps two independent contexts: loopCtx gates scheduling (cancelled to stop launching new runs) and jobParentCtx parents each run (cancelled to abort an in-flight run). Splitting them lets stop drain - end scheduling while letting the current run finish - or cancel, depending on the graceful flag.
func MakeBeat ¶
MakeBeat resolves cfg against opts and builds a Beat. job is required; handler may be nil (a no-op handler is used). Drive the returned Beat with Start and Stop; the beatfx subpackage does this from the Fx lifecycle.
Example ¶
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/uchaloop/beat"
)
func main() {
shutdown, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
job := func(ctx context.Context) (int, error) {
// Replace this timer with one bounded batch of application work.
timer := time.NewTimer(20 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
return 1, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
handler := beat.HandlerFunc(func(_ context.Context, r beat.Record) {
slog.Info("job completed", "outcome", r.Outcome,
"processed", r.Processed, "duration", r.Duration, "missed", r.Missed)
})
runner, err := beat.MakeBeat(beat.Config{
Period: 5 * time.Second, JobTimeout: 2 * time.Second, Jitter: time.Second,
}, job, handler, beat.WithGracefulStop())
if err != nil {
slog.Error("configure beat", "error", err)
return
}
if err := runner.Start(shutdown); err != nil {
slog.Error("start beat", "error", err)
return
}
<-shutdown.Done()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer stopCancel()
if err := runner.Stop(stopCtx); err != nil {
slog.Error("stop beat", "error", err)
}
}
Output:
func (*Beat) Done ¶ added in v0.4.0
func (b *Beat) Done() <-chan struct{}
Done returns a channel that closes when the run loop has left and no Job is in flight - or, for a Beat that never started, as soon as it is stopped.
Stop returns on its own deadline and may return first, because a job timeout cancels a run's context but cannot interrupt it. This is how an application learns that the run has finally ended, so it can release, after the fact, a resource the Job was still using. Done does not wait for OnStop. Only use it for fallback cleanup after Stop returned ErrStillRunning; otherwise cleanup could overlap with OnStop.
func (*Beat) Start ¶
Start runs the OnStart hook and launches the run loop. The loop runs in its own goroutine, so Start returns once it is launched. ctx bounds the hook, and a returned error aborts startup.
It may be called once. A second call reports ErrAlreadyStarted, and a call on a stopped Beat reports ErrStopped; neither launches a second loop, which is what keeps runs from overlapping. If the hook fails the Beat is left stopped, and the OnStop hook is not run - the hooks are a pair, and the first one did not complete. If OnStart succeeds but ctx has been cancelled, Start invokes Stop with a fresh 15-second cleanup context and returns both errors. A concurrent Stop owns cleanup instead. Hooks must respect their contexts.
func (*Beat) Stop ¶
Stop ends scheduling, waits for the run loop to leave (bounded by ctx), then runs the OnStop hook. By default the in-flight run's context is cancelled at once; with graceful stop the run is left to finish and is only cancelled if it outlives ctx.
If ctx elapses while a run is still going, Stop cancels the run, returns ErrStillRunning joined with ctx's error, and does not run the OnStop hook: the hook is documented to run after the loop has stopped, and running it against a Job that is still working would race it for the very resources the hook exists to release. Beat.Done reports when the run finally ends.
Stop is safe to call more than once and from several goroutines: the first call does the work, and the others report its result without running the hook again. Stopping a Beat that was never started does nothing and reports no error. Waiting callers may return their own context error before the first Stop finishes. The deadline bounds waiting; OnStop runs synchronously and must respect ctx. Stop during OnStart waits for that hook before cleanup.
type Config ¶
type Config struct {
// Period is how often the Job runs. Under ModeFixedRate it is the spacing
// of the grid the runs sit on; under ModeFixedDelay it is the pause between
// the end of one run and the start of the next. The deployment has to
// supply it: there is no period that makes sense for every job.
Period time.Duration `env:"PERIOD,notEmpty"`
// JobTimeout cancels a run's context after this duration. Zero selects one
// minute. Cancellation is cooperative: Job must return and join its own
// goroutines. A longer timeout can allow a run to cover multiple grid points.
JobTimeout time.Duration `env:"JOB_TIMEOUT"`
// Jitter is the upper bound of the random offset that staggers replicas so
// they do not all fire at once. The draw is half-open, [0, Jitter), and
// happens once when the Beat is built, so the offset holds for the life of
// the process. Zero disables it; WithOffset replaces the draw with a value
// the application chooses.
//
// It may be as large as Period - spreading replicas over the whole period is
// what an evenly polling deployment wants. Work tied to a boundary is the
// exception: a wide jitter delays it by that much.
//
// It spreads load. It is not a guard against two replicas doing the same
// work: runs longer than the offset overlap regardless.
Jitter time.Duration `env:"JITTER"`
}
Config is the configuration beat declares. The env tags are inert strings: an application loads them with github.com/uchaloop/confmaker and provides the filled Config into the container. beat itself never reads the environment.
func (Config) ConfigName ¶ added in v0.3.2
ConfigName is the default instance name, "beat": a loader such as confmaker reads BEAT_PERIOD and the rest of BEAT_* unless the application names the instance itself.
func (*Config) SetDefaults ¶ added in v0.2.0
func (c *Config) SetDefaults()
SetDefaults sets JobTimeout to one minute. Configuration loaders call it before applying input. MakeBeat also defaults a zero JobTimeout.
type Handler ¶
Handler receives a Record after every run. It is the single observability seam of beat: metrics, logging and tracing are all built on top of it, so the core depends on none of them. It mirrors slog.Handler.
Handle runs inline on the run loop, so its time comes out of the schedule: a slow Handler delays the next run and can cost a scheduled point. It is handed a context that is deliberately not cancellable, so that the last record of a shutdown still arrives - which means a Handler that blocks holds the loop, and Stop will give up on it with ErrStillRunning. Return promptly, and give any network call a budget of its own.
func MultiHandler ¶
MultiHandler returns a Handler that passes each Record to every handler in turn, skipping nil ones. Use it to run several sinks - metrics and logging, say - behind the single Handler slot. Handlers run in order and are not isolated: the core does not recover a panicking handler, so one panic propagates and crashes the process. Keep handlers panic-free.
type HandlerFunc ¶
HandlerFunc adapts an ordinary function to a Handler, like http.HandlerFunc.
type Job ¶
Job is the unit of work beat runs on each tick. It reports how many items it processed - return 0 when the job is not item-oriented - and an error if the run failed. A Job must respect ctx: beat bounds it with the job timeout and, unless graceful stop is enabled, cancels it on shutdown.
type Middleware ¶
Middleware wraps a Job to add behaviour around it - recovery, context enrichment, extra logging. It is classic Go composition: a Middleware receives the next Job and returns a Job that calls it.
A Middleware does not decide when the next run happens: pausing inside the Job would spend the job timeout and land in Record.Duration. Use WithBackoff for that.
type Mode ¶
type Mode string
Mode selects how the start of one run relates to the next.
const ( // ModeFixedRate starts runs on an absolute grid: every Period since the // Unix epoch, shifted by the offset. The cadence does not depend on how // long a run takes, so two processes that share a Period agree on the // points without sharing anything else - which is what lets a per-replica // offset hold them apart for as long as they live. // // Runs still never overlap. A run that outlives its period covers the // points it ran through, and they are reported as Record.Missed rather // than queued for later. ModeFixedRate Mode = "fixed_rate" // ModeFixedDelay starts the next run one Period after the previous one // ended, so a slow run pushes the whole schedule back. Use it for a job // that should rest between runs rather than keep a cadence. // // There is no grid, so nothing is ever missed and an offset only delays // the first run: the phase follows the job's duration and is not held. ModeFixedDelay Mode = "fixed_delay" )
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option customizes a Beat built by MakeBeat.
func WithBackoff ¶ added in v0.4.0
WithBackoff computes a minimum pause from Job completion. The callback runs inline after Handler and must return promptly. A non-positive result adds no restriction; it does not remove Period. The next target must satisfy both the schedule and the pause. Intentionally bypassed points are not Missed. Backoff is outside JobTimeout and Record.Duration.
Example ¶
package main
import (
"context"
"time"
"github.com/uchaloop/beat"
)
func main() {
job := func(ctx context.Context) (int, error) { return 0, ctx.Err() }
runner, err := beat.MakeBeat(beat.Config{
Period: time.Second, JobTimeout: 30 * time.Second,
}, job, nil,
beat.WithMode(beat.ModeFixedDelay),
beat.WithBackoff(func(r beat.Record) time.Duration {
if r.Outcome != beat.OutcomeOK {
return 30 * time.Second
}
if r.Processed < 1000 {
return 5 * time.Minute
}
return 0
}),
)
if err != nil {
panic(err)
}
// Call runner.Start and runner.Stop from the application's lifecycle.
_ = runner
}
Output:
func WithGracefulStop ¶
func WithGracefulStop() Option
WithGracefulStop lets the active Job finish while Stop waits. JobTimeout still applies. If Stop's context expires before startup/the loop finishes, Stop cancels the Job context, returns ErrStillRunning and skips OnStop.
func WithHandler ¶
WithHandler sets the observability Handler. It overrides a Handler provided through the Fx container.
func WithMiddleware ¶
func WithMiddleware(mw ...Middleware) Option
WithMiddleware wraps the Job. The first middleware is the outermost layer and runs first.
func WithMode ¶ added in v0.4.0
WithMode selects the scheduling policy. ModeFixedRate is the default; ModeFixedDelay waits at least Period from the end of the previous Job.
func WithOffset ¶ added in v0.4.0
WithOffset sets the offset within the period explicitly, instead of drawing one below Config.Jitter. Use it to give each replica a slot that survives a restart - see OffsetFor - or to spread replicas evenly by their index. The offset must be in [0, Period).
func WithOnStart ¶
WithOnStart registers a hook run once before the loop starts. A returned error aborts startup and leaves the Beat stopped, without running the OnStop hook - the two are a pair, and this one did not complete. If the hook succeeds after the startup context is cancelled, Start arranges cleanup; see Beat.Start.
func WithOnStop ¶
WithOnStop registers a hook run once after the loop has stopped.
"After the loop has stopped" is exact: if Stop's context elapses while a run is still going, the hook does not run at all, because it would otherwise race the Job for whatever it is meant to release. Watch Beat.Done for the moment the run finally ends, after Stop returned ErrStillRunning. Done does not wait for OnStop. The hook runs synchronously and must respect its context.
type Outcome ¶ added in v0.4.0
type Outcome string
Outcome classifies how a run ended. It is the one label an observability adapter needs; Record.Err carries the detail behind it.
const ( // OutcomeOK is a run that finished and returned no error. OutcomeOK Outcome = "ok" // OutcomeError is a run that returned an error of its own. OutcomeError Outcome = "error" // OutcomePanic is a run whose panic the recovery middleware turned into a // *PanicError. Without that middleware a panic never reaches a Record - it // crashes the process. OutcomePanic Outcome = "panic" // OutcomeTimeout means the Job's context deadline expired before the // outcome was classified. It is reported only after Job returns, even if // Job returns nil. It does not imply that execution was forcibly stopped. OutcomeTimeout Outcome = "timeout" // OutcomeCanceled means the Job context was cancelled by shutdown before // the outcome was classified. It is separate from a Job-returned error. OutcomeCanceled Outcome = "canceled" )
type PanicError ¶
PanicError wraps a value recovered from a panic in the Job. The recovery middleware returns it as the run error, so a Handler can tell a panic from an ordinary error - Record.Outcome reads OutcomePanic - and inspect the recovered value and its stack. The beat core does not recover panics; without the recovery middleware a panic crashes the process (with a stack on stderr).
func (*PanicError) Error ¶
func (e *PanicError) Error() string
type Record ¶
type Record struct {
// Iteration is the 1-based run counter for this Beat, useful as a
// correlation id. It counts runs, not scheduled points - see Missed.
Iteration int64
// ScheduledFor is the point this run was aiming at. Start minus this is how
// late the run actually began.
ScheduledFor time.Time
// Start is the wall-clock time the run began.
Start time.Time
// Duration is how long the Job took. It excludes the Handler and any
// backoff, so it stays comparable with Period.
Duration time.Duration
// Period is the configured period, so a Handler can report Duration over
// Period - how close the job is to outgrowing its schedule - without being
// handed the configuration separately.
Period time.Duration
// Mode is the timing policy that produced this run.
Mode Mode
// Processed is the number of items the Job reported for this run.
Processed int
// Err is the error the Job returned, or nil on success. A panic recovered
// by the recovery middleware is reported as a *PanicError.
Err error
// Outcome classifies the run and is authoritative: a run can time out with
// a nil Err, and a *PanicError reads as OutcomePanic.
Outcome Outcome
// Missed is how many scheduled points passed without a run between the
// previous run and this one - because that run and its Handler outlasted
// them, or because the clock moved.
//
// Points the loop chose not to serve are not counted: a backoff asking it to
// wait is the schedule the application requested, not a loss, and counting
// those would make this number loudest on a healthy idle poller. It is
// always 0 under ModeFixedDelay, which has no grid to miss.
//
// This Record's timestamps and Duration describe the current run, not the
// preceding work that caused Missed. A single Record cannot identify the
// cause; comparing successive records may help diagnose it.
Missed int
}
Record describes a single completed run of the Job. It is handed to a Handler after each run, mirroring the shape of slog.Record: the library produces the data, the caller decides what to do with it (metrics, logging, tracing).
Directories
¶
| Path | Synopsis |
|---|---|
|
Package beatfx connects one beat.Beat to an Fx lifecycle.
|
Package beatfx connects one beat.Beat to an Fx lifecycle. |
|
middleware
|
|
|
recovery
Package recovery supplies middleware that converts panics in the wrapped Job call into beat.PanicError, including the recovered value and stack.
|
Package recovery supplies middleware that converts panics in the wrapped Job call into beat.PanicError, including the recovered value and stack. |