cronkit

package
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 6, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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

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

View Source
const DefaultClaimTimeout = 10 * time.Second

DefaultClaimTimeout bounds one claim. A claim that times out skips the tick.

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

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

func Parse(schedule string) (cron.Schedule, error)

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

func Run(ctx context.Context, opts Options, fn func(ctx context.Context) error) error

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.

func TickTime

func TickTime(ctx context.Context) (time.Time, bool)

TickTime returns the scheduled time of the tick ctx belongs to: the same value on every replica, so a job can key an idempotent write on it.

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

type DB interface {
	Exec(ctx context.Context, query string, args ...any) (sql.Result, error)
}

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.

func SQLDB

func SQLDB(db *sql.DB) DB

SQLDB adapts a *sql.DB to DB.

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.

Jump to

Keyboard shortcuts

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