Help us shape the future of Go! Take the 2026 Go Developer Survey

postgres

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 20, 2026 License: MIT Imports: 21 Imported by: 0

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

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

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

View Source
const (
	// DefaultPollFloor is how often Wait ticks regardless of notifications.
	DefaultPollFloor = 200 * time.Millisecond
)

Notifier defaults.

View Source
const NotifyChannel = "jobrun_ready"

NotifyChannel is the LISTEN/NOTIFY channel; the payload is the queue name.

Variables

This section is empty.

Functions

func EffectKey

func EffectKey(j job.Job) (scope, key string)

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

func Migrate(ctx context.Context, pool *pgxpool.Pool) error

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.

func Open

func Open(ctx context.Context, url string, maxConns int32, log *slog.Logger) (*pgxpool.Pool, error)

Open parses url, applies the production pool limits (maxConns connections, 30m lifetime, 30s health check) and returns a pinged pool.

func OpenConfig

func OpenConfig(ctx context.Context, cfg *pgxpool.Config, log *slog.Logger) (*pgxpool.Pool, error)

OpenConfig opens a pool from an already-built config (tests use it to set a per-test search_path) and pings it before returning. The pool is closed again if the ping fails.

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.

const (
	// EventsAll records every transition, the enqueue event included.
	EventsAll EventMode = iota
	// EventsTerminal records only transitions into a terminal status
	// (succeeded, dead, cancelled): enqueue, claim and retry rows are skipped.
	EventsTerminal
)

func ParseEventMode

func ParseEventMode(s string) (EventMode, error)

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

func NewNotifier(pool *pgxpool.Pool, log *slog.Logger, opts ...NotifierOption) *Notifier

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

func (n *Notifier) Ready(ctx context.Context, queue string) error

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.

func (*Notifier) Run

func (n *Notifier) Run(ctx context.Context) error

Run holds the listener connection until ctx is done, reconnecting with backoff (1s doubling to 30s) after any failure. It returns nil on ctx cancellation; the poll floor covers every gap.

func (*Notifier) Wait

func (n *Notifier) Wait(ctx context.Context, queue string) <-chan struct{}

Wait returns a channel that receives (coalesced, one pending signal at most) whenever queue is notified and at least every poll floor; it is closed when ctx is done.

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

func WithCommitTimeout(d time.Duration) Option

WithCommitTimeout sets the statement_timeout of the completion transaction; 0 disables it. The default is DefaultCommitTimeout.

func WithEventMode

func WithEventMode(m EventMode) Option

WithEventMode selects which transitions are written to job_events (JOB_EVENTS).

func WithFIFOEvery

func WithFIFOEvery(n int) Option

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

func WithNotifyChannel(name string) Option

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 NewStore

func NewStore(pool *pgxpool.Pool, log *slog.Logger, opts ...Option) *Store

NewStore returns a Store over pool.

func (*Store) AdvanceSchedule

func (s *Store) AdvanceSchedule(ctx context.Context, id job.ID, lastRun, nextRun time.Time) error

AdvanceSchedule sets last_run and next_run after a materialisation; job.ErrScheduleNotFound when missing.

func (*Store) Cancel

func (s *Store) Cancel(ctx context.Context, id job.ID) (job.Job, error)

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

func (s *Store) Claim(ctx context.Context, q job.ClaimQuery) ([]job.Job, error)

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

func (s *Store) Complete(ctx context.Context, l job.Lease, owner string, fn job.Commit) error

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

func (s *Store) CreateSchedule(ctx context.Context, sc job.Schedule) (job.Schedule, error)

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

func (s *Store) DeleteSchedule(ctx context.Context, id job.ID) error

DeleteSchedule removes a schedule; job.ErrScheduleNotFound when missing.

func (*Store) Depth

func (s *Store) Depth(ctx context.Context) ([]job.DepthRow, error)

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

func (s *Store) EffectDone(ctx context.Context, scope, key string) (bool, error)

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

func (s *Store) Enqueue(ctx context.Context, j job.Job) (job.Job, error)

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

func (s *Store) EnqueueBatch(ctx context.Context, js []job.Job) (int64, error)

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

func (s *Store) Events(ctx context.Context, id job.ID) ([]job.Event, error)

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) Get

func (s *Store) Get(ctx context.Context, id job.ID) (job.Job, error)

Get returns one job or job.ErrNotFound.

func (*Store) GetSchedule

func (s *Store) GetSchedule(ctx context.Context, id job.ID) (job.Schedule, error)

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

func (s *Store) List(ctx context.Context, q job.ListQuery) ([]job.Job, job.Cursor, error)

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

func (s *Store) Now(ctx context.Context) (time.Time, error)

Now returns the database clock. Callers that pass "now" into Heartbeat, ReapExpired or DueSchedules read it here, never from time.Now.

func (*Store) PruneEvents

func (s *Store) PruneEvents(ctx context.Context, olderThan time.Duration) (int64, error)

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

func (s *Store) Purge(ctx context.Context, q job.PurgeQuery) (int64, error)

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.

func (*Store) Retry

func (s *Store) Retry(ctx context.Context, id job.ID) (job.Job, error)

Retry moves a dead, cancelled or failed job back to queued with attempts=0 and run_at=now(), records the event and notifies the queue; anything else is job.ErrNotRetryable.

func (*Store) UpdateSchedule

func (s *Store) UpdateSchedule(ctx context.Context, sc job.Schedule) (job.Schedule, error)

UpdateSchedule replaces cron, timezone, template, enabled and next_run of the schedule with sc.ID (the name is immutable); job.ErrScheduleNotFound when missing.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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