Documentation
¶
Overview ¶
Package cronkit runs a cron-scheduled job so that each tick fires on exactly ONE replica, however many replicas run the worker.
Why a tick claim, not a leader ¶
A cron worker runs inside a long-running workload, so every replica of that workload computes the same schedule and wakes at the same instant. Without coordination a poller scaled to three replicas polls three times an hour. A leader lease (forge/pkg/lease, lifecyclekit.Singleton) would fix that by idling every replica but one; a tick claim fixes it without a standby: each tick is a row keyed by (job, scheduled tick time) in the app's Postgres, the replica whose INSERT lands runs the tick, and every other replica's INSERT is a no-op, so it skips the tick (a debug log and a "skipped" count).
The key is the SCHEDULED time, not the moment a replica woke, so replicas whose timers fire a few hundred milliseconds apart (or whose clocks are a little skewed) still contend for the same row. An `@every <d>` schedule is aligned to multiples of d for the same reason: robfig/cron counts it from the moment each process started, which no two replicas share.
What a claim guarantees ¶
At most once per tick. The claim is taken before the job runs, so a replica that crashes mid-tick loses that tick (cron semantics: a missed tick is not caught up). A claim that cannot be taken (the database is down) skips the tick on that replica rather than risking a double run, and logs at ERROR. Ticks on one replica never overlap: a tick that outlives its period skips the ticks it covered. A LATER tick may run on another replica while a slow one is still running here; a job that must never overlap itself as well should hold a forge/pkg/lease lease inside its job.
Running everywhere on purpose ¶
Options.PerReplica fires every tick on every replica and never consults the claims: for a job whose effect is local to the pod (refreshing an in-memory cache, sweeping a local temp dir). With no Options.Claims (the app has no database) every replica fires too, and Run says so at boot with a WARN, because that is a fallback, not a choice.
Index ¶
Constants ¶
const ( OutcomeRan = "ran" // this replica ran the tick and it returned nil OutcomeFailed = "failed" // this replica ran the tick and it returned an error or panicked OutcomeSkipped = "skipped" // another replica claimed the tick OutcomeClaimError = "claim_error" // the claim could not be taken; the tick was skipped here )
The values of the outcome attribute on forge_cron_ticks_total.
const DefaultClaimTimeout = 10 * time.Second
DefaultClaimTimeout bounds one claim. A claim that times out skips the tick.
const DefaultRetention = 24 * time.Hour
DefaultRetention is how long a claimed tick's row is kept. It only has to outlast the spread between replicas waking for the same tick.
const Table = "forge_cron_ticks"
Table is the claims table Postgres creates and writes. It is bookkeeping, like the migrator's own tables: a row per tick that ran, pruned after DefaultRetention.
Variables ¶
This section is empty.
Functions ¶
func Parse ¶
Parse reads a schedule the way Run does, so a caller can validate one up front. An `@every` schedule is aligned to multiples of its interval.
func Run ¶
Run calls fn at every tick of opts.Schedule until ctx is cancelled, then waits for an in-flight tick to return (its ctx is cancelled too) and returns nil. It returns an error only for options it cannot run.
Each tick that this replica runs is a trace of its own (a root span named "cron.<name>.tick"); an error fn returns is logged and recorded on the span, and a panic is recovered, so one bad tick never stops the schedule.
Types ¶
type Claims ¶
type Claims interface {
// Claim reports whether the caller won tick of job. Across every
// replica sharing the store, exactly one call per (job, tick) returns
// true.
Claim(ctx context.Context, job string, tick time.Time) (bool, error)
}
Claims decides which replica runs a tick. Postgres is the implementation a forge app uses.
func Postgres ¶
func Postgres(db DB, opts ...PostgresOptions) Claims
Postgres returns Claims over the app's database, or nil when db is nil (or a nil pointer in an interface, as an ORM handle is when the app has no DATABASE_URL), so Run falls back to firing on every replica with its boot warning.
The claims table is created on first use (CREATE TABLE IF NOT EXISTS, serialized across replicas by an advisory lock), so the database role the app runs as needs CREATE on its schema, as it does to run its own migrations at boot. Any pool works, including one behind a transaction-pooling PgBouncer: the creation and each claim are one autocommit statement apiece.
type DB ¶
DB is what Postgres needs from a pool. A forge app's ORM handle (orm.Context, *orm.Client) satisfies it; wrap a *sql.DB with SQLDB.
type Options ¶
type Options struct {
// Name is the job's name: the claim key and the worker label on logs,
// spans and metrics. Required. A cron worker passes its Name().
Name string
// Schedule is a standard 5-field cron expression or a descriptor
// (@hourly, @every 5m), as robfig/cron's ParseStandard reads it.
// Required.
Schedule string
// Claims makes each tick fire on one replica. Nil fires every tick on
// every replica, with a WARN at start (unless PerReplica says that is
// intended).
Claims Claims
// PerReplica fires every tick on every replica and never consults
// Claims: the opt-out for a job meant to run on each pod.
PerReplica bool
// ClaimTimeout bounds one claim (default [DefaultClaimTimeout]).
ClaimTimeout time.Duration
// Logger receives the start line, skipped ticks (DEBUG), failed ticks
// and claim errors. nil uses slog.Default().
Logger *slog.Logger
// Meter records forge_cron_ticks_total. nil uses the global meter
// provider.
Meter metric.Meter
// contains filtered or unexported fields
}
Options configures Run.
type PostgresOptions ¶
type PostgresOptions struct {
// Replica names the claimant in claimed_by (default: the hostname,
// which is the pod name in Kubernetes).
Replica string
// Retention is how long claimed rows are kept (default
// [DefaultRetention]).
Retention time.Duration
}
PostgresOptions tunes Postgres. The zero value is production-ready.