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

job

package
v1.9.3 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 14 Imported by: 2

Documentation

Overview

Package job defines the job entity, state machine, typed definitions, and store interface.

Job Entity

A Job represents a unit of work. It embeds dispatch.Entity for timestamps, carries a typed payload (JSON), and progresses through a state machine:

pending → running → completed
pending → running → retrying → running → ...
pending → running → failed
pending → running → failed → dlq
pending → cancelled

Fields of note:

  • Queue: which queue the job belongs to (default: "default")
  • Priority: higher values are dequeued first
  • MaxRetries / RetryCount: controls retry budget
  • RunAt: earliest time the job may be dequeued
  • Timeout: per-job execution deadline (zero = unlimited)

Defining a Job

Use Definition with a typed handler. The payload is JSON-serialized at enqueue time and deserialized before the handler runs:

var SendEmail = job.NewDefinition("send_email",
    func(ctx context.Context, input EmailInput) error {
        return mailer.Send(input.To, input.Subject, input.Body)
    },
)

Registry

Registry maps job names to type-erased HandlerFunc values. Register definitions at startup via RegisterDefinition:

job.RegisterDefinition(registry, SendEmail)
job.RegisterDefinition(registry, GenerateReport)

The engine package provides higher-level engine.Register and engine.Enqueue wrappers.

Index

Constants

View Source
const DefaultLeaseTTL = 30 * time.Second

DefaultLeaseTTL is how long a lease survives without renewal when neither the definition nor the pool specifies otherwise. It matches the historical Config.StaleJobThreshold so that adopting leases does not change reclamation timing for an existing deployment.

View Source
const UnleasedReclaimGrace = 15 * time.Minute

UnleasedReclaimGrace is how long a running job carrying no lease at all must have been silent before reclamation will adopt it.

It exists because a null expiry has two very different causes and the reclaim predicate cannot tell them apart from the expiry alone. One is a job left running by a build that predates leases, which nothing will ever look at again: Lease.IsExpired reports false for a zero expiry, the pool stopped calling ReapStaleJobs for a store implementing LeaseStore, and dequeue claims only pending and retrying rows. The other is a live, perfectly healthy job, because DequeueOpts.Grants() is false whenever LeaseUntil is zero, so any caller claiming through Store without lease options holds a running job with no lease by design.

Silence is what separates them, which is why every backend gates the exception on heartbeat_at, falling back to started_at for a worker that died before its first beat, rather than on the null expiry alone. A row with neither timestamp is never adopted: there is nothing to measure age against, and guessing would mean guessing against a running job.

The value is arbitrary and an operator cannot tune it, which is worth saying plainly rather than burying. ReclaimExpiredLeases(ctx, limit) carries no threshold, and widening that signature would change all five backends. Fifteen minutes is chosen to be conservative rather than precise: before leases these same rows were reaped at Config.StaleJobThreshold, 30 seconds by default, so any value well above that is strictly less aggressive than what already shipped. Overshooting costs only how long an abandoned job waits to come back.

Variables

View Source
var (
	// ErrLeaseLost means the worker no longer holds the lease it tried to
	// act on: the job was reclaimed, reassigned, or deleted while the
	// worker believed it was still running it. A worker receiving this must
	// stop working on the job immediately — someone else owns it now.
	ErrLeaseLost = errors.New("dispatch/job: lease lost")

	// ErrLeaseWithoutWorker means a dequeue asked for a lease
	// (DequeueOpts.LeaseUntil) without naming the worker that would hold
	// it. It is a programming error, not a degenerate case: RenewLease
	// matches on worker ID, so a lease held by the zero worker can never
	// be renewed and the job would be claimed and reclaimed on every
	// cycle forever. Backends refuse the claim rather than granting it.
	ErrLeaseWithoutWorker = errors.New("dispatch/job: DequeueOpts.LeaseUntil set without WorkerID")
)

Lease sentinels.

These live in job rather than the root dispatch package — where every other sentinel lives — because the root package already exports ErrLeadershipLost for cluster leadership. A sibling ErrLeaseLost for job leases one line away would be a standing invitation to grab the wrong one. Qualified as job.ErrLeaseLost, the call site is unambiguous.

Functions

func RegisterDefinition

func RegisterDefinition[T any](r *Registry, def *Definition[T])

RegisterDefinition registers a typed job definition. The generic handler is wrapped in a closure that JSON-unmarshals the payload into T before calling the typed handler.

This is a package-level generic function because Go does not allow generic methods on non-generic receiver types.

Types

type CountOpts

type CountOpts struct {
	// Queue filters by queue name. Empty means all queues.
	Queue string
	// State filters by job state. Empty means all states.
	State State
}

CountOpts controls filtering for job count queries.

type Definition

type Definition[T any] struct {
	// Name is the unique identifier for this job type.
	Name string

	// Handler is the function that processes the job payload.
	Handler func(ctx context.Context, payload T) error

	// Opts configures retries, queue, priority, and timeout.
	Opts Options
}

Definition is a typed job definition with a handler function. T is the payload type (must be JSON-serializable).

func NewDefinition

func NewDefinition[T any](name string, handler func(ctx context.Context, payload T) error, opts ...Option) *Definition[T]

NewDefinition creates a typed job definition.

func (*Definition[T]) JobName

func (d *Definition[T]) JobName() string

JobName returns the name this definition registers under.

func (*Definition[T]) Policy

func (d *Definition[T]) Policy() exec.Policy

Policy returns this definition's execution declaration.

func (*Definition[T]) Register

func (d *Definition[T]) Register(r *Registry)

Register adds the definition's handler to the registry.

type DequeueOpts

type DequeueOpts struct {
	// Queues restricts the claim to these queue names.
	//
	// An EMPTY list is not a portable request and callers should not
	// send one. The backends genuinely disagree, and the disagreement
	// predates this option:
	//
	//	memory    claims from every queue
	//	postgres  claims nothing (queue = ANY(NULL) matches no row)
	//	sqlite    claims nothing, by an explicit early return before the
	//	          query is built
	//	redis     claims nothing — the index is one sorted set per queue
	//	          name and there has never been a cross-queue index
	//	mongo     claims nothing, by an explicit early return
	//
	// Mongo's guard is the one that had to be added: {queue: {$in: nil}}
	// marshals to BSON null and the server rejects the whole query with
	// "$in needs an array", so an empty list was a hard error rather
	// than any behaviour at all. It returns empty to match the three
	// backends a real deployment would be running, rather than inventing
	// an all-queues scan that no other persistent backend performs.
	//
	// The conformance suite does not exercise an empty list and no
	// caller in this repository sends one. Unifying the five would be a
	// behaviour change to store/memory and is deliberately not made
	// here; documenting the split is what stops a caller assuming it.
	Queues []string

	// Limit is the maximum number of jobs to claim. It counts eligible
	// jobs only: a job excluded by Budget or CustomKeys must not consume
	// a slot, or one oversized job at the head of the queue would starve
	// a worker that had capacity for everything behind it.
	//
	// Limit <= 0 claims nothing and returns (nil, nil); this is unified
	// across all five backends, checked before Validate.
	Limit int

	// Budget is the free capacity the caller is offering, in canonical
	// units (cpu millicores, memory and disk bytes, gpu milli-devices).
	// A job is eligible on a dimension when its requirement is <= the
	// budget for that dimension.
	//
	// An ABSENT key is unconstrained, not zero. This inverts
	// resource.Set.Fits, which treats absent capacity as zero, and the
	// inversion is deliberate: a worker that declares only memory must
	// still claim GPU-requiring jobs, because otherwise adding a
	// dimension to one worker's config would silently strand work on
	// every worker that had not been updated yet.
	//
	// A key present with the value zero is a real constraint — a worker
	// with no free memory — and excludes any job requiring more than
	// zero of it. That is why IsUnbounded tests key presence rather than
	// resource.Set.IsZero.
	//
	// Custom keys in Budget are ignored by the predicate. Quantity
	// matching on a custom dimension would need a document comparison or
	// a join table in five backends to serve a rare case; offer custom
	// keys through CustomKeys instead.
	Budget resource.Set

	// CustomKeys are the custom resource keys the caller offers,
	// typically free.CustomKeys(). A job is eligible only if every custom
	// key it requires appears here.
	//
	// An empty list is read against IsUnbounded rather than on its own,
	// because "no custom keys" means two different things to two
	// different callers. If the whole of o is unbounded, the caller does
	// not use the resource model at all and claims everything, custom
	// keys included — that is the backward-compatibility guarantee. If o
	// is bounded in any other way, an empty list means this worker
	// genuinely has no custom resources, and a job requiring an fpga must
	// not be handed to it.
	//
	// Reading an empty list as unconstrained in the bounded case would
	// let a resource-aware worker claim work it cannot possibly run;
	// reading it as "offers nothing" in the unbounded case would strand
	// every custom-key job in the fleet the day this option shipped.
	//
	// Only key containment is tested at dequeue; the quantity is enforced
	// locally after the claim, by the admission path that already owns
	// the accounting. Matching a quantity here would need a document
	// comparison or a join table in five backends to serve a rare case.
	//
	// Backends match against the delimited string
	// resource.EncodeCustomKeys produced at enqueue, whose leading and
	// trailing separators are what stop ",fpga," matching a worker that
	// only offers ",fpga-large,". The test is subset, not substring: a
	// job needing {fpga, tpu} is eligible for a caller offering
	// {fpga, nvme, tpu}, even though the offered list interleaves a key
	// the job does not want.
	CustomKeys []string

	// PreferHashes are PrimaryInputHash values the caller already has
	// staged locally. A job whose PrimaryInputHash appears here sorts
	// ahead of jobs at the same priority, saving a re-download.
	//
	// Backends must bind PreferredHashes rather than this field: an
	// empty string here is not a locality signal and must not become one.
	//
	// This is advisory and must NEVER filter, and must never outrank
	// priority: locality that could reorder across priority bands would
	// let a steady stream of locally cached work starve the high-priority
	// job the pool exists to run first. The full ordering is priority
	// descending, then preferred before unpreferred, then RunAt
	// ascending.
	//
	// It is deliberately NOT a term of IsUnbounded. If it were, opts
	// carrying only PreferHashes would count as bounded, and the empty
	// CustomKeys rule would then reject every custom-resource job — this
	// field would filter transitively, contradicting the paragraph above.
	PreferHashes []string

	// ReservedFor restricts the claim to a single job. When set, no other
	// job may be returned, and that job is still subject to every other
	// constraint here — a targeted claim that could bypass Budget would
	// reintroduce exactly the overcommit this predicate prevents.
	ReservedFor *id.JobID

	// WorkerID is the worker taking the lease. It is required when
	// LeaseUntil is set and ignored otherwise.
	WorkerID id.WorkerID

	// LeaseUntil, when non-zero, makes the claim grant a lease: the
	// claimed rows get WorkerID, this expiry, and an incremented
	// lease_epoch, in the same statement that claims them.
	//
	// A zero value grants no lease and leaves every lease column
	// untouched, which is exactly how DequeueJobs behaved before leases
	// existed. That is the backward-compatibility guarantee: a caller
	// that does not opt in cannot be affected by this.
	//
	// The grant must be part of the claim, not a second write, and the
	// reason is the opposite of the obvious one. Reclamation cannot
	// rescue a half-granted job on the timing that leases exist to
	// provide: Lease.IsExpired reports false for a zero ExpiresAt
	// precisely so the reclaim loop never steals a job that was never
	// leased. A crash between a claim and a separate grant would leave a
	// row running with no expiry, and the fine-grained lease machinery
	// cannot see it at all.
	//
	// Such a row is recovered eventually, because reclamation adopts an
	// unleased running job once it has been silent for
	// UnleasedReclaimGrace, but that clause is a coarse compatibility
	// fallback measured in minutes, not the per-job TTL a lease buys. A
	// half-granted job would wait it out, which is precisely the coarse
	// stale-job behaviour leases exist to replace.
	//
	// One write means a claimed job always carries a lease something can
	// act on promptly.
	LeaseUntil time.Time
}

DequeueOpts narrows a dequeue to the jobs the caller can actually run.

The fit predicate lives in the query rather than in the worker because DequeueJobs claims a job and marks it running atomically: by the time a worker can read a job's requirements it already owns it. Filtering after the claim would mean requeueing, and a 32 GB job would then bounce between small workers, burning a write on every bounce and delaying exactly the job that is hardest to place. Every constraint here must therefore be evaluated as part of the claim, never applied to the rows the claim returned.

func (DequeueOpts) Allows

func (o DequeueOpts) Allows(j *Job) bool

Allows reports whether j satisfies every constraint in o except Queues, Limit, and ordering. It is the executable definition of the fit predicate: backends that select candidates in Go should call it instead of reimplementing the rules, and backends that express the predicate in their query language must return the same answer for every job.

It must be applied BEFORE the claim. Claiming a job and then rejecting it here is not an implementation of this contract — it is the claim-then-requeue behaviour the whole option exists to avoid.

func (DequeueOpts) Grants

func (o DequeueOpts) Grants() bool

Grants reports whether o asks the claim to grant a lease. Backends test intent through this rather than open-coding the zero check five times.

func (DequeueOpts) IsUnbounded

func (o DequeueOpts) IsUnbounded() bool

IsUnbounded reports whether o restricts WHICH jobs may be claimed.

A dequeue asks two independent questions, and this answers only the first:

should I filter?  — Budget, CustomKeys, ReservedFor. IsUnbounded.
should I order?   — PreferHashes. len(o.PreferHashes) > 0.

Do not reuse this for the second. A backend skips the fit predicate when IsUnbounded is true, and separately adds the locality term to its ORDER BY whenever PreferHashes is non-empty. A caller that sets only PreferHashes therefore gets locality ordering over an unfiltered candidate set, which is precisely what "advisory, never a filter" means. Folding PreferHashes in here would make it bounded, and the empty-CustomKeys rule would then reject every custom-resource job on its behalf.

It tests Budget for key presence rather than calling resource.Set.IsZero: a Budget of {"memory": 0} is an exhausted worker, which must claim nothing that needs memory. Treating it as unbounded would hand that worker a job it cannot run.

func (DequeueOpts) Less

func (o DequeueOpts) Less(a, b *Job) bool

Less orders two eligible jobs the way every backend must return them: priority descending, then preferred-by-locality before not, then RunAt ascending. Ties beyond that are unspecified.

func (DequeueOpts) OfferedCustomKeys

func (o DequeueOpts) OfferedCustomKeys() []string

OfferedCustomKeys returns CustomKeys sorted, for backends that build a delimited parameter and need a stable, deduplicated order.

func (DequeueOpts) PreferredHashes

func (o DequeueOpts) PreferredHashes() []string

PreferredHashes returns the locality hashes worth matching on: deduplicated, in caller order, with the empty string dropped. It is what every backend must bind, never PreferHashes itself.

The empty string is the case that matters. Prefers reports false for a job with no PrimaryInputHash, so an empty entry offers no information — but primary_input_hash is a plain string column, and a job that was never hashed stores ” rather than NULL on the SQL backends. Bound verbatim, ” = ANY('{""}') is TRUE, so a single empty entry would make EVERY unhashed job "locally staged" and hand it the head of its priority band. Under a tight Limit that is not a reordering, it is a filter: the jobs the caller actually has staged stop being claimed at all. Mongo already stripped empties; postgres and sqlite bound them.

func (DequeueOpts) Prefers

func (o DequeueOpts) Prefers(j *Job) bool

Prefers reports whether j's PrimaryInputHash is one the caller already has staged. It is the sort key backends apply after priority.

func (DequeueOpts) Validate

func (o DequeueOpts) Validate() error

Validate reports whether o is a coherent request, and is the one place every backend checks before it writes anything.

The only incoherent combination is a grant with no holder. A lease granted to the zero worker can never be renewed, because RenewLease matches on worker ID — so the job would be claimed, left to expire, and reclaimed on every cycle forever, which presents as a queue that never drains rather than as an error. Refusing the claim turns a silent livelock into a caller bug reported at the call that caused it.

type HandlerFunc

type HandlerFunc func(ctx context.Context, payload []byte) error

HandlerFunc is a type-erased job handler that accepts raw JSON payload. The typed Definition[T] is converted to a HandlerFunc at registration time by closing over JSON unmarshal + the typed handler.

type Job

type Job struct {
	dispatch.Entity

	ID          id.JobID      `json:"id"`
	Name        string        `json:"name"`
	Queue       string        `json:"queue"`
	Payload     []byte        `json:"payload"`
	State       State         `json:"state"`
	Priority    int           `json:"priority"`
	MaxRetries  int           `json:"max_retries"`
	RetryCount  int           `json:"retry_count"`
	LastError   string        `json:"last_error,omitempty"`
	ScopeAppID  string        `json:"scope_app_id,omitempty"`
	ScopeOrgID  string        `json:"scope_org_id,omitempty"`
	WorkerID    id.WorkerID   `json:"worker_id,omitempty"`
	RunAt       time.Time     `json:"run_at"`
	StartedAt   *time.Time    `json:"started_at,omitempty"`
	CompletedAt *time.Time    `json:"completed_at,omitempty"`
	HeartbeatAt *time.Time    `json:"heartbeat_at,omitempty"`
	Timeout     time.Duration `json:"timeout,omitempty"`

	// ArtifactBindings carries the encoded map of declared input names to
	// artifact refs. It travels with the job because Payload is opaque to
	// the engine: bindings placed inside it would be invisible to the
	// scheduler and to the staging middleware.
	ArtifactBindings []byte `json:"artifact_bindings,omitempty"`

	// Resources is the resolved requirement, computed once at enqueue.
	// Scheduling reads this rather than calling user code.
	Resources resource.Set `json:"resources,omitempty"`

	// ResourceLimits is the resolved enforcement ceiling.
	ResourceLimits resource.Set `json:"resource_limits,omitempty"`

	// ResourceClass is forwarded to the isolation backend uninterpreted.
	ResourceClass string `json:"resource_class,omitempty"`

	// InputBytes is the total size of the declared artifact inputs. It
	// is the estimator's primary feature and the measurement bucket key.
	InputBytes int64 `json:"input_bytes,omitempty"`

	// PrimaryInputHash is the content hash of the largest declared
	// input, used as the locality-scheduling signal. Often empty: the
	// artifact plane fills content_hash at first staging, not at
	// registration, so locality helps from an artifact's second use on.
	PrimaryInputHash string `json:"primary_input_hash,omitempty"`

	// LeaseEpoch is the fencing token for the current lease. It increments
	// on every grant and every reclamation. RenewLease, the grant inside
	// DequeueJobs, ReclaimExpiredLeases, and UpdateLeasedJob all check
	// it, so a worker holding a stale epoch fails to renew — and the pool
	// cancels the job within one heartbeat interval — or has its terminal
	// write refused outright. UpdateJob does not check it: that is a
	// whole-row write with no epoch predicate, so a caller that wants the
	// fence must use UpdateLeasedJob instead.
	//
	// Without the renewal check, a worker resuming from a long GC pause
	// would carry on renewing a lease on a job another worker now owns.
	LeaseEpoch int `json:"lease_epoch"`

	// LeaseExpiresAt is when the current lease lapses if not renewed.
	// Nil means no lease is held.
	LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`

	// LeaseTTL is how long each renewal extends the lease for this job,
	// copied at enqueue from the definition's WithLeaseTTL, or from the
	// enqueue site's when that supplies one. Zero means the pool's
	// default.
	//
	// This is what makes per-definition thresholds work: a 30-second job
	// and a six-hour job carry different values on their own rows, so one
	// reclaim query serves both.
	LeaseTTL time.Duration `json:"lease_ttl,omitempty"`

	// EvictCount is how many times this job has lost a worker to
	// infrastructure — a reclaimed lease, or later a graceful drain. It is
	// deliberately separate from RetryCount: a preempted job has not
	// failed, and charging preemptions to the retry budget would send a
	// healthy job to the DLQ having never once errored.
	EvictCount int `json:"evict_count"`
}

Job represents a unit of work to be processed by a worker.

func (*Job) ClearOwnership

func (j *Job) ClearOwnership()

ClearOwnership drops every field recording who was running the job and under what lease, so the row can safely go back to a runnable state.

Any path returning a job to pending from outside the lease machinery has to call this, and forgetting to is not a cosmetic bug. UpdateJob writes the whole row on every backend, lease columns included, so a stale LeaseExpiresAt survives the transition. The row is then pending with an expiry already in the past, which is harmless right up until the job is claimed by a caller that does not grant a lease (DequeueOpts.Grants() is false whenever LeaseUntil is zero). That claim writes state and worker but never touches the expiry, so the job lands in running carrying a lapsed lease and the very next sweep reclaims it. It is claimed and reclaimed forever, never running to completion, with EvictCount climbing on every pass.

LeaseEpoch is deliberately left alone. It is a fencing token and must never move backwards, and there is nothing to fence here: a job being returned to pending by this path is not running, so no holder exists to invalidate. ReclaimExpiredLeases increments it because it is taking the job away from a live holder, which is a different situation.

type Lease

type Lease struct {
	// ExpiresAt is when the lease lapses if not renewed. A zero value
	// means no lease has been granted.
	ExpiresAt time.Time
}

Lease is the grant a worker holds over a running job.

It carries only the expiry, because that is the single question the backends ask of it: IsExpired is the one authority on whether a lease has lapsed, and the zero-means-never-leased rule below is the reason that question cannot simply be asked of a time.Time inline. The holder and the fencing token live on the job row itself, as Job.WorkerID and Job.LeaseEpoch, which is where every writer already reads them.

func (Lease) IsExpired

func (l Lease) IsExpired(now time.Time) bool

IsExpired reports whether the lease has lapsed as of now.

A zero ExpiresAt reports false: no lease was ever granted, which is "not held" rather than "expired". Reporting true would let the reclaim loop steal jobs that were never leased.

Reclamation does eventually take such a job, but never through this function. It applies a separate and much coarser rule, gated on the row having gone silent for UnleasedReclaimGrace, precisely so that the question this function answers stays "is a lease lapsed" rather than blurring into "is a job abandoned". The two are not the same, and the backends depend on them staying separate.

type LeaseStore

type LeaseStore interface {
	// RenewLease extends the lease to leaseUntil, but only if the job is
	// still running, still assigned to workerID, and still at epoch.
	//
	// It returns ErrLeaseLost when that condition does not hold. That
	// return is the entire fencing mechanism: a worker that was reclaimed
	// while paused learns it no longer owns the job within one heartbeat
	// interval, instead of continuing to write for hours.
	RenewLease(
		ctx context.Context,
		jobID id.JobID,
		workerID id.WorkerID,
		epoch int,
		leaseUntil time.Time,
	) error

	// ReclaimExpiredLeases returns to pending every running job whose
	// lease has expired, clearing the worker assignment, incrementing
	// lease_epoch to fence the previous holder, and incrementing
	// evict_count. RetryCount is never touched — a lost lease is
	// infrastructure, not a handler failure.
	//
	// It also adopts a running job carrying no lease at all, but only
	// once that job has been silent for UnleasedReclaimGrace. See that
	// constant for why the exception exists and why it is gated on
	// silence rather than on the missing expiry alone.
	//
	// The claim and the read are one atomic statement, so two pools
	// reclaiming concurrently cannot both take the same job.
	//
	// A non-positive limit claims nothing and returns (nil, nil), checked
	// before any query runs. This matches DequeueOpts.Limit, so the two
	// methods on this interface agree.
	//
	// ORDER IS NOT PART OF THE CONTRACT, and the backends genuinely
	// differ. Postgres, SQLite and Mongo take the longest-expired jobs
	// first, because each already reads through an index on the expiry
	// and ordering it costs nothing. Memory iterates a map and Redis
	// walks an unordered set member list, so both return an arbitrary
	// subset.
	//
	// This only becomes visible when limit is smaller than the number of
	// jobs eligible at that instant, where the ordered backends drain
	// oldest-first and the other two drain arbitrarily. Nothing starves
	// either way, since each pass moves what it takes out of the eligible
	// set and the reaper runs on a timer.
	//
	// Redis is the reason this is documented rather than unified. It can
	// stop scanning as soon as it has claimed `limit` jobs; ordering would
	// force every call to read the entire job-id set first, turning a
	// bounded scan into a full one on the largest deployments, which is a
	// steep price for an ordering no caller has asked for.
	ReclaimExpiredLeases(ctx context.Context, limit int) ([]*Job, error)

	// UpdateLeasedJob persists j only while the caller still holds the
	// lease.
	//
	// The write applies iff the row is still running, still assigned to
	// workerID, and still at epoch. Otherwise it returns ErrLeaseLost and
	// leaves the row untouched — the lease has moved on, and a worker
	// that no longer owns a job has no coherent claim to make about it.
	//
	// It writes the same columns as UpdateJob EXCEPT the lease-owned
	// ones: lease_epoch, lease_expires_at, worker_id, and heartbeat_at
	// are never written here. Those have exactly three writers — the
	// grant in DequeueJobs, RenewLease, and ReclaimExpiredLeases — and
	// this method deliberately is not a fourth.
	//
	// j is the caller's claim-time (or otherwise stale) snapshot, so
	// j.LeaseExpiresAt is whatever the expiry was when this worker last
	// read the row — every renewal since has pushed the real value
	// forward. A whole-row write that passed the epoch predicate would
	// still roll the expiry backwards, shortening the current holder's
	// lease by however long this caller ran. Excluding the lease-owned
	// columns is what makes the epoch predicate a genuine fence rather
	// than a check that only looks like one.
	//
	// A missing row returns dispatch.ErrJobNotFound rather than
	// ErrLeaseLost: zero rows affected by the fence predicate means "the
	// lease moved on", but a row that no longer exists was never a
	// question of who holds it.
	UpdateLeasedJob(ctx context.Context, j *Job, workerID id.WorkerID, epoch int) error
}

LeaseStore is the opt-in lease capability.

It is deliberately not part of Store. A backend that implements Store alone keeps compiling and keeps behaving exactly as it does today, reaped on the pool's single global threshold. A backend that also implements LeaseStore gets per-definition lease TTLs, epoch fencing, and atomic reclamation. This mirrors the capability idiom the artifact backend already uses for RangeReader and Presigner.

Where an expiry is passed at all — RenewLease here, and DequeueOpts.LeaseUntil for the grant — it is an absolute timestamp rather than a TTL. If the store computed now+ttl it would need per-dialect interval arithmetic over a nanosecond integer, and SQLite, Mongo, and Redis have no interval type at all. Passing a timestamp means every backend only writes a value, and lease policy lives in one place. ReclaimExpiredLeases takes no expiry: it acts on the ones already written.

The GRANT is deliberately not here. It travels on DequeueOpts instead (WorkerID and LeaseUntil), so a leased claim is an ordinary claim that also writes the lease columns and therefore carries the budget, the custom-key containment, the locality preference and the reservation like any other. A second dequeue entry point taking (queues, limit) existed here until the two paths had to be kept in sync by hand; it carried none of those, so turning leases and resources on together — the natural upgrade — claimed jobs that could not fit and requeued them on every poll. This interface is what a backend adds ON TOP of Store to make that grant renewable and reclaimable.

type ListJobsOpts

type ListJobsOpts struct {
	States     []State
	Queue      string
	NamePrefix string
	ScopeAppID string
	ScopeOrgID string

	// Cursor is the ID of the last job on the previous page. Empty is the
	// first page. See package paging for the ordering contract.
	Cursor string

	// Limit is the page size; zero or less means paging.DefaultLimit.
	Limit int
}

ListJobsOpts filters and pages ListJobs.

Every string filter is an exact match except NamePrefix, which is a case-sensitive literal prefix. An empty value means "no filter": an empty States lists every state, and an empty ScopeAppID lists every app. The second is pinned by the conformance suite because it is the dangerous one: the dashboard is operator-wide on purpose, and a caller that meant to scope must pass the scope.

func (ListJobsOpts) Match

func (o ListJobsOpts) Match(j *Job) bool

Match reports whether j passes every filter in o. The cursor and limit are not filters and are ignored. Backends that filter in Go share this so the predicate cannot drift between them.

type ListOpts

type ListOpts struct {
	// Limit is the maximum number of jobs to return. Zero means no limit.
	Limit int
	// Offset is the number of jobs to skip.
	Offset int
	// Queue filters by queue name. Empty means all queues.
	Queue string
}

ListOpts controls pagination and filtering for job list queries.

type Lister

type Lister interface {
	ListJobs(ctx context.Context, opts ListJobsOpts) (Page, error)
}

Lister is the paged, filtered job read the dashboard uses. It is a separate interface from Store so backends could gain it one at a time; store.Store embeds it once all five have.

type Option

type Option func(*Options)

Option is a functional option for configuring a job definition.

func WithArtifactInputs

func WithArtifactInputs(specs ...artifact.InputSpec) Option

WithArtifactInputs declares the artifact inputs a job consumes.

The engine validates every binding against these declarations at enqueue and stages the declared inputs before the handler runs.

func WithExecution

func WithExecution(opts ...exec.PolicyOption) Option

WithExecution declares the isolation this job's handler requires.

It mirrors WithArtifactInputs: the exec package builds the value and job adapts it, which is what keeps exec a leaf that never imports job.

func WithLeaseTTL

func WithLeaseTTL(d time.Duration) Option

WithLeaseTTL sets how long this job's lease survives without renewal.

It works on a definition and at an enqueue, and the enqueue wins: a definition declares the TTL its work needs, and a caller who knows this particular job is different overrides it, the same way WithResources overrides a declared requirement.

A lease TTL is a liveness window, not a time limit: it should be a small multiple of the heartbeat interval regardless of how long the work takes. Non-positive durations are ignored, because a zero TTL would expire the lease the instant it was granted and the job would be reclaimed before its first heartbeat.

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries sets the maximum number of retry attempts.

func WithPriority

func WithPriority(p int) Option

WithPriority sets the job priority. Higher values are processed first.

func WithQueue

func WithQueue(q string) Option

WithQueue sets the queue name for the job.

func WithResourceClass

func WithResourceClass(class string) Option

WithResourceClass sets an opaque scheduling class for the isolation backend. Core stores and forwards it without interpretation.

func WithResourceFunc

func WithResourceFunc(fn resource.ResourceFunc) Option

WithResourceFunc computes the requirement from the job's input.

This is what lets one definition serve a 40 MB model and a 4 GB one: the artifact plane knows the input size at enqueue, so the function sees it before the job is ever scheduled.

func WithResourceLimits

func WithResourceLimits(sets ...resource.Set) Option

WithResourceLimits sets the enforcement ceiling explicitly.

func WithResources

func WithResources(sets ...resource.Set) Option

WithResources declares the resources a job needs. Multiple sets are merged per key, so the common form reads as a list:

job.WithResources(resource.CPUs(4), resource.MemoryGB(16))

Passed at enqueue instead of on the definition, it overrides every other source, including a configured estimator.

func WithRunAt

func WithRunAt(t time.Time) Option

WithRunAt schedules the job for execution at a specific time.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the maximum execution duration for the job.

type Options

type Options struct {
	// MaxRetries is the maximum number of retry attempts before sending to DLQ.
	MaxRetries int

	// Queue is the queue name this job should be enqueued to.
	Queue string

	// Priority determines dequeue ordering. Higher values are processed first.
	Priority int

	// Timeout is the maximum duration a job may run before being cancelled.
	Timeout time.Duration

	// LeaseTTL is how long this job's lease survives without renewal.
	// Zero means the worker pool's default. Set it above the expected gap
	// between heartbeats, not above the expected runtime — a lease is a
	// liveness window, not a time limit.
	LeaseTTL time.Duration

	// RunAt schedules the job for future execution. Zero means immediate.
	RunAt time.Time

	// Inputs declares the artifacts this job consumes. Declaring them,
	// rather than burying refs in the opaque payload, is what lets the
	// engine size the job before scheduling it, validate bindings at
	// enqueue, and stage the bytes before the handler runs.
	Inputs []artifact.InputSpec

	// Bindings maps declared input names to the artifacts supplied at
	// enqueue. The engine validates them against Inputs before the job is
	// persisted.
	Bindings map[string]artifact.Ref

	// Resources declares what this job needs. It is the floor: the
	// engine may raise it via ResourceFunc or a configured estimator,
	// and a per-enqueue WithResources call overrides both.
	Resources resource.Set

	// ResourceLimits is the enforcement ceiling. When unset, memory and
	// the other incompressible keys default to their request and CPU is
	// left unbounded.
	ResourceLimits resource.Set

	// ResourceFunc computes the requirement from the enqueue-time
	// request, for jobs whose footprint scales with their input. It runs
	// once, in the enqueuing process, and never on the scheduling path.
	ResourceFunc resource.ResourceFunc

	// ResourceClass is an opaque scheduling class the isolation backend
	// interprets. Core never reads it.
	ResourceClass string

	// Execution declares the minimum isolation this job's handler
	// requires. The zero value runs in-process, which is what every
	// existing definition gets.
	Execution exec.Policy
}

Options configures per-job behavior such as retries, queue, and priority.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns Options with sensible defaults.

type Page

type Page struct {
	Jobs []*Job

	// NextCursor is the cursor for the following page, or empty when there
	// is none. It is always set when Complete is false.
	NextCursor string

	// Complete is false when a backend stopped scanning at its budget
	// before it could fill the page or prove there was nothing left. The
	// page is then not evidence that no more rows match: continue from
	// NextCursor.
	//
	// Only a backend that stops at a scan budget (redis) ever reports
	// false. The SQL and document backends always finish the search and
	// report true, and that is not a promise that the call is cheap: a
	// selective NamePrefix uses no index on postgres (starts_with), sqlite
	// (substr) or mongo (anchored regex), so a rare or prefix-only filter
	// can walk the whole table before it finds limit+1 matches or reaches
	// the end. Bound list calls with a context deadline.
	Complete bool
}

Page is one page of ListJobs, newest first.

type Registrable

type Registrable interface {
	// Register adds this definition's handler to the registry.
	Register(r *Registry)

	// JobName returns the name the definition registers under.
	JobName() string

	// Policy returns the execution declaration, so a caller can check
	// that the deployment can satisfy it before registering anything.
	Policy() exec.Policy
}

Registrable is a job definition that can register itself into a Registry without the caller knowing its payload type.

Go forbids generic methods, but a method on a generic type is legal, so Definition[T] can satisfy this non-generic interface. That is what lets definitions with different payload types live in one slice — and a slice is what an out-of-process entrypoint can be handed, since it cannot be given the engine that would otherwise do the registering.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry maps job names to type-erased handler functions. It is safe for concurrent use.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates an empty job registry.

func (*Registry) Get

func (r *Registry) Get(name string) (HandlerFunc, bool)

Get returns the handler for the given job name. Returns false if no handler is registered.

func (*Registry) Inputs

func (r *Registry) Inputs(name string) []artifact.InputSpec

Inputs returns the artifact declarations for a job, or nil when it declares none.

The slice is copied, matching the copy RegisterDefinition makes on the way in: a caller that mutated the stored declaration would change what every future job of that name validates against. InputSpec is all value fields, so a shallow copy is a complete one.

func (*Registry) LeaseTTL

func (r *Registry) LeaseTTL(name string) time.Duration

LeaseTTL returns the lease TTL a definition declared, or zero when it declared none.

Zero is not a sentinel this has to distinguish: it is what Job.LeaseTTL already means everywhere downstream — Pool.leaseTTLFor falls through to the pool default, then the stale-job threshold, then job.DefaultLeaseTTL — so an unregistered name and a definition that declared nothing correctly resolve the same way.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns all registered job names.

func (*Registry) Policy

func (r *Registry) Policy(name string) exec.Policy

Policy returns the execution declaration for a job. An unregistered name yields a default policy rather than a zero one, so callers always get a usable grace period.

func (*Registry) Resources

func (r *Registry) Resources(name string) ResourceDecl

Resources returns the resource declaration for a job, or the zero ResourceDecl when it declares none.

The sets are cloned on the way out as well as in: returning the stored maps by reference would let one caller's mutation rewrite the requirement every future job of that name resolves from.

type ResourceDecl

type ResourceDecl struct {
	// Requests is the declared requirement. It is a floor: the engine may
	// raise it, and a per-enqueue override replaces it per key.
	Requests resource.Set
	// Limits is the declared enforcement ceiling, if any.
	Limits resource.Set
	// Func computes the requirement from the enqueue-time request.
	Func resource.ResourceFunc
	// Class is the opaque scheduling class for the isolation backend.
	Class string
}

ResourceDecl is what a definition declares about its resource needs, captured at registration.

The declaration has to be reachable by job name because the typed definition is gone by the time a job is enqueued through EnqueueRaw, exactly as with Inputs.

func (ResourceDecl) IsZero

func (d ResourceDecl) IsZero() bool

IsZero reports whether the definition declares nothing about resources, which is how every job behaves before this feature is used.

type State

type State string

State represents the lifecycle state of a job.

const (
	// StatePending means the job is waiting to be picked up by a worker.
	StatePending State = "pending"
	// StateRunning means a worker is currently executing the job.
	StateRunning State = "running"
	// StateCompleted means the job finished successfully.
	StateCompleted State = "completed"
	// StateFailed means the job failed and will not be retried.
	StateFailed State = "failed"
	// StateRetrying means the job failed but is scheduled for retry.
	StateRetrying State = "retrying"
	// StateCancelled means the job was explicitly cancelled.
	StateCancelled State = "cancelled"
)

type Store

type Store interface {
	// EnqueueJob persists a new job in pending state.
	EnqueueJob(ctx context.Context, j *Job) error

	// DequeueJobs atomically claims up to opts.Limit ready jobs from
	// opts.Queues that fit opts, sets them to running, and returns them
	// ordered by priority descending, then locality-preferred first, then
	// RunAt ascending.
	//
	// The fit test is part of the claim, not a filter over claimed rows.
	// A job that does not fit stays pending and untouched, available to
	// the next worker that does have room for it.
	//
	// When opts.Grants() the same statement also grants a lease: the
	// claimed rows get opts.WorkerID, opts.LeaseUntil, and an incremented
	// lease_epoch, and the returned jobs carry the epoch they were
	// granted. The grant travels in the claiming write itself, never as a
	// follow-up: a row left running with no expiry is outside the lease
	// machinery entirely, so a crash between two writes would leave the
	// job waiting out the far coarser UnleasedReclaimGrace instead of the
	// TTL it was given. See DequeueOpts.LeaseUntil.
	//
	// Opts that do not grant leave every lease column untouched. A grant
	// with no WorkerID is refused with ErrLeaseWithoutWorker and claims
	// nothing.
	//
	// Every backend must pass storetest.RunDequeueSuite, which is the
	// contract this signature only sketches.
	DequeueJobs(ctx context.Context, opts DequeueOpts) ([]*Job, error)

	// GetJob retrieves a job by ID.
	GetJob(ctx context.Context, jobID id.JobID) (*Job, error)

	// UpdateJob persists changes to an existing job.
	UpdateJob(ctx context.Context, j *Job) error

	// DeleteJob removes a job by ID.
	DeleteJob(ctx context.Context, jobID id.JobID) error

	// ListJobsByState returns jobs matching the given state.
	ListJobsByState(ctx context.Context, state State, opts ListOpts) ([]*Job, error)

	// HeartbeatJob updates the heartbeat timestamp for a running job,
	// indicating the worker is still alive.
	HeartbeatJob(ctx context.Context, jobID id.JobID, workerID id.WorkerID) error

	// ReapStaleJobs returns running jobs whose last heartbeat is older than
	// the given threshold, indicating the worker may have crashed.
	ReapStaleJobs(ctx context.Context, threshold time.Duration) ([]*Job, error)

	// CountJobs returns the number of jobs matching the given options.
	CountJobs(ctx context.Context, opts CountOpts) (int64, error)
}

Store defines the persistence contract for jobs.

type Usage

type Usage struct {
	ID      id.UsageID `json:"id"`
	JobID   id.JobID   `json:"job_id"`
	Name    string     `json:"name"`
	Queue   string     `json:"queue"`
	Attempt int        `json:"attempt"`

	// Status is the terminal state this attempt reached. Failed attempts
	// are recorded too: a job that OOMs is the single most informative
	// data point about how much memory it needed.
	Status State `json:"status"`

	// InputBytes totals the artifacts bound to the job, or zero when it
	// declared none.
	InputBytes int64 `json:"input_bytes"`

	// Resources is what was predicted, empty when the resource model is
	// off.
	Resources resource.Set `json:"resources,omitempty"`

	// Measured consumption. Every field is zero when the rung that ran
	// the attempt could not account it: the in-process rung knows only
	// wall time, because a handler sharing the worker's address space has
	// no separable RSS.
	WallTime    time.Duration `json:"wall_time"`
	CPUTime     time.Duration `json:"cpu_time,omitempty"`
	PeakRSS     int64         `json:"peak_rss,omitempty"`
	DiskWritten int64         `json:"disk_written,omitempty"`

	// Executor names the rung that ran the attempt, so measurements taken
	// under different isolation are not silently pooled.
	Executor string `json:"executor,omitempty"`

	ScopeAppID string      `json:"scope_app_id,omitempty"`
	ScopeOrgID string      `json:"scope_org_id,omitempty"`
	WorkerID   id.WorkerID `json:"worker_id,omitempty"`
	RecordedAt time.Time   `json:"recorded_at"`
}

Usage is what one attempt actually consumed, recorded against what was predicted for it.

The pairing is the point. Resources is the estimate the enqueuing process computed; WallTime, CPUTime, PeakRSS, and DiskWritten are what the attempt really used. Without both on the same row there is no way to tell a good estimate from a lucky one, and no way to improve either.

InputBytes is recorded separately because it is the feature an estimator regresses on: for the workloads this exists to serve, a job's footprint is mostly a function of how big its input was.

type UsageListOpts

type UsageListOpts struct {
	// Name filters to one job definition. Empty means all.
	Name string
	// Since bounds the query to attempts recorded at or after this time.
	Since time.Time
	// Limit caps the rows returned. Zero means no limit.
	Limit int
	// Offset skips rows, for paging.
	Offset int
}

UsageListOpts filters a usage query.

type UsageRecorder

type UsageRecorder interface {
	// RecordJobUsage persists one attempt's measurements.
	RecordJobUsage(ctx context.Context, u *Usage) error

	// ListJobUsage returns recorded attempts, most recent first.
	ListJobUsage(ctx context.Context, opts UsageListOpts) ([]*Usage, error)

	// PurgeJobUsage deletes records older than before, up to limit rows,
	// and returns how many it removed.
	//
	// This table grows with every attempt the fleet makes, so something
	// has to bound it. Retention is the operator's call, which is why the
	// cutoff is a parameter rather than a policy baked in here.
	PurgeJobUsage(ctx context.Context, before time.Time, limit int) (int64, error)
}

UsageRecorder is an optional store capability: backends that can retain per-attempt measurements implement it, and the worker records through it when they do.

It is deliberately not part of the composite Store. Usage is telemetry, not correctness — a backend without it runs jobs exactly the same way, it just accumulates no history to size them from later. Making it optional is what lets a backend gain the capability without every other backend having to gain it at the same moment.

Callers must treat every method as best effort. A failure to record what a job consumed must never fail the job that consumed it.

Directories

Path Synopsis
Package jobtest provides shared conformance suites for the optional job store capabilities.
Package jobtest provides shared conformance suites for the optional job store capabilities.

Jump to

Keyboard shortcuts

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