scheduler

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: GPL-2.0, GPL-3.0 Imports: 13 Imported by: 0

README

scheduler

Go Reference Go version Test coverage Mutation OpenSSF Scorecard

Scheduling scaffold for containerized job runners

A standalone Go library of small, composable primitives for a container that runs a job on an interval or an external trigger: interval parsing with the standard sentinels, a startup-plus-ticker run loop with jitter, an advisory flock overlap guard, a graceful shutdown drain, and a SIGTERM-graceful subprocess runner. Standard library only (test dependency: pgregory.net/rapid). Unix-only (the overlap guard is flock(2)).

It is a toolbox, not a framework: each primitive is independent, and the composition root wires the ones it needs. The library says nothing about what a job does, how health is signaled, or how logging is configured — those stay in the app (health is the companion library for the marker pattern).

Install

go get github.com/cplieger/scheduler@latest

Usage

A typical composition root reads an interval variable, picks a mode, and drives the job — guarding overlap and shutting down gracefully:

package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/cplieger/scheduler"
)

const lockPath = "/tmp/.myjob.lock"

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sched := scheduler.ParseInterval(os.Getenv("JOB_INTERVAL"), 6*time.Hour,
		scheduler.WithName("JOB_INTERVAL"))

	switch sched.Mode {
	case scheduler.ModeBuiltin:
		// Fire once now, then every interval (with ±10% jitter), draining on SIGTERM.
		scheduler.RunLoop(ctx, runPass, scheduler.LoopOptions{
			Interval:    sched.Interval,
			FireOnStart: true,
			Jitter:      0.10,
		})
	case scheduler.ModeExternal:
		// Idle: runs are triggered out-of-band (an Ofelia docker-exec of a
		// one-shot subcommand). On shutdown, wait out an in-flight external run.
		<-ctx.Done()
		scheduler.WaitForDrain(context.Background(), lockPath, scheduler.DefaultDrainPoll, 10*time.Minute)
	case scheduler.ModeOnce:
		runPass(ctx) // run exactly once, then exit
	}
}

// run builds context-cancellable subprocesses that get SIGTERM (not SIGKILL)
// on shutdown, with a grace period before the kill.
var run = scheduler.NewCommandRunner(scheduler.DefaultGrace)

func runPass(ctx context.Context) {
	lock, ok, err := scheduler.TryLock(lockPath)
	if err != nil {
		return // could not acquire; mark unhealthy in a real app
	}
	if !ok {
		return // another run already in flight — the overlap guard skips this one
	}
	defer lock.Unlock()

	cmd := run(ctx, "rsync", "-a", "/src/", "remote:/dst/")
	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
	_ = cmd.Run()
}
Interval parsing

ParseInterval applies the standard sentinel and fallback rules to a *_INTERVAL environment value and returns a Schedule (cadence + Mode):

Raw value Result
"30m", "1h30m" (positive Go duration) ModeBuiltin, that cadence (clamped by WithBounds)
"" (unset) ModeBuiltin, the default cadence
"off", "disabled" (case-insensitive) ModeExternal
"0", "0s" (zero) ModeExternal, or ModeOnce with WithZeroAsOnce()
"-1h" (negative) ModeBuiltin at default + a warning (a likely typo)
"banana" (unparseable) ModeBuiltin at default + a warning

Options: WithZeroAsOnce() (treat a zero duration as run-once), WithBounds(low, high) (clamp a positive cadence), WithName(env) (name the variable in warnings), WithIntervalLogger(l) (route warnings to a specific logger; defaults to slog.Default()), WithRedactedValue() (keep the supplied raw value out of every warning — use when the interval passes through secret-capable config expansion, where a config typo could place an expanded secret in the field; plain env-var reads should keep the default echo, it is useful diagnostics).

Overlap guard and coalescing

TryLock / Unlock serialize runs across both the in-process loop and an out-of-band docker exec trigger. InFlight probes whether a run holds the lock; ReadHolder reads how long it has held it (observability only). For a trigger that arrives mid-run, RerunFlag coalesces any number of overlapping triggers into exactly one queued rerun:

flag := scheduler.NewRerunFlag("/tmp/.myjob.rerun")
for {
	flag.Clear()      // clear before the run so only triggers during it queue a rerun
	runOnce(ctx)
	if !flag.Pending() {
		break
	}
}

RerunFlag is a coalescing specialization of Latch, the bare single-bit cross-process marker (present on disk means raised). Use a Latch directly for one-off signals between processes that cannot signal each other, such as a daemon raising a shutdown/drain latch on SIGTERM that a separate docker exec child observes with Raised() and drains on.

Run coalescing across processes

Exclusive packages the lock + queue pattern into Renovate's run-coalescing model for a whole app: at most one cycle runs at a time per instance, across every entry point (the resident daemon's tick, a poll subcommand exec'd by an operator or an external scheduler). A request that arrives while a cycle runs is queued — without a blocked process: the requester records a rerun request in a counter file and exits immediately, and the active runner executes the queued demand when the current run finishes. Requests beyond the queue capacity (default 1, set with WithQueueCapacity) are discarded, because the queued rerun already guarantees a run starts after they arrived.

The two entry points pair as queue mode for demand-driven callers and skip mode for time-driven ticks:

ex := scheduler.NewExclusive("/config", logger)

// Daemon: RunLoop ticks use skip mode — a busy lock means the job is already
// running, and the next tick provides freshness; never queue a tick.
scheduler.RunLoop(ctx, func(ctx context.Context) {
	_, _ = ex.RunOrSkip(func() error { return runCycle(ctx) })
}, scheduler.LoopOptions{Interval: sched.Interval, FireOnStart: true})

// Poll subcommand (exec'd by an operator or an external scheduler): queue
// mode — the request must be satisfied by a run that starts after it arrived.
outcome, err := ex.Run(func() error { return runCycle(ctx) })
switch outcome {
case scheduler.OutcomeQueued, scheduler.OutcomeDiscarded:
	os.Exit(0) // the in-flight runner covers this request; nothing to wait for
default:
	if err != nil {
		os.Exit(1)
	}
}

The lock is a flock(2) (cycle.lock in the directory), so the kernel releases it when the holding process dies — a crashed run never wedges the scheduler, and a queue counter orphaned by a crash is cleared at the next acquisition. Pending reports the queued-request count for observability, and ReadHolder on ExclusiveLockName reports how long the current cycle has run.

API

  • ModeModeBuiltin, ModeExternal, ModeOnce (implements fmt.Stringer).
  • Schedule{Interval, Mode} returned by ParseInterval.
  • ParseInterval(raw string, def time.Duration, opts ...IntervalOption) Schedule.
  • WithZeroAsOnce(), WithBounds(low, high), WithName(name), WithIntervalLogger(l), WithRedactedValue() — interval options.
  • Jobfunc(ctx context.Context), one unit of scheduled work.
  • LoopOptions{Interval, Jitter, FireOnStart}.
  • RunLoop(ctx, job, opts) — sequential startup-plus-ticker loop; drains on cancellation.
  • JitteredDelay(interval, fraction) time.Duration — the pure ±band jitter core.
  • Lock, TryLock(path) (*Lock, bool, error), (*Lock).Unlock().
  • InFlight(path) (bool, error), ReadHolder(path) (time.Time, bool).
  • RerunFlag, NewRerunFlag(path), .Set(), .Pending() bool, .Clear().
  • Latch, NewLatch(path), .Raise() error, .Raised() bool, .Clear() — the bare single-bit cross-process marker behind RerunFlag, used directly for one-off signals such as a shutdown/drain latch.
  • Exclusive, NewExclusive(dir, logger, opts...), .Run(job) (Outcome, error) (queue mode), .RunOrSkip(job) (Outcome, error) (skip mode), .Pending() (int, error) — cross-process run coalescing.
  • WithQueueCapacity(n) — Exclusive option: how many rerun requests may queue (default 1).
  • OutcomeOutcomeRan, OutcomeRanQueued, OutcomeQueued, OutcomeDiscarded, OutcomeSkipped, OutcomeNone (implements fmt.Stringer).
  • ExclusiveLockName, ExclusiveQueueName — the file names Exclusive maintains inside its directory.
  • WaitForDrain(ctx, path, poll, maxWait) bool, DefaultDrainPoll.
  • CommandRunner, NewCommandRunner(grace) CommandRunner, DefaultGrace.

Unsupported by design

These are deliberate non-goals, not a TODO list. The library is one cohesive concept — schedule a container job, guard its overlap, run and drain it — and stays small on purpose. It complements the other cplieger libraries rather than absorbing them.

Feature Rationale
Logging setup (slog handler, UTC time attr) The composition root owns logging. The library logs interval warnings through slog.Default() (or WithIntervalLogger), a best-effort rerun-flag write-failure warning through slog.Default(), and Exclusive's coalescing lines through its injected logger (nil falls back to slog.Default()); it never configures a handler.
Health signaling Set(healthy) is the app's call inside its job. Use the companion health library for the marker; the two compose.
What a job does / its outcome type Job is func(ctx). Exit codes, health flips, and log lines are the app's policy, wired inside the closure.
Cron expressions / calendar schedules This is interval + external-trigger scheduling. For 0 2 * * * semantics, use an external scheduler (Ofelia, cron) in ModeExternal.
Distributed / multi-node coordination The flock guard is single-host. Cross-node leader election is a different abstraction (a lease store), out of scope.
Concurrent in-process runs RunLoop is sequential by design (two runs never overlap in-process); the flock guards the cross-process case. Run a job concurrently yourself if you must.
Retry / backoff of a failed run Retrying outbound work belongs to httpx; a failed pass is reported by the job and retried on the next tick or trigger.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0 — see LICENSE.

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

Examples

Constants

View Source
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.

View Source
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.

View Source
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

func InFlight(path string) (inFlight bool, err error)

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

func JitteredDelay(interval time.Duration, fraction float64) time.Duration

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

func ReadHolder(path string) (since time.Time, known bool)

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

func WaitForDrain(ctx context.Context, path string, poll, maxWait time.Duration) bool

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

type CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd

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

func (e *Exclusive) Pending() (int, error)

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

func (e *Exclusive) Run(job func() error) (Outcome, error)

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

func (e *Exclusive) RunOrSkip(job func() error) (Outcome, error)

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

type Job func(ctx context.Context)

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

func NewLatch(path string) *Latch

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.

func (*Latch) Raise added in v1.1.0

func (l *Latch) Raise() error

Raise sets the latch by creating its marker file. Idempotent: raising an already-raised latch is a no-op. It returns an error only if the marker could not be written, leaving the decision of whether a missed raise is tolerable to the caller; Raise never panics.

func (*Latch) Raised added in v1.1.0

func (l *Latch) Raised() bool

Raised reports whether the latch is currently raised (its marker file exists).

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

func TryLock(path string) (l *Lock, ok bool, err error)

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

func (*Lock) Unlock

func (l *Lock) Unlock()

Unlock releases the lock and closes the underlying file. The lock file is left on disk; its only content is the last holder's acquisition timestamp, reused across runs and irrelevant while the lock is free.

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
)

func (Mode) String

func (m Mode) String() string

String returns the lowercase mode name for logging.

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
)

func (Outcome) String added in v1.2.0

func (o Outcome) String() string

String returns the lowercase outcome name for logging.

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

func NewRerunFlag(path string) *RerunFlag

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.

func (*RerunFlag) Pending

func (r *RerunFlag) Pending() bool

Pending reports whether a rerun was queued (the flag file exists).

func (*RerunFlag) Set

func (r *RerunFlag) Set()

Set records that a rerun is pending. Idempotent: the flag is boolean, so any number of overlapping triggers coalesce into one queued rerun. Best-effort — a failed write defers the trigger to the next scheduled run.

type Schedule

type Schedule struct {
	Interval time.Duration
	Mode     Mode
}

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL