Documentation
¶
Overview ¶
Package beat schedules one job.Runner in a long-lived process. Attempts are sequential and never overlap. beat suits frequent polling with reusable process state; rare or calendar work belongs in a Kubernetes CronJob.
The work, its middleware and its timeout live in the Runner, so the same attempt can also be driven by a one-shot process. beat adds only the schedule.
Scheduling ¶
ModeFixedRate, the default, places runs on an absolute grid of Period since the Unix epoch, shifted by an offset. Cadence does not depend on run duration, and a run that outlives its period reports the points it covered as Record.Missed rather than queueing them. ModeFixedDelay measures Period from the end of each run; only fixed rate preserves a shared grid.
The grid is anchored in UTC, so there is no time zone and no daylight saving. A point already past is served at once - one late run, no catch-up queue.
Config.Jitter draws an offset in [0, Jitter) once per process; WithOffset sets one outright. It may reach Period. Because the grid is absolute, the offset is the only thing separating replicas, and it holds for the life of the process. It spreads load and guards nothing: queue claiming and idempotency belong to the application.
WithBackoff sets a minimum pause after the complete attempt, including any job.ErrorHandler. The pause is outside timeouts and Result.Duration. It cannot shorten the configured schedule, and the points it holds back are not counted as losses.
Running in several clusters ¶
WithAssignment adds an optional job/assignment rotation: one cluster owns each grid point and all of its replicas run it, while the others skip. It is off by default, needs ModeFixedRate, and must share Config.Period. A point another cluster owns produces no run, no Record and no backoff, and is not a Missed point; WithDecisionHandler observes those decisions. With N clusters Record.LocalPeriod is N*Period - the spacing a handler should measure a run against. An owner that is down simply leaves its points unserved.
Records ¶
Handler receives a Record after each completed attempt. Record.Result is what the work reported - see job.Result - and everything beside it describes the schedule. Result.Outcome is authoritative even when Result.Err is nil.
Missed carries previously computed losses of this process's own points, excluding intentional backoff and points owned elsewhere. It travels with the next completed attempt, so the final losses can go unreported. A single Record cannot say why a point was lost; successive records can.
Lifecycle ¶
MakeBeat builds a scheduler; 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 attempt, or use WithGracefulStop to drain within Stop's context.
Whether a new attempt may begin is decided under the same mutex Stop moves the lifecycle state with, so a graceful stop lets an attempt already accepted finish and refuses every later one.
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 loop activity has ended, not when OnStop has completed; it also closes when a loop ends by itself, and Stop then reports why. 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 a concurrent Stop already owns it. All hooks run synchronously and must respect their contexts.
Integration ¶
The beatfx package connects the scheduler to Fx. job/middleware/recovery catches panics in the wrapped work as job.PanicError; panics elsewhere - Handler, hooks, backoff, child goroutines - propagate.
beat provides no calendar scheduling, persistence, replay after downtime, or delivery guarantee for business work. See the README for usage examples.
Index ¶
- Variables
- func OffsetFor(id string, max time.Duration) time.Duration
- type Beat
- type Config
- type Handler
- type HandlerFunc
- type Mode
- type Option
- func WithAssignment(r *assignment.Rotation) Option
- func WithBackoff(fn func(Record) time.Duration) Option
- func WithDecisionHandler(fn func(assignment.Decision)) Option
- func WithGracefulStop() Option
- func WithHandler(h Handler) 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 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 Stop returned before the run did: the run // was cancelled, but had not returned by the time the stop deadline // elapsed. The OnStop hook 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"
"github.com/uchaloop/job"
)
func work(ctx context.Context) (int64, error) {
timer := time.NewTimer(20 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
return 1, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
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 := job.MakeRunner(job.Config{}, work)
if err != nil {
panic(err)
}
scheduler, err := beat.MakeBeat(cfg, runner, beat.WithOffset(offset))
if err != nil {
panic(err)
}
_ = scheduler
}
Output:
Types ¶
type Beat ¶
type Beat struct {
// contains filtered or unexported fields
}
Beat runs one job.Runner on a schedule. Build it with MakeBeat and drive it with Start and Stop; the separate beatfx module does that from the Fx lifecycle.
It keeps two independent contexts: loopCtx gates scheduling (cancelled to stop launching new runs) and workCtx is the parent of 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 around runner. runner is required and carries the work, its middleware and its timeout; handler may be supplied with WithHandler, and a no-op is used without it.
Drive the returned Beat with Start and Stop; the separate beatfx module does this from the Fx lifecycle.
Example ¶
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/uchaloop/beat"
"github.com/uchaloop/job"
"github.com/uchaloop/job/middleware/recovery"
)
func work(ctx context.Context) (int64, error) {
timer := time.NewTimer(20 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
return 1, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
shutdown, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// The work, its middleware and its timeout belong to the runner, so the
// same attempt can also be driven by a one-shot process.
runner, err := job.MakeRunner(
job.Config{Timeout: 2 * time.Second},
work,
job.WithMiddleware(recovery.Middleware()),
)
if err != nil {
slog.Error("configure runner", "error", err)
return
}
handler := beat.HandlerFunc(func(_ context.Context, r beat.Record) {
slog.Info("attempt completed",
"outcome", r.Result.Outcome,
"processed", r.Result.Processed,
"duration", r.Result.Duration,
"missed", r.Missed,
)
})
scheduler, err := beat.MakeBeat(
beat.Config{Period: 5 * time.Second, Jitter: time.Second},
runner,
beat.WithHandler(handler),
beat.WithGracefulStop(),
)
if err != nil {
slog.Error("configure beat", "error", err)
return
}
if err := scheduler.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 := scheduler.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 work is in flight - or, for a Beat that never started, as soon as it is stopped.
It closes for two reasons: a Stop you called, and a loop that ended by itself after an error it cannot recover from. Stop returns on its own deadline and may return first, because a job timeout cancels an attempt's context but cannot interrupt it. Watch this to learn that the run finally ended - so a resource the work was still using can be released - or that a loop stopped without being asked to, in which case Stop reports why.
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 work that is still going would race it for the very resources the hook exists to release. Beat.Done reports when the run finally ends.
A loop that stopped by itself - a cluster rotation that could not decide a point - leaves its error here, joined with whatever the hook reports.
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.
Do not call Stop synchronously from the work, handlers, backoff callback or lifecycle hooks: it would wait for the calling callback itself to return. Signal the application's lifecycle owner instead, then return from the callback.
type Config ¶
type Config struct {
// Period is how often the work 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" envDescription:"Interval between scheduled runs; must be greater than zero."`
// 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 `` /* 146-byte string literal not displayed */
}
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.
The timeout of one attempt is not here: it belongs to the job.Runner beat drives, and lives in job.Config, so the same work carries the same bound whether a scheduler or a one-shot process runs it. Register with the explicit instance name "beat" to read BEAT_*.
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 Mode ¶
type Mode string
Mode is the timing policy of the Beat that produced a run.
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, and what lets an // optional cluster rotation name an owner for each point. // // 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, an offset only delays the // first run, and a cluster rotation cannot be used: replicas have no shared // numbering to rotate over. ModeFixedDelay Mode = "fixed_delay" )
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option customizes a Beat built by MakeBeat.
func WithAssignment ¶ added in v0.5.0
func WithAssignment(r *assignment.Rotation) Option
WithAssignment makes the loop ask a cluster rotation who owns each scheduled point, and skip the ones it does not own. It is off by default: without it every instance runs every point, which is what a deployment scaling for throughput wants.
It requires ModeFixedRate - replicas have no shared numbering under ModeFixedDelay - and the rotation's period must match Config.Period, since they describe the same grid. Both are checked by MakeBeat.
A point owned by another cluster produces no run, no Record and no backoff, and is not counted as Record.Missed: it was never this process's to serve. Use WithDecisionHandler to observe those points.
The rotation names an owner; it does not guarantee the owner runs. A cluster that is down simply leaves its points unserved - no other cluster takes over, because that would need shared state the policy deliberately does not have.
Example ¶
package main
import (
"context"
"log/slog"
"os"
"time"
"github.com/uchaloop/beat"
"github.com/uchaloop/job"
"github.com/uchaloop/job/assignment"
)
func work(ctx context.Context) (int64, error) {
timer := time.NewTimer(20 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
return 1, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
// One cluster owns each point; every replica of that cluster runs it. With
// three clusters on a five-minute period each one attempts every fifteen
// minutes, and the deployment as a whole every five.
rotation, err := assignment.MakeRotation(assignment.Config{
Clusters: []string{"el", "xc", "dm"},
Current: os.Getenv("CLUSTER"),
Period: 5 * time.Minute,
})
if err != nil {
panic(err)
}
runner, err := job.MakeRunner(job.Config{Timeout: 4 * time.Minute}, work)
if err != nil {
panic(err)
}
scheduler, err := beat.MakeBeat(
beat.Config{Period: 5 * time.Minute, Jitter: 30 * time.Second},
runner,
beat.WithAssignment(rotation),
beat.WithDecisionHandler(func(d assignment.Decision) {
slog.Debug("scheduled point", "slot", d.Slot, "owner", d.Owner, "ours", d.Execute)
}),
)
if err != nil {
panic(err)
}
_ = scheduler
}
Output:
func WithBackoff ¶ added in v0.4.0
WithBackoff pauses the loop after a run, for the duration fn reports from the Record of that run. Returning 0 - or not setting it at all - leaves the schedule as configured.
beat.WithBackoff(func(r beat.Record) time.Duration {
if r.Result.Processed == 0 {
return 30 * time.Second // the queue was empty, come back later
}
return 0
})
It sets a floor rather than a delay: the next run starts no earlier than the pause allows and no earlier than the schedule says, whichever is later. Under ModeFixedRate a pause that outlives the next grid points simply moves the run past them; they are not counted as Record.Missed, because a pause the application asked for is its schedule and not a loss.
The pause belongs here and not in a job.Middleware because the loop owns the timing. A pause taken inside the work would spend the job timeout, land in job.Result.Duration and be invisible to the schedule; this one does none of that. A point another cluster owns produces no Record and no backoff.
Example ¶
package main
import (
"context"
"time"
"github.com/uchaloop/beat"
"github.com/uchaloop/job"
)
func work(ctx context.Context) (int64, error) {
timer := time.NewTimer(20 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
return 1, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
runner, err := job.MakeRunner(job.Config{Timeout: 30 * time.Second}, work)
if err != nil {
panic(err)
}
// Drain while batches come back full, rest once the queue empties.
scheduler, err := beat.MakeBeat(
beat.Config{Period: time.Second},
runner,
beat.WithMode(beat.ModeFixedDelay),
beat.WithBackoff(func(r beat.Record) time.Duration {
switch {
case r.Result.Outcome != job.OutcomeOK:
return 30 * time.Second
case r.Result.Processed < 1000:
return 5 * time.Minute
}
return 0
}),
)
if err != nil {
panic(err)
}
// Call Start and Stop from the application's lifecycle.
_ = scheduler
}
Output:
func WithDecisionHandler ¶ added in v0.5.0
func WithDecisionHandler(fn func(assignment.Decision)) Option
WithDecisionHandler observes every decision the cluster rotation makes, including the ones that allow the run. It is the only way to tell a point this process skipped from one it never had, since neither produces a Record.
A decision that allows a run is not a promise that one followed. The loop decides, reports here, and only then takes the last word on whether a new attempt may begin, so a Stop landing in between leaves a reported decision with no Record behind it. The order cannot be reversed without reopening the window in which a graceful stop would start fresh work.
It runs inline on the loop and must return promptly; a panic propagates.
func WithGracefulStop ¶
func WithGracefulStop() Option
WithGracefulStop lets the in-flight run finish on shutdown instead of having its context cancelled. beat stops scheduling new runs immediately; the current run continues until it returns or the stop deadline (fx.StopTimeout, 15s unless the application raises it) elapses, at which point its context is cancelled as a last resort and Stop reports ErrStillRunning.
A job whose duration varies should be matched by a stop timeout above its worst case, or the drain only sometimes happens - and a stop that runs out of time skips the OnStop hook, which is exactly when a graceful stop was supposed to help.
func WithHandler ¶
WithHandler sets the observability Handler. It overrides a Handler provided through the Fx container.
func WithMode ¶ added in v0.4.0
WithMode selects the timing policy. The default is ModeFixedRate, which keeps a cadence on an absolute grid; ModeFixedDelay measures the period from the end of the previous run instead.
This is an option rather than a Config field because the choice follows from what the job is - a poller that should rest between runs against a scraper that should keep a rate - and not from how a particular deployment is tuned.
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 job/assignment for the cluster-level equivalent - 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 work 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 Record ¶
type Record struct {
// Result includes work and optional error processing: durations, count, errors and
// outcome. See job.Result.
Result job.Result
// Iteration is the 1-based run counter for this Beat, useful as a
// correlation id. It counts runs, not scheduled points - see Missed - and
// does not advance for a point another cluster owns.
Iteration int64
// GridPoint is the point of the shared grid this run served, before the
// per-replica offset. It is what every replica and every cluster agrees on,
// and therefore what a cluster rotation decides over. Under ModeFixedDelay
// it equals ScheduledFor, which is not a shared tick at all.
GridPoint time.Time
// ScheduledFor is the point this replica aimed at: GridPoint plus the
// offset. Result.Start minus this is how late the run actually began.
ScheduledFor time.Time
// Period is the configured spacing of the shared grid.
Period time.Duration
// LocalPeriod is the spacing of the points assigned to this process: Period
// times the number of clusters when a rotation is in use, and Period itself
// otherwise. A handler reporting how much of its schedule the work uses
// divides by this, not by Period.
LocalPeriod time.Duration
// Mode is the timing policy that produced this run.
Mode Mode
// Missed is how many scheduled points belonging to this process 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, and a point another
// cluster owns was never this process's to serve. It is always 0 under
// ModeFixedDelay, which has no grid to miss.
// Counts larger than MaxUint64 are capped at that maximum.
//
// This Record's timestamps and Result describe the current attempt, not the
// earlier work that lost the points. A single Record therefore cannot
// identify the cause - the previous attempt may have been long while this
// one is short and on time, and under a rotation other clusters' points sit
// between them. Comparing successive records can.
Missed uint64
}
Record describes a single completed run. It is handed to a Handler after each run: the scheduling around the work, plus the job.Result the work produced.
The nesting is the boundary made visible. Everything under Result belongs to the attempt and means the same in a one-shot process; everything beside it belongs to the schedule and exists only here.