go / job queues

My job queue runs in a production jobs service on one Postgres table:

Queues

The service has ~40 queues. Most call a rate-limited API: Apollo, GitHub, Discord, Slack, Anthropic, Pitchbook. One job at a time keeps a queue inside its rate limit.

The table

CREATE TABLE jobs (
  id SERIAL,
  queue text NOT NULL,
  name text NOT NULL,
  args jsonb DEFAULT '{}' NOT NULL,
  status text DEFAULT 'pending'::text NOT NULL,
  callsite text,
  created_at timestamp DEFAULT now() NOT NULL,
  started_at timestamp,
  finished_at timestamp
);

The shared poller

One Poller struct holds the shared code. Each queue supplies Dispatch, which routes a job name to a handler, and an optional Throttle, which sets the sleep between jobs.

type ThrottleFunc func(status string, err error, elapsed time.Duration) time.Duration

type DispatchFunc func(ctx context.Context, job PendingJob) (string, error)

type PendingJob struct {
	ID   int64  `db:"id"`
	Name string `db:"name"`
	Args []byte `db:"args"`
}

type Poller struct {
	Queue        string
	DB           *pgdb.DB
	Dispatch     DispatchFunc
	JobTimeout   time.Duration
	Throttle     ThrottleFunc
	PollInterval time.Duration
}

The loop marks stale jobs on boot, then works pending rows until the context ends. An enqueue wakes it through Wake (see go / wakeups), and the interval is the fallback:

func (p *Poller) Poll(ctx context.Context) {
	interval := p.PollInterval
	if interval <= 0 {
		interval = defaultPollInterval
	}

	// Every started row on this queue belongs to the process that
	// died, so the startup sweep takes them whatever their age.
	p.interruptStarted(ctx, 0)
	lastSweep := time.Now()

	// reported holds whether the last fetch failure reached sockeye.
	reported := false

	for {
		if time.Since(lastSweep) >= staleSweepInterval {
			p.interruptStarted(ctx, p.jobTimeout()+staleSweepMargin)
			lastSweep = time.Now()
		}

		jobs, err := p.fetch(ctx)
		if ctx.Err() != nil {
			return
		}
		if err != nil {
			log.Printf("queue=%s fetch error: %v", p.Queue, err)
			if !reported {
				errs.CaptureException(errs.Wrap(err, "fetch pending jobs"))
				reported = true
			}
		} else {
			reported = false
		}

		for _, job := range jobs {
			p.WorkOnce(ctx, job)
			if ctx.Err() != nil {
				return
			}
		}
		if len(jobs) > 0 {
			continue
		}

		select {
		case <-p.Wake:
		case <-time.After(interval):
		case <-ctx.Done():
			return
		}
	}
}

A database that nobody can read fails every cycle, on every queue. One error event for each run of failures says as much as eighty a minute, so reported resets only after a fetch succeeds.

WorkOnce claims a job, dispatches it with a deadline, and finalizes it in a deferred block that also catches a panic. It writes one log line per job:

func (p *Poller) WorkOnce(ctx context.Context, job PendingJob) {
	var latency float64
	err := p.DB.QueryRow(ctx, qClaimJob, job.ID).Scan(&latency)
	if err != nil {
		log.Printf("queue=%s job=%s id=%d claim error: %v", p.Queue, job.Name, job.ID, err)
		return
	}

	start := time.Now()
	var status string
	var workErr error

	defer func() {
		if r := recover(); r != nil {
			status = fmt.Sprintf("err: panic: %v", r)
			captureErr(job, fmt.Errorf("panic: %v", r))
		}
		p.finalize(ctx, job, status)
		elapsed := time.Since(start)
		log.Printf("queue=%s job=%s id=%d status=%q latency=%.2fs duration=%.2fs",
			p.Queue, job.Name, job.ID, status, latency, elapsed.Seconds())

		if p.Throttle != nil {
			if delay := p.Throttle(status, workErr, elapsed); delay > 0 {
				select {
				case <-time.After(delay):
				case <-ctx.Done():
				}
			}
		}
	}()

	jobCtx, cancel := context.WithTimeout(ctx, p.jobTimeout())
	defer cancel()

	status, workErr = p.Dispatch(jobCtx, job)
	if workErr != nil {
		if shouldCaptureDispatchError(job, workErr) {
			captureErr(job, workErr)
		}
		status = "err: " + workErr.Error()
	}
}

I export WorkOnce so a test runs one job without the poll loop.

The claim is a conditional update, so a worker cannot claim a job twice:

UPDATE jobs
SET started_at = now(), status = 'started'
WHERE id = $1 AND status = 'pending'
RETURNING coalesce(extract(EPOCH FROM now() - created_at)::float8, 0) AS latency;

A claim that finds no row is the ordinary answer: another poller got there first. Any other claim error goes to sockeye.

A lost connection

One night, two jobs lost their database connections in the same second. The job query reported conn closed, and the finalize after it reported write: software caused connection abort. The pool pings an idle connection before it hands it out. The dialer sends a TCP keepalive every 15 seconds. So an idle connection was not the cause. I never found the cause, so the loop now recovers from a lost connection and reports what it cannot recover.

finalize runs the write once more when the failure is the connection, not Postgres:

func (p *Poller) finalize(ctx context.Context, job PendingJob, status string) {
	ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finalizeTimeout)
	defer cancel()

	err := p.DB.Exec(ctx, qFinalizeJob, status, job.ID)
	if err != nil && pgdb.IsConnFailure(err) {
		err = p.DB.Exec(ctx, qFinalizeJob, status, job.ID)
	}
	if err != nil {
		captureErr(job, errs.Wrap(err, "finalize job"))
	}
}

The update sets two columns by primary key, so a second run is safe whether or not the first reached the server. The pool discards the connection that failed, so the second attempt runs on a connection that answered. finalize detaches from ctx, because a deploy cancels ctx in the middle of a job.

IsConnFailure reads any error that did not come from the server as a lost connection. A cancelled context is the caller's own deadline, so it is not a reason to run the statement again:

func IsConnFailure(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	_, fromServer := errors.AsType[*pgconn.PgError](err)
	return !fromServer
}

A finalize that fails twice leaves the row in started. That row is more than a wrong reading on an admin page. Some jobs dedupe with a unique partial index over (queue, name, args) where the status is pending or started. One stuck row then blocks every later enqueue of that job. Before, only the startup sweep cleared it, so the fix cost a deploy. Now the poller sweeps every five minutes while it runs:

UPDATE jobs
SET status = $2, finished_at = now()
WHERE queue = $1
  AND status = 'started'
  AND (
    started_at IS NULL
    OR started_at < now() - make_interval(secs => $3)
  );

The sweep in the loop passes the queue's job timeout plus a minute, because a younger row is the job that the poller runs now.

A worker

Each queue package exports NewWorker, which returns a Poller. The closure captures dependencies such as an API client:

func NewWorker(db *pgdb.DB, client API) *jobs.Poller {
	return &jobs.Poller{
		Queue:    "apollo",
		DB:       db,
		Throttle: throttle,
		Dispatch: func(ctx context.Context, job jobs.PendingJob) (string, error) {
			switch job.Name {
			case "apollo.IngestCompany":
				args, err := jobs.UnmarshalArgs[IngestCompanyArgs](job.Args)
				if err != nil {
					return "", err
				}
				return IngestCompany(ctx, db, client, args)
			case "apollo.RefreshPerson":
				args, err := jobs.UnmarshalArgs[RefreshPersonArgs](job.Args)
				if err != nil {
					return "", err
				}
				return RefreshPerson(ctx, db, client, args)
			default:
				return "", fmt.Errorf("unknown job %q for queue apollo", job.Name)
			}
		},
	}
}

UnmarshalArgs decodes the JSONB payload:

func UnmarshalArgs[T any](raw []byte) (T, error) {
	var args T
	if err := json.Unmarshal(raw, &args); err != nil {
		return args, fmt.Errorf("unmarshal args: %w", err)
	}
	return args, nil
}

Throttle is a pure function, so a test needs no clock. The Apollo queue targets 150 jobs a minute and backs off when a status reports an exhausted rate limit:

func throttle(status string, _ error, elapsed time.Duration) time.Duration {
	minJobTime := time.Minute / maxJobsPerMinute
	for _, p := range rateLimitStatusPrefixes {
		if strings.HasPrefix(status, p.prefix) {
			minJobTime = p.backoff
			break
		}
	}
	if elapsed >= minJobTime {
		return 0
	}
	return minJobTime - elapsed
}

No throttle

A queue with no external API leaves Throttle nil:

func NewWorker(db *pgdb.DB) *jobs.Poller {
	return &jobs.Poller{
		Queue: "caches",
		DB:    db,
		Dispatch: func(ctx context.Context, job jobs.PendingJob) (string, error) {
			switch job.Name {
			case "caches.Age":
				return Age(ctx, db)
			// ...
			default:
				return "", fmt.Errorf("unknown job %q for queue caches", job.Name)
			}
		},
	}
}

The registry

One process runs one goroutine per poller. main holds a list of constructors, so a new queue is one line:

registry = []func(*pgdb.DB) *jobs.Poller{
	func(db *pgdb.DB) *jobs.Poller { return caches.NewWorker(db) },
	func(db *pgdb.DB) *jobs.Poller { return github.NewWorker(db, githubClient) },
	func(db *pgdb.DB) *jobs.Poller { return apollo.NewWorker(db, apolloClient) },
	func(db *pgdb.DB) *jobs.Poller { return anthropic.NewWorker(db, anthropicClient) },
	// ... one line per queue
}

The list length sets the Postgres pool size, one connection per poller plus a small buffer:

pollers := make([]*jobs.Poller, len(registry))
for i, newPoller := range registry {
	pollers[i] = newPoller(db)
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

var wg sync.WaitGroup
for _, p := range pollers {
	wg.Go(func() { p.Poll(ctx) })
}
wg.Wait()

On SIGTERM, each loop finishes its active job and returns. A hard kill leaves a job in started, and the poller resets it to err: interrupted on the next boot.

A test checks the registry against every writer of a job row. A scheduled job, a job name in a SQL file, and a job that an Insert call names must each have a handler. The Insert check parses each Go file with go/ast, reads the queue and name arguments, and fails on a pair that no worker holds. Without it, such a row stays pending, and only a person who looks sees it.

Enqueuing

Insert writes one row and records the caller's file and line as the callsite:

func Insert(ctx context.Context, db *pgdb.DB, queue string, name string, args any) (int64, error) {
	argsJSON, err := json.Marshal(args)
	if err != nil {
		return 0, fmt.Errorf("marshal args: %w", err)
	}

	callsite := "go:0"
	if _, file, line, ok := runtime.Caller(1); ok {
		short := trimToModulePath(file)
		callsite = fmt.Sprintf("%s:%d", short, line)
	}

	var id int64
	err = db.QueryRow(ctx, `
		INSERT INTO jobs (queue, name, callsite, args)
		VALUES ($1, $2, $3, $4::jsonb)
		ON CONFLICT DO NOTHING
		RETURNING id
	`, queue, name, callsite, string(argsJSON)).Scan(&id)
	if errors.Is(err, pgx.ErrNoRows) {
		return 0, nil
	}
	return id, err
}

It returns 0 when the conflict clause inserts nothing.

The callsite is relative to the module root, so it reads apollo/refresh_person.go:88 whatever worktree built the binary.

Every single-row enqueue goes through Insert. I deleted dozens of SQL files that each spelled out the same INSERT, because each one was a place to copy the next enqueue from. A query that enqueues a set of rows from a SELECT stays a SQL file.

A write and the jobs that follow from it share a transaction. A retry then cannot leave the data changed without its follow-up work:

err := db.Transaction(ctx, func(tx *pgdb.DB) error {
	if err := tx.Exec(ctx, qUpdateCompany, id, domain); err != nil {
		return err
	}
	_, err := jobs.Insert(ctx, tx, "apollo", "apollo.IngestCompany", IngestCompanyArgs{ID: id})
	return err
})

args is part of the conflict key. So an enqueue carries only the keys it sets: a blank field would make a second row, not a duplicate.

Scheduling

A Clock ticks every minute and enqueues each scheduled job whose predicate matches the current UTC time:

func (c *Clock) tick(ctx context.Context, t time.Time) {
	for _, job := range schedule {
		if job.at(t) {
			if err := c.db.Exec(ctx, qInsertScheduledJob, job.queue, job.name); err != nil {
				errs.CaptureException(fmt.Errorf("clock job=%s queue=%s: %w", job.name, job.queue, err))
				log.Printf("clock job=%s queue=%s err: %v", job.name, job.queue, err)
				continue
			}
			log.Printf("clock job=%s queue=%s", job.name, job.queue)
		}
	}
}

The schedule is a list, and each cadence is a predicate:

type scheduledJob struct {
	queue string
	name  string
	at    func(time.Time) bool
}

var schedule = []scheduledJob{
	{queue: "caches", name: "caches.Age", at: func(t time.Time) bool {
		return t.Minute()%15 == 0 // every 15m
	}},
	{queue: "pitchbook", name: "pitchbook.IngestRecentDeals", at: func(t time.Time) bool {
		return t.Minute() == 30 && t.Hour()%6 == 0 // 3x/day
	}},
	// ...
}

insert_scheduled_job.sql also uses ON CONFLICT DO NOTHING, so the Clock does not enqueue a second copy of a job that is still pending.

Maintenance

A scheduled jobs.CleanUpQueues job deletes old rows. The rows it keeps let a handler check for recent work before it calls a paid API.

func CleanUpQueues(ctx context.Context, db *pgdb.DB) (string, error) {
	if err := db.Exec(ctx, qDeleteOldJobs); err != nil {
		return "", errs.Wrap(err, "delete old jobs")
	}
	return "ok", nil
}

Status semantics

A handler returns (status, nil) for an expected outcome and ("", err) for a failure. The poller sends the error to sockeye with errs and stores it as an err: ... status. It skips sockeye for a cancelled context.

A small package builds each status string, so each prefix has one definition:

One function reads the prefix back, and every count of outcomes uses it. Before, some jobs recorded an expected answer as err:, which inflated the error count. Other jobs recorded a real fault as err: and returned no error, so nobody saw it. An expired API token was one such fault.

A client returns an unhandled HTTP status as a typed error. On a 401 or a 403, the worker pauses its queue for one hour. An expired token then sends one error event an hour, not one for each job. The restart after a token fix ends the pause.

← All articles