Documentation
¶
Overview ¶
Package scheduler is the scheduling scaffold shared by several containerized job runners (docker-fclones-scheduler, docker-rsync-scheduler, docker-renovate-scheduler, pg-autodump, github-scout, seadex-scout).
It provides small, orthogonal primitives — not a framework — that a composition root wires together:
- ParseInterval turns a *_INTERVAL environment value into a Schedule (a cadence plus a Mode: built-in, external, or once), applying the standard off/disabled/0 sentinel and fallback rules.
- RunLoop drives the built-in mode: a startup fire plus a jittered interval ticker that drains on context cancellation. JitteredDelay is its pure, testable core.
- TryLock / Unlock / InFlight / ReadHolder are an advisory flock(2) overlap guard so a run and an out-of-band trigger never execute two jobs at once; RerunFlag coalesces a trigger that arrives mid-run into a single rerun, and Latch is the bare single-bit cross-process marker behind it (used directly for one-off signals such as a shutdown/drain latch).
- Exclusive composes the pieces above into Renovate-style run coalescing for whole cycles: at most one cycle runs at a time across processes, a requester that finds a run in flight queues a rerun request (bounded, no blocked waiters) or skips its tick, and the runner executes the queued demand when the current run finishes.
- WaitForDrain polls the lock so a daemon can wait out an externally triggered run before exiting on shutdown.
- NewCommandRunner builds context-cancellable subprocesses that shut down gracefully (SIGTERM with a grace period before SIGKILL).
The package is deliberately silent about what a job does, how health is signaled, and how logging is configured; those belong to the consuming app (see the companion health library for the marker pattern). It carries no runtime dependencies beyond the standard library, and its flock-based primitives are Unix-only.
Index ¶
- Constants
- func InFlight(path string) (inFlight bool, err error)
- func JitteredDelay(interval time.Duration, fraction float64) time.Duration
- func ReadHolder(path string) (since time.Time, known bool)
- func RunLoop(ctx context.Context, job Job, opts LoopOptions)
- func WaitForDrain(ctx context.Context, path string, poll, maxWait time.Duration) bool
- type CommandRunner
- type Exclusive
- type ExclusiveOption
- type IntervalOption
- type Job
- type Latch
- type Lock
- type LoopOptions
- type Mode
- type Outcome
- type RerunFlag
- type Schedule
Examples ¶
Constants ¶
const ( // ExclusiveLockName is the flock(2) file serializing cycle runs. ExclusiveLockName = "cycle.lock" // ExclusiveQueueName is the counter file holding the number of queued // rerun requests. ExclusiveQueueName = "cycle.queued" )
Names of the two files Exclusive maintains inside its directory. They are exported so a consumer can point observability tooling at them — for example ReadHolder(filepath.Join(dir, ExclusiveLockName)) to report how long the current cycle has been running.
const DefaultDrainPoll = 500 * time.Millisecond
DefaultDrainPoll is a reasonable poll interval for WaitForDrain: runs take seconds to minutes, so a sub-second poll keeps the post-completion shutdown delay negligible without busy-waiting.
const DefaultGrace = 5 * time.Second
DefaultGrace is the graceful-shutdown grace period the scheduling apps use: on context cancellation the child is sent SIGTERM and given this long to exit before os/exec escalates to SIGKILL.
Variables ¶
This section is empty.
Functions ¶
func InFlight ¶
InFlight reports whether a run currently holds the lock at path. It probes with a non-blocking acquire: a free lock is taken and immediately released (not in flight); a held lock reports in flight. Because flock releases when the holding process exits, a crashed run never reports as perpetually in flight.
func JitteredDelay ¶
JitteredDelay returns a delay drawn uniformly from [interval−fraction×interval, interval+fraction×interval). It is the pure core of RunLoop's jitter, exported so the ±band can be tested directly and reused. A non-positive fraction or interval returns interval unchanged.
func ReadHolder ¶
ReadHolder reads the acquisition timestamp the current holder recorded in the lock file. known is false when the timestamp could not be read — the holder had not written it yet, the line was torn mid-write, or the file is absent — in which case since is the zero time. The value is observability-only (a contender reporting how long the holder has run) and never affects locking correctness; it is meaningful only while the lock is actually held.
func RunLoop ¶
func RunLoop(ctx context.Context, job Job, opts LoopOptions)
RunLoop runs job on a schedule until ctx is cancelled. The job runs sequentially in the loop, so two invocations never overlap in-process; guard cross-process overlap (an external trigger racing the loop) with TryLock inside the job. RunLoop blocks until ctx is cancelled and the in-flight job (if any) returns, so a caller can treat its return as a completed drain.
RunLoop covers the built-in scheduling mode only. A one-shot (ModeOnce) job is run directly by the caller; an idle (ModeExternal) container simply waits on ctx.Done. RunLoop returns immediately if Interval is not positive.
func WaitForDrain ¶
WaitForDrain blocks until no run holds the lock at path (the in-flight run finished, or its process died — flock releases on exit), or until maxWait elapses or ctx is cancelled. It returns true if the run drained and false otherwise. A failure probing the lock (an I/O error on the lock path) is treated conservatively as "not drained" and also returns false. It is the external-mode shutdown drain: when runs are triggered out-of-band (a separate docker-exec process the daemon cannot wait() on), the daemon polls the shared lock instead so a redeploy does not tear down a run mid-flight. maxWait should be one run's own maximum lifetime; the container's stop grace period is the real outer bound. A non-positive poll uses DefaultDrainPoll.
Types ¶
type CommandRunner ¶
CommandRunner constructs a configured *exec.Cmd for a context and argument vector. It decouples job orchestration from subprocess construction so tests can inject a fake runner; NewCommandRunner returns the production one.
func NewCommandRunner ¶
func NewCommandRunner(grace time.Duration) CommandRunner
NewCommandRunner returns a CommandRunner that builds a context-cancellable command with graceful shutdown: on cancellation the child is sent SIGTERM (rather than os/exec's default SIGKILL) and given grace before the SIGKILL escalation. The caller wires Stdout/Stderr on the returned command — capture them into buffers, or stream them to os.Stdout/os.Stderr — before calling Run. A non-positive grace uses DefaultGrace.
type Exclusive ¶ added in v1.2.0
type Exclusive struct {
// contains filtered or unexported fields
}
Exclusive coordinates cycle runs across processes so that at most one runs at a time, with a small queue of pending rerun requests instead of blocked waiters. It is the cross-process analogue of RerunFlag's in-run coalescing, for callers that are themselves short-lived processes (a poll subcommand exec'd by an operator or an external scheduler racing the resident daemon).
The mechanics: a runner holds a flock(2) on dir/cycle.lock for the whole job; a requester that finds the lock busy increments the counter in dir/cycle.queued and exits immediately (never blocking for the job's duration); the runner consumes the counter at job end, rerunning once per queued request until none remain. Because the lock is an flock, the kernel releases it when the holding process dies — there is no stale-lock state — and a queue counter orphaned by a crash is cleared at the next acquisition (the run about to start satisfies the demand it recorded).
Both files live in dir and are created on first use; they are never deleted (clearing the queue writes a zero count — unlinking a locked file would let a concurrent opener land on a different inode and break mutual exclusion). Place dir where untrusted local users cannot write, per the same symlink-following caveat as TryLock and Latch.
Example ¶
package main
import (
"fmt"
"log/slog"
"os"
"github.com/cplieger/scheduler"
)
func main() {
dir, err := os.MkdirTemp("", "scheduler_example_exclusive")
if err != nil {
return
}
defer func() { _ = os.RemoveAll(dir) }()
ex := scheduler.NewExclusive(dir, slog.New(slog.DiscardHandler))
outcome, _ := ex.Run(func() error {
fmt.Println("cycle ran")
return nil
})
fmt.Println("outcome:", outcome)
}
Output: cycle ran outcome: ran
func NewExclusive ¶ added in v1.2.0
func NewExclusive(dir string, log *slog.Logger, opts ...ExclusiveOption) *Exclusive
NewExclusive returns an Exclusive coordinating runs through lock and queue files inside dir (which must exist). A nil log falls back to slog.Default() at call time. Options: WithQueueCapacity.
func (*Exclusive) Pending ¶ added in v1.2.0
Pending reports the number of currently queued rerun requests. It is observability-only: the value can change the moment it is read.
func (*Exclusive) Run ¶ added in v1.2.0
Run executes job under the cycle lock, queueing the request if a run is already in flight (queue mode — for demand-driven callers such as a poll subcommand, where the caller's request must be satisfied by a run that starts after it arrived).
- Lock free: run the job now, then execute any rerun requests queued during it (OutcomeRan, or OutcomeRanQueued if reruns happened).
- Lock busy, queue below capacity: record a rerun request and return immediately (OutcomeQueued) — the active runner executes it when the current run finishes. The requester never blocks for the job's duration.
- Lock busy, queue full: drop the request (OutcomeDiscarded) — the queued rerun(s) already guarantee a run starts after this request arrived.
The returned error carries the job's own error(s) when it ran (joined across reruns), or the infrastructure error that prevented the request from being recorded (OutcomeNone). Queued and Discarded outcomes are success for the requesting process: log-and-exit-0 is the intended caller behavior.
func (*Exclusive) RunOrSkip ¶ added in v1.2.0
RunOrSkip executes job under the cycle lock, skipping when a run is already in flight (skip mode — for time-driven callers such as a RunLoop tick, where the next tick provides freshness and queueing would only pile on the process already doing the work):
scheduler.RunLoop(ctx, func(ctx context.Context) {
_, _ = ex.RunOrSkip(func() error { return runCycle(ctx) })
}, opts)
A skipped tick logs a warning (the job is overrunning its interval) and returns (OutcomeSkipped, nil). When the lock is free it behaves exactly like Run's acquired path, including executing queued rerun requests at job end.
type ExclusiveOption ¶ added in v1.2.0
type ExclusiveOption func(*Exclusive)
ExclusiveOption configures NewExclusive.
func WithQueueCapacity ¶ added in v1.2.0
func WithQueueCapacity(n int) ExclusiveOption
WithQueueCapacity sets how many rerun requests may queue while a cycle is running (the default is 1, Renovate's coalescing model: one queued rerun satisfies all demand that arrived before it starts). Each queued request is consumed by exactly one rerun, so a capacity of n allows up to n back-to-back reruns to accumulate. A value below 1 is treated as 1.
type IntervalOption ¶
type IntervalOption func(*intervalConfig)
IntervalOption configures ParseInterval.
func WithBounds ¶
func WithBounds(low, high time.Duration) IntervalOption
WithBounds clamps a positive built-in interval to [low, high], logging a warning when it adjusts the value. A non-positive bound is ignored, so WithBounds(time.Minute, 0) enforces only a floor. If both bounds are positive and high is lower than low, the pair is normalized so a swapped argument order cannot produce an interval outside the intended band. Bounds never apply to the external or once modes.
func WithIntervalLogger ¶
func WithIntervalLogger(l *slog.Logger) IntervalOption
WithIntervalLogger routes ParseInterval's warnings to a specific logger instead of slog.Default().
func WithName ¶
func WithName(name string) IntervalOption
WithName sets the environment-variable name used in warning logs (for example "SYNC_INTERVAL"), so an operator can tell which setting was rejected. It defaults to "interval".
func WithRedactedValue ¶ added in v1.2.0
func WithRedactedValue() IntervalOption
WithRedactedValue keeps the raw interval value out of ParseInterval's warnings, making them field-name-only. Use it when the value passes through secret-capable config expansion (a YAML file with ${VAR} references): a config typo can place an expanded secret in the interval field, and the default unparseable-value warning would echo it to the startup log. Only the unparseable warning can ever carry such a value — a negative or clamped value necessarily parsed as a duration — but with this option every warning omits the supplied value (the clamp warning keeps the resulting bound), so the redaction contract is uniform rather than per-branch.
func WithZeroAsOnce ¶
func WithZeroAsOnce() IntervalOption
WithZeroAsOnce makes a zero duration ("0"/"0s") select ModeOnce instead of the default ModeExternal. Use it for a job that supports a run-once mode (a batch or one-shot context) in addition to a resident daemon.
type Job ¶
Job is one unit of scheduled work. It receives the loop's context, which is cancelled when RunLoop is asked to stop; a job that must run to completion past a shutdown signal should derive its own context (context.WithoutCancel) internally. A Job reports its outcome through its own closure (setting a health marker, logging); RunLoop does not inspect a return value.
type Latch ¶ added in v1.1.0
type Latch struct {
// contains filtered or unexported fields
}
Latch is a persistent, cross-process one-shot boolean backed by the presence of a marker file: one process Raises it, another observes it with Raised, and either Clears it. Unlike a Lock it holds no OS resource and does not auto-release — its state is purely whether the marker file exists — so it survives the raising process exiting, which is exactly what a one-bit signal between two processes that cannot signal each other needs.
It is the primitive behind RerunFlag (rerun coalescing) and is used directly as a latch between processes, e.g. a shutdown/drain latch: a daemon (PID 1) Raises it on SIGTERM and a separate worker — a docker exec child that never receives the container's SIGTERM — observes it with Raised and drains. Raise is the only operation that can fail (a marker write); Raised and Clear treat a missing marker as the natural un-raised state and never fail.
func NewLatch ¶ added in v1.1.0
NewLatch returns a Latch backed by the marker file at path. Place path in a directory not writable by untrusted local users: Raise opens the marker file following symlinks, so it may create or open a target chosen by an attacker rather than the intended marker.
func (*Latch) Clear ¶ added in v1.1.0
func (l *Latch) Clear()
Clear lowers the latch by removing its marker file. Best-effort: a missing marker is already the desired state.
type Lock ¶
type Lock struct {
// contains filtered or unexported fields
}
Lock is an advisory exclusive lock backed by flock(2). It is the overlap guard for a scheduled job: it serializes runs both in-process (a startup run racing a tick) and cross-process (an external trigger — a docker exec — racing the built-in loop), because flock associates the lock with the open file description, so two independent OpenFile calls contend even within one process.
func TryLock ¶
TryLock attempts a non-blocking exclusive lock on path, creating the file if absent. ok is false without error when another holder currently owns the lock (a run is already in flight); the caller must release an acquired lock with Unlock. On acquisition it records the current time in the file so a later contender can read the holder's age via ReadHolder.
Place path in a directory not writable by untrusted local users (e.g. a container-private /tmp or a service-owned dir, not a world-writable host /tmp shared with other accounts): the file is opened following symlinks and its holder timestamp is written with Truncate, so a pre-planted symlink at path would be clobbered. Callers that must harden further can place path under a 0700 service-owned directory.
Example ¶
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/cplieger/scheduler"
)
func main() {
path := filepath.Join(os.TempDir(), "scheduler_example.lock")
defer func() { _ = os.Remove(path) }()
lock, ok, _ := scheduler.TryLock(path)
fmt.Println("first acquire:", ok)
_, contended, _ := scheduler.TryLock(path)
fmt.Println("second acquire while held:", contended)
lock.Unlock()
}
Output: first acquire: true second acquire while held: false
type LoopOptions ¶
type LoopOptions struct {
// Interval is the gap between ticks. It must be positive; RunLoop returns
// immediately otherwise (the built-in mode ParseInterval selects always
// carries a positive interval).
Interval time.Duration
// Jitter spreads each tick uniformly across ±(Jitter × Interval) so
// restarts across many instances do not synchronize into a thundering herd on a
// shared upstream. It is a fraction in [0, 1); 0 disables jitter. The
// startup fire is never jittered.
Jitter float64
// FireOnStart runs the job immediately as the first iteration, before the
// first interval elapses, so a freshly-deployed container does work at once
// instead of waiting a full interval.
FireOnStart bool
}
LoopOptions configures RunLoop. Interval must be positive; Jitter and FireOnStart are optional.
type Mode ¶
type Mode int
Mode is how a container job is scheduled, derived from an interval environment variable by ParseInterval.
const ( // ModeBuiltin runs the job once at startup, then on every interval tick. It // is selected by a positive interval duration and is the fallback when the // value is empty, unparseable, or (by default) negative. ModeBuiltin Mode = iota // ModeExternal idles: the built-in loop is disabled and runs are triggered // out-of-band (for example an Ofelia docker-exec of a one-shot subcommand). // It is selected by the "off" and "disabled" sentinels, and by a zero // duration unless WithZeroAsOnce is set. ModeExternal // ModeOnce runs the job exactly once, then exits. It is selected by a zero // duration ("0"/"0s") only when WithZeroAsOnce is passed; otherwise a zero // duration selects ModeExternal. ModeOnce )
type Outcome ¶ added in v1.2.0
type Outcome int
Outcome reports what an Exclusive.Run or RunOrSkip call did with the request. It stays meaningful alongside a non-nil error: check the error first, then the outcome — OutcomeRan with an error means the job ran and failed, while OutcomeNone with an error means an infrastructure failure prevented the request from running or queueing at all.
const ( // OutcomeNone is the zero value: the request produced neither a run nor a // queue entry. It is returned only with an infrastructure error (the lock // or queue file could not be used). OutcomeNone Outcome = iota // OutcomeRan means this call acquired the cycle lock and ran the job. OutcomeRan // OutcomeRanQueued means this call ran the job and then executed at least // one queued rerun request on top of it. OutcomeRanQueued // OutcomeQueued means a cycle was already in flight, so this request was // queued; the active runner executes it when the current run finishes. OutcomeQueued // OutcomeDiscarded means a cycle was in flight and the rerun queue was // already full; the request was dropped because the queued rerun(s) // already guarantee a run starts after this request arrived. OutcomeDiscarded // OutcomeSkipped means a cycle was in flight and the caller chose skip // mode (RunOrSkip): the tick was dropped without queueing. OutcomeSkipped )
type RerunFlag ¶
type RerunFlag struct {
// contains filtered or unexported fields
}
RerunFlag is a single-slot coalescing flag that pairs with a Lock: when a trigger arrives while the lock is held, the loser sets the flag instead of dropping the request, and the active holder — clearing the flag before each run — reruns once on completion if it was set during the run. Any number of overlapping triggers collapse into exactly one queued rerun. It is a Latch specialized for that coalescing use.
func NewRerunFlag ¶
NewRerunFlag returns a RerunFlag backed by the file at path. Place path in a directory not writable by untrusted local users: Set opens the marker file following symlinks, so it may create or open a target chosen by an attacker rather than the intended marker.
func (*RerunFlag) Clear ¶
func (r *RerunFlag) Clear()
Clear removes the flag. Call it before each run so only triggers arriving during that run queue the next rerun (clearing before, not after, prevents lost wakeups). Best-effort: a missing flag is already the desired state.
type Schedule ¶
Schedule is the parsed result of an interval environment variable: the built-in cadence and the selected Mode. Interval is meaningful only in ModeBuiltin; the other modes carry the default for reference.
func ParseInterval ¶
func ParseInterval(raw string, def time.Duration, opts ...IntervalOption) Schedule
ParseInterval interprets a raw interval environment value into a Schedule, applying the standard sentinel and fallback rules shared by every scheduled container job:
- empty -> def, ModeBuiltin
- "off" / "disabled" -> def, ModeExternal (case-insensitive)
- a positive Go duration -> that duration (clamped by WithBounds), ModeBuiltin
- a zero duration ("0"/"0s") -> def, ModeExternal (or ModeOnce with WithZeroAsOnce)
- a negative duration -> def, ModeBuiltin, with a warning (a likely typo; falling back to the default cadence beats silently disabling the job)
- anything unparseable -> def, ModeBuiltin, with a warning
def is the fallback cadence used for every non-positive outcome; it is also carried on the returned Schedule in the external and once modes for reference. def must be positive: it becomes the Interval of every ModeBuiltin result (empty, negative, or unparseable input), and the library's invariant that a ModeBuiltin Schedule always carries a positive Interval -- which a consumer relies on when it passes the Interval straight to time.NewTicker (which panics on a non-positive duration) -- holds only when def > 0. (RunLoop itself also guards defensively, since a hand-built LoopOptions can carry any Interval.) Warnings are logged via slog.Default() unless WithIntervalLogger is set.
Example ¶
package main
import (
"fmt"
"time"
"github.com/cplieger/scheduler"
)
func main() {
built := scheduler.ParseInterval("30m", time.Hour)
fmt.Printf("%s every %s\n", built.Mode, built.Interval)
fmt.Println(scheduler.ParseInterval("off", time.Hour).Mode)
fmt.Println(scheduler.ParseInterval("0", time.Hour, scheduler.WithZeroAsOnce()).Mode)
}
Output: built-in every 30m0s external once