Documentation
¶
Overview ¶
Package scheduler is the scheduling scaffold shared by the fleet's containerized job runners.
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 / ReadHolder are an advisory flock(2) overlap guard so a run and an out-of-band trigger never execute two jobs at once (a trigger arriving mid-run is queued by Exclusive, below).
- Exclusive composes the pieces above into packaged cross-process 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 — continuing through job errors (a queued request is owed a run, succeed or fail) and retiring at a rerun cap that defers a storm's residue to the next run. WithGate wires the composition root's shutdown signal in front of every run start, so a stop request is never followed by a fresh run.
- SlotFile is the storage mechanism under Exclusive's counter — a single-slot byte payload mutated by flock'd read-modify-write transactions — exported for apps whose coalescing state carries a payload (the payload's meaning and merge/claim policy stay app-side).
- NewCommandRunner builds context-cancellable subprocesses that shut down gracefully (SIGTERM with a grace period before SIGKILL).
The trigger subpackage is the in-process alternative to the flock-based coordination above, for single-owner daemons: PID 1 owns every run, and triggers submit requests through a bounded FIFO queue served over an owner-only in-container unix socket (see package trigger).
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 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)
- type CommandRunner
- type Exclusive
- type ExclusiveOption
- type IntervalOption
- type Job
- type Lock
- type LoopOptions
- type Mode
- type Outcome
- type Schedule
- type SlotFile
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 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 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.
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 ¶
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 — packaged cross-process 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).
A holder executes at most maxCoalescedReruns (8) queued reruns per acquisition; demand still pending past that cap is deferred — it stays in the counter file and the next acquisition's run satisfies it — so a relentless trigger source cannot pin one holder indefinitely. WithGate bounds the holder further: it stops runs from starting once the composition root's shutdown signal trips. Deferral is always demand-preserving.
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.
Example ¶
package main
import (
"fmt"
"log/slog"
"os"
"github.com/cplieger/scheduler/v4"
)
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 ¶
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, WithGate.
func (*Exclusive) Pending ¶
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 ¶
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). OutcomeQueued may also accompany an error: the request was recorded but the post-enqueue re-probe failed — the demand stands, and a current or next runner consumes it. Queued and Discarded outcomes are success for the requesting process: log-and-exit-0 is the intended caller behavior.
A failed run does not stop queued demand: each queued request is owed a run that starts after it arrived, succeed or fail, so the consume loop continues through job errors (bounded by the rerun cap). WithGate stops runs from STARTING once the composition root's shutdown (or other) signal trips: a gated initial run returns OutcomeGated, and demand queued behind a closed gate waits for the next run.
func (*Exclusive) RunOrSkip ¶
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 ¶
type ExclusiveOption func(*Exclusive)
ExclusiveOption configures NewExclusive.
func WithGate ¶
func WithGate(gate func() bool) ExclusiveOption
WithGate installs a pre-run gate consulted every time the runner is about to START the job: on acquisition, before each queued rerun, and before each post-release handoff. A false return stops runs from starting — an initial run is skipped (OutcomeGated) and queued demand is deferred to the next run — while the in-flight run is never interrupted. Wire it to the composition root's shutdown signal (typically the shutdown context's Err) so a stop request is never followed by a fresh run. Requests are still recorded while the gate is closed: the gate decides what runs, not what queues.
func WithQueueCapacity ¶
func WithQueueCapacity(n int) ExclusiveOption
WithQueueCapacity sets how many rerun requests may queue while a cycle is running (the default is 1, the single-slot 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 ¶
func WithRedactedValue(redacted bool) IntervalOption
WithRedactedValue selects whether ParseInterval's warnings echo the raw interval value: true keeps it out, making them field-name-only, while false keeps the default echo, exactly as if the option were absent. Repeated applications resolve last-wins. Pass true 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 true 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(zeroAsOnce bool) IntervalOption
WithZeroAsOnce selects what a zero duration ("0"/"0s") means: true makes it select ModeOnce — for a job that supports a run-once mode (a batch or one-shot context) in addition to a resident daemon — while false keeps the default ModeExternal, exactly as if the option were absent. Repeated applications resolve last-wins.
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 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/v4"
)
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(true) is passed. ModeExternal // ModeOnce runs the job exactly once, then exits. It is selected by a zero // duration ("0"/"0s") only when WithZeroAsOnce(true) is passed; otherwise a // zero duration selects ModeExternal. ModeOnce )
type Outcome ¶
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, OutcomeNone with an error means an infrastructure failure prevented the request from running or queueing at all, and OutcomeQueued with an error means the request WAS recorded but the post-enqueue re-probe failed (the queued demand still stands; a current or next runner consumes it).
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 (or, // if that runner retires at its rerun cap, the next run to acquire the // lock satisfies it). 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 // OutcomeGated means the run gate (WithGate) was closed when the runner // was about to start the job: nothing ran, and the queue counter was left // untouched (a request this call had already queued on the re-probe path // stays queued; the next run satisfies it). OutcomeGated )
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(true))
- 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 and ParseInterval panics otherwise (a programmer error in the composition root, caught at first boot — the same contract as time.NewTicker): def 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 -- 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/v4"
)
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(true)).Mode)
}
Output: built-in every 30m0s external once
type SlotFile ¶
type SlotFile struct {
// contains filtered or unexported fields
}
SlotFile is a single-slot byte payload shared across processes through one file, mutated by atomic read-modify-write transactions under a short exclusive flock(2) on the file itself. It is the storage mechanism behind Exclusive's rerun counter, exported so an app can build its own coalescing state on the same transaction — for example a payload-carrying demand slot whose merge and claim semantics are app policy (docker-renovate-scheduler records WHICH repos a queued trigger wants, not just that one arrived).
The transaction is content-agnostic: what the bytes mean, how concurrent demands merge, and when a slot counts as satisfied are the caller's parser and policy. Parsers must self-heal on unparseable content (treat torn or garbage bytes as the zero value): a crash between Truncate and WriteAt can leave a torn slot, and the library's own counter and every other slot user recover by reading garbage as empty.
The slot file is created on first use and never unlinked — by the library or the caller — while contenders may exist: unlinking a locked file lets a concurrent opener land on a different inode and breaks mutual exclusion. "Clear" is writing an empty payload. Place the path in a directory not writable by untrusted local users, per the same symlink-following caveat as TryLock.
func NewSlotFile ¶
NewSlotFile returns a SlotFile backed by the file at path (see the type documentation for the trust and lifecycle rules).
func (*SlotFile) Mutate ¶
Mutate applies fn to the slot's current content under an exclusive flock on the slot file and returns the content fn saw. fn receives the current bytes (empty on first use) and returns the bytes to store; returning content byte-equal to before (returning before itself is the idiom) leaves the file untouched, so a read is a Mutate whose fn returns its argument. A nil return stores an empty payload (the clear idiom).
The lock is blocking: contenders serialize, and the critical section is the read, fn, and write of one short payload — microseconds, never a job's duration — so keep fn small and non-blocking (it runs under the flock). before stays meaningful alongside a non-nil error when the failure happened after the read (a truncate or write error).
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package trigger is the single-owner trigger broker shared by the fleet's socket-shaped scheduler daemons (docker-renovate-scheduler, docker-rsync-scheduler, docker-fclones-scheduler).
|
Package trigger is the single-owner trigger broker shared by the fleet's socket-shaped scheduler daemons (docker-renovate-scheduler, docker-rsync-scheduler, docker-fclones-scheduler). |