go / job queues

I ported my Ruby job queue to Go. It uses the same Postgres table in a production jobs service:

Modest needs

The service has ~40 queues. Most invoke rate-limited third-party APIs: Apollo, GitHub, Discord, Slack, Anthropic, Pitchbook. I do not need high throughput or parallelism within a queue. Processing one job at a time keeps each queue inside its provider's rate limit. It also prevents a slow job from causing a thundering herd.

The table

The table uses the schema from the Ruby version:

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

The Ruby version used a PollWorker base class. Go has no inheritance, so the shared mechanics live in one Poller struct. Each queue supplies two functions: Dispatch routes a job name to a handler, and an optional Throttle sets the sleep duration 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 poll loop recovers stale jobs on boot. It then fetches and processes pending rows until the context is cancelled:

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

	if err := p.DB.Exec(ctx, qInterruptStartedJobsInQueue, p.Queue); err != nil {
		log.Printf("queue=%s interrupt stale jobs error: %v", p.Queue, err)
	}

	log.Printf("queue=%s poll=%s", p.Queue, interval)

	for {
		select {
		case <-ctx.Done():
			return
		default:
		}

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

		rows, err := p.DB.Query(ctx, qFetchPendingJobs, p.Queue)
		// ... collect rows, then:
		for _, job := range jobs {
			p.WorkOnce(ctx, job)
			if ctx.Err() != nil {
				return
			}
		}
	}
}

WorkOnce claims a job, runs the dispatch within a per-job deadline, and finalizes the job in a deferred block that also catches panics. It writes the status string, latency, and duration to one greppable log line:

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)
			captureSentry(job, fmt.Errorf("panic: %v", r))
		}
		if err := p.DB.Exec(ctx, qFinalizeJob, status, job.ID); err != nil {
			log.Printf("queue=%s job=%s id=%d finalize error: %v", p.Queue, job.Name, job.ID, err)
		}
		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) {
			captureSentry(job, workErr)
		}
		status = "err: " + workErr.Error()
	}
}

WorkOnce is exported so tests can run one job without starting the poll goroutine.

The claim uses a conditional update, so a worker cannot claim a re-interrupted job twice after a crash:

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 worker

Each queue package exports NewWorker, which returns a configured Poller. The Anthropic queue, the caches queue, and the other queues each define their own dispatch switch. 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 is a generic function that decodes the JSONB payload and adds a prefix to any parse error:

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 you can test rate-limit policies without a clock. The Apollo queue targets 150 jobs a minute. It backs off when a status reports an exhausted header:

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 sets Throttle to nil and runs jobs continuously. For example, the caches queue recomputes materialized data:

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

The Ruby version used fork to start one process per worker. The Go version runs one goroutine per poller in a single process. The main function stores the workers as a list of constructors. Adding a new queue requires one line. The composition root configures dependencies without managing control flow:

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 maximum size of the Postgres pool to one connection per poller plus a small buffer. Each poller then runs in its own goroutine:

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

signal.NotifyContext cancels the context on SIGTERM. Each poll loop finishes its active job and returns, which allows clean deploys. If a hard kill leaves a job in the started state, the poller resets it to err: interrupted on the next boot.

Enqueuing

Insert writes one row with ON CONFLICT DO NOTHING and records the file and line number of the caller 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 nil, 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)
	}

	rows, err := db.Query(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 ids
}

The callsite path is relative to the module root. The package computes this path once from the build-time path of insert.go. It outputs apollo/refresh_person.go:88 regardless of which worktree built the binary.

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 data, and each cadence is a predicate over time.Time:

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, which prevents duplicate jobs if a previous job is still pending.

Maintenance

A scheduled jobs.CleanUpQueues job deletes old rows to limit the size of the table and its indexes. Old rows also provide idempotency: a handler can query 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

The return signature defines the entire protocol. A handler returns (status, nil) for an expected terminal outcome. It returns ("", err) for an unexpected failure to send to Sentry. The poller converts the error into an err: ... status. It skips Sentry capture for expected errors, such as a cancelled context or a known long-job timeout.

← All articles