Documentation
¶
Overview ¶
Package postgres is the only package in jobrun that touches SQL. It implements the ports the rest of the system consumes (job.Store with its embedded job.Schedules, job.Dispatcher, and the scheduler's Locker) on top of a pgxpool.Pool and the sqlc-generated queries in sqlc/ (GENERATED from db/query/*.sql against migrations/; never hand-edited).
Files ¶
- pool.go: Open / OpenConfig, a pgxpool with the production limits (MaxConns = WORKER_CONCURRENCY + 8), pinged.
- migrate.go: Migrate, goose migrations embedded from migrations/ and run through a *sql.DB borrowed from the pool. Idempotent.
- store.go: Store (job.Store) - Enqueue, EnqueueBatch, Claim, Heartbeat, Complete, Fail, ReapExpired, Get, List, Cancel, Retry, Purge, Depth, Now, plus withTx and the Options (WithEventMode, WithCommitTimeout, WithNotifyChannel).
- schedules.go: the job.Schedules half of the Store.
- events.go: EventMode (JOB_EVENTS=all|terminal), the job_events writers used inside every transition, Events and PruneEvents.
- effects.go: EffectKey, EffectDone and the processed_effects ledger row written by Complete.
- notify.go: Notifier (job.Dispatcher) over LISTEN/NOTIFY with one dedicated listener connection, reconnect and a 1-slot channel per waiter.
- lock.go: Locker and AdvisoryLocker (pg_try_advisory_lock on a dedicated connection) for scheduler leader election.
- convert.go: row <-> domain conversion and the small pg helpers.
Transaction boundaries ¶
A transaction opens only inside this package, always through Store.withTx, and every mutating Store method is exactly one transaction:
- Enqueue: INSERT jobs ON CONFLICT DO NOTHING (+ re-read on a replay) + INSERT job_events + pg_notify. Postgres delivers the notification only at commit, so a consumer can never wake before the row is visible.
- EnqueueBatch: SELECT now() + COPY jobs + batched INSERT job_events + one pg_notify per distinct queue.
- Claim: the CTE claim statement (FOR UPDATE SKIP LOCKED, attempts++) + batched INSERT job_events. Without claim events (EventsTerminal) it is the single statement, no explicit transaction.
- Complete: set_config(statement_timeout) + SELECT jobs FOR UPDATE (owner check, job.ErrLeaseLost) + INSERT processed_effects + the handler's Commit closure (skipped when the effect row already existed) + the owner-guarded UPDATE + INSERT job_events. Any failure, including a lost lease, rolls the handler's writes back with everything else: that is the exactly-once story for handlers on the tx path.
- Fail: the owner-guarded UPDATE (queued at retry_at, dead, or cancelled when a cancel was requested) + INSERT job_events.
- ReapExpired: SELECT expired leases FOR UPDATE SKIP LOCKED + one pgx batch of per-row UPDATEs (verdict and backoff computed in Go from the job's own policy) + batched INSERT job_events.
- Cancel / Retry: SELECT jobs FOR UPDATE + the UPDATE + INSERT job_events (+ pg_notify for Retry, which makes the job claimable now).
- DueSchedules: SELECT ... FOR UPDATE SKIP LOCKED in its own short transaction (defensive against two leaders; the sched:<id>:<next_run> idempotency key on the materialised job is the durable guard).
Heartbeat, Purge, Depth, Get, List, Events and the remaining schedule methods are single statements on the pool.
The clock rule ¶
Every comparison and default uses the database clock: run_at defaults to now() + delay, claims read run_at <= now(), leases are now() + ttl, Fail and ReapExpired stamp finished_at = now(), Purge measures age from now(). The time.Time arguments the ports carry (Heartbeat until, ReapExpired now, DueSchedules now, Fail retryAt) are "db now as read by the caller" - use Store.Now, never time.Now - so an app clock that drifts can never hide a job or fire it early. Timestamps come back normalised to UTC.
Leases and fencing ¶
A claim flips queued -> leased and increments attempts, so a process that crashes mid-job burns exactly one attempt. Heartbeat extends only leases that have not yet expired and omits ids whose cancel was requested; an id missing from the kept set means the worker must cancel that job's context. Complete and Fail are owner-guarded: after the reaper requeued the job (or another owner claimed it) they return job.ErrLeaseLost and change nothing. A job with cancel_requested ends cancelled whichever of Complete or Fail arrives; the event is recorded either way.
Idempotency ¶
jobs_idem_uniq is a partial unique index on (queue, idempotency_key). Enqueue uses it as the ON CONFLICT target: a replay with the same type, payload (compared as JSON), priority, max_attempts and backoff returns the stored job unchanged (stored.ID != j.ID tells the caller), a different body is job.ErrIdempotencyConflict. processed_effects is keyed (scope, key) = ("job:"+type, idempotency_key or id), so a Retry of a job whose effect already committed completes without running the Commit closure again.
Index ¶
- Constants
- func EffectKey(j job.Job) (scope, key string)
- func Migrate(ctx context.Context, pool *pgxpool.Pool) error
- func Open(ctx context.Context, url string, maxConns int32, log *slog.Logger) (*pgxpool.Pool, error)
- func OpenConfig(ctx context.Context, cfg *pgxpool.Config, log *slog.Logger) (*pgxpool.Pool, error)
- type AdvisoryLocker
- type EventMode
- type Locker
- type Notifier
- type NotifierOption
- type Option
- type Store
- func (s *Store) AdvanceSchedule(ctx context.Context, id job.ID, lastRun, nextRun time.Time) error
- func (s *Store) Cancel(ctx context.Context, id job.ID) (job.Job, error)
- func (s *Store) Claim(ctx context.Context, q job.ClaimQuery) ([]job.Job, error)
- func (s *Store) Complete(ctx context.Context, l job.Lease, owner string, fn job.Commit) error
- func (s *Store) CreateSchedule(ctx context.Context, sc job.Schedule) (job.Schedule, error)
- func (s *Store) DeleteSchedule(ctx context.Context, id job.ID) error
- func (s *Store) Depth(ctx context.Context) ([]job.DepthRow, error)
- func (s *Store) DueSchedules(ctx context.Context, now time.Time, limit int32) ([]job.Schedule, error)
- func (s *Store) EffectDone(ctx context.Context, scope, key string) (bool, error)
- func (s *Store) Enqueue(ctx context.Context, j job.Job) (job.Job, error)
- func (s *Store) EnqueueBatch(ctx context.Context, js []job.Job) (int64, error)
- func (s *Store) Events(ctx context.Context, id job.ID) ([]job.Event, error)
- func (s *Store) Fail(ctx context.Context, l job.Lease, owner, cause string, perm bool, ...) (job.Status, error)
- func (s *Store) Get(ctx context.Context, id job.ID) (job.Job, error)
- func (s *Store) GetSchedule(ctx context.Context, id job.ID) (job.Schedule, error)
- func (s *Store) Heartbeat(ctx context.Context, owner string, leases []job.Lease, until time.Time) (job.Renewal, error)
- func (s *Store) List(ctx context.Context, q job.ListQuery) ([]job.Job, job.Cursor, error)
- func (s *Store) ListSchedules(ctx context.Context, afterName string, limit int32) ([]job.Schedule, error)
- func (s *Store) Now(ctx context.Context) (time.Time, error)
- func (s *Store) PruneEvents(ctx context.Context, olderThan time.Duration) (int64, error)
- func (s *Store) Purge(ctx context.Context, q job.PurgeQuery) (int64, error)
- func (s *Store) ReapExpired(ctx context.Context, now time.Time, limit int32, p job.Policy) ([]job.Event, error)
- func (s *Store) Retry(ctx context.Context, id job.ID) (job.Job, error)
- func (s *Store) UpdateSchedule(ctx context.Context, sc job.Schedule) (job.Schedule, error)
Constants ¶
const ( // DefaultCommitTimeout is the statement_timeout of the completion // transaction: a handler's Commit closure is "writes only, no network". DefaultCommitTimeout = 5 * time.Second // DefaultPurgeLimit bounds one Purge call when PurgeQuery.Limit is 0. DefaultPurgeLimit = 1000 )
Defaults for the Store options.
const (
// DefaultMaxConns is used when Open is given a non-positive maxConns.
DefaultMaxConns = 24
)
Pool limits applied by Open. MaxConns is a parameter: the plan sizes it as WORKER_CONCURRENCY + 8 so the claim loop, heartbeater, reaper, scheduler and the gRPC surface never starve behind handlers holding completion transactions.
const ( // DefaultPollFloor is how often Wait ticks regardless of notifications. DefaultPollFloor = 200 * time.Millisecond )
Notifier defaults.
const NotifyChannel = "jobrun_ready"
NotifyChannel is the LISTEN/NOTIFY channel; the payload is the queue name.
Variables ¶
This section is empty.
Functions ¶
func EffectKey ¶
EffectKey is the processed_effects primary key for a job: scope is "job:<type>" and key is the idempotency key when the job has one, otherwise the job id. Two enqueues that share a queue and idempotency key are the same job by definition, so they also share one effect.
func Migrate ¶
Migrate applies every pending goose migration embedded in migrations/ to the database behind pool. It is idempotent: a second call is a no-op. The goose version table lives in the first schema of the connection's search_path, so a pool configured with a private search_path migrates that schema only.
Types ¶
type AdvisoryLocker ¶
type AdvisoryLocker struct {
// contains filtered or unexported fields
}
AdvisoryLocker implements Locker with a session-level pg_try_advisory_lock on a dedicated connection (not a pool connection, so a busy pool can never stall the leader and a leader can never exhaust the pool). The lock is released by release, or by Postgres when the connection dies, in which case the scheduler re-elects on its next TryLock.
func NewLocker ¶
func NewLocker(pool *pgxpool.Pool) *AdvisoryLocker
NewLocker returns an AdvisoryLocker that dials with pool's connection config.
func (*AdvisoryLocker) TryLock ¶
func (l *AdvisoryLocker) TryLock(ctx context.Context, key int64) (func(context.Context) error, bool, error)
TryLock dials a dedicated connection and tries the advisory lock for key. acquired false (and a nil release) means another session holds it; the connection is closed again. On success the connection stays open until release is called.
type EventMode ¶
type EventMode int
EventMode selects which transitions are written to job_events (JOB_EVENTS). The rows are always written in the same transaction as the jobs change, so history and state can never disagree; the mode only trades write amplification (about four rows per job in EventsAll) for a thinner history.
func ParseEventMode ¶
ParseEventMode reads the JOB_EVENTS spelling ("all" or "terminal", any case).
type Locker ¶
type Locker interface {
TryLock(ctx context.Context, key int64) (release func(context.Context) error, acquired bool, err error)
}
Locker is the leader-election port the scheduler consumes (docs/PLAN.md spells it as scheduler.Locker; this is the same signature so the scheduler can define its own interface and AdvisoryLocker satisfies both). release must be called exactly once when the caller gives leadership up.
type Notifier ¶
type Notifier struct {
// contains filtered or unexported fields
}
Notifier implements job.Dispatcher over Postgres LISTEN/NOTIFY. Producers signal a queue with Ready (the Store also does it from inside its enqueue transactions, which is the path that cannot race the row); Run holds one dedicated connection on LISTEN and coalesces every notification into a 1-slot channel per waiter. Wait always also ticks at the poll floor, so a dropped or missed notification (disconnect, NOTIFY storm) costs latency, never correctness.
func NewNotifier ¶
NewNotifier returns a Notifier over pool. The listener connection is opened by Run from the pool's connection config (it is not a pool connection).
func (*Notifier) Ready ¶
Ready tells consumers that work exists on queue with a standalone NOTIFY. Producers that write through the Store do not need it: the Store notifies inside its transaction.
type NotifierOption ¶
type NotifierOption func(*Notifier)
NotifierOption configures a Notifier.
func WithChannel ¶
func WithChannel(name string) NotifierOption
WithChannel sets the LISTEN/NOTIFY channel (default NotifyChannel; must match the Store's WithNotifyChannel).
func WithPollFloor ¶
func WithPollFloor(d time.Duration) NotifierOption
WithPollFloor sets the Wait tick interval (default DefaultPollFloor).
type Option ¶
type Option func(*Store)
Option configures a Store.
func WithCommitTimeout ¶
WithCommitTimeout sets the statement_timeout of the completion transaction; 0 disables it. The default is DefaultCommitTimeout.
func WithEventMode ¶
WithEventMode selects which transitions are written to job_events (JOB_EVENTS).
func WithFIFOEvery ¶
WithFIFOEvery makes every n-th claim per queue take the oldest ready jobs regardless of priority (CLAIM_FIFO_EVERY), bounding priority starvation: a low-priority job waits at most for the jobs older than it to be claimed by the one-in-n FIFO claims. 0 (the default) disables it: strict priority.
func WithNotifyChannel ¶
WithNotifyChannel sets the LISTEN/NOTIFY channel the store signals from inside its enqueue transactions; "" disables the in-transaction notify (PollOnly deployments). The default is NotifyChannel.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store implements job.Store on a pgxpool.Pool. Every mutating method is one transaction (see the package doc for the exact boundaries).
func (*Store) AdvanceSchedule ¶
AdvanceSchedule sets last_run and next_run after a materialisation; job.ErrScheduleNotFound when missing.
func (*Store) Cancel ¶
Cancel moves a queued job to cancelled at once, sets cancel_requested on a leased one (returned still leased; the worker gives it up on its next heartbeat), and returns job.ErrNotCancellable for a finished job.
func (*Store) Claim ¶
Claim leases up to q.Limit ready jobs on q.Queue to q.Owner in one round trip (FOR UPDATE SKIP LOCKED), increments attempts and records the claim events.
func (*Store) Complete ¶
Complete finishes a leased job in one transaction: lock the row and check the owner and fencing token (job.ErrLeaseLost otherwise), insert the processed_effects row, run fn when the effect is new, flip the job to succeeded (or cancelled when a cancel was requested meanwhile) and record the event. Zero rows on the owner-guarded update rolls everything back, fn's writes included.
func (*Store) CreateSchedule ¶
CreateSchedule inserts s (NextRun must be set by the caller from cronx). A zero ID gets a fresh one, "" timezone means UTC; a taken name is job.ErrScheduleExists.
func (*Store) DeleteSchedule ¶
DeleteSchedule removes a schedule; job.ErrScheduleNotFound when missing.
func (*Store) Depth ¶
Depth returns the (queue, status) counts with the oldest run_at per cell. It scans every non-purged row: admin caches it on a ticker, never per scrape.
func (*Store) DueSchedules ¶
func (s *Store) DueSchedules(ctx context.Context, now time.Time, limit int32) ([]job.Schedule, error)
DueSchedules returns up to limit enabled schedules with next_run <= now, oldest next_run first. It runs SELECT ... FOR UPDATE SKIP LOCKED in its own short transaction, so two leaders ticking at once split the due set instead of both materialising it; the idempotency key on the materialised job is the durable guard.
func (*Store) EffectDone ¶
EffectDone reports whether the effect (scope, key) has already been recorded. It is the fast-path check a handler may use before doing expensive work; the durable guard is the processed_effects row Complete writes in the completion transaction, so a false negative here only costs the work, never correctness.
func (*Store) Enqueue ¶
Enqueue inserts j in one transaction: INSERT jobs ON CONFLICT (queue, idempotency_key) DO NOTHING, the enqueue event and the NOTIFY. No row from the insert means a replay: the stored job is returned unchanged (the caller sees stored.ID != j.ID); a replay with a different body is job.ErrIdempotencyConflict.
func (*Store) EnqueueBatch ¶
EnqueueBatch inserts js with COPY in one transaction (no idempotency handling: a duplicate key fails the whole batch), writes the enqueue events and notifies each distinct queue once. Zero RunAt values resolve against the database clock read at the start of the transaction.
func (*Store) Events ¶
Events returns a job's history oldest first (empty, not nil, for an unknown id).
func (*Store) Fail ¶
func (s *Store) Fail(ctx context.Context, l job.Lease, owner, cause string, perm bool, retryAt time.Time) (job.Status, error)
Fail records cause and moves the job to queued at retryAt, to dead when perm is set or attempts reached max_attempts, or to cancelled when a cancel was requested; it returns the resulting status. Owner- and token-guarded (job.ErrLeaseLost).
func (*Store) GetSchedule ¶
GetSchedule returns one schedule or job.ErrScheduleNotFound.
func (*Store) Heartbeat ¶
func (s *Store) Heartbeat(ctx context.Context, owner string, leases []job.Lease, until time.Time) (job.Renewal, error)
Heartbeat extends the unexpired leases held by owner whose fencing token still matches to until, and splits them into kept and cancel-requested. A lease in neither list expired, was reaped, or was claimed again (a newer token): the worker fences it.
func (*Store) List ¶
List pages newest first over the (created_at, id) keyset. Limit 0 means job.DefaultListLimit and anything above job.MaxListLimit is clamped; the returned cursor is zero when there is no next page.
func (*Store) ListSchedules ¶
func (s *Store) ListSchedules(ctx context.Context, afterName string, limit int32) ([]job.Schedule, error)
ListSchedules pages by name ascending: rows with name > afterName, up to limit (0 means job.DefaultListLimit, clamped at job.MaxListLimit).
func (*Store) Now ¶
Now returns the database clock. Callers that pass "now" into Heartbeat, ReapExpired or DueSchedules read it here, never from time.Now.
func (*Store) PruneEvents ¶
PruneEvents deletes job_events rows older than olderThan on the database clock (the reaper's retention step, paired with Purge) and returns the count.
func (*Store) Purge ¶
Purge deletes finished jobs (and their events) matching q, oldest first, and returns the count. Empty Statuses means succeeded, cancelled and dead; OlderThan is measured from finished_at on the database clock.
func (*Store) ReapExpired ¶
func (s *Store) ReapExpired(ctx context.Context, now time.Time, limit int32, p job.Policy) ([]job.Event, error)
ReapExpired requeues up to limit leases expired at now with a backoff from the job's own policy (p when the stored one is unreadable), marks them dead at max_attempts or cancelled when a cancel was requested, and returns the transitions it made. One transaction: SELECT ... FOR UPDATE SKIP LOCKED, one batched UPDATE, the events.