go / job queues

This is the Go port of my Ruby job queue, running the same Postgres table in an EDS jobs service:

Modest needs

The service has ~40 queues. Most invoke rate-limited third-party APIs: Apollo, GitHub, Discord, Slack, Anthropic, Pitchbook. I don't need high throughput or parallelism within a queue. Processing one at a time keeps each queue inside its provider's rate limit and makes a slow job impossible to interleave into a thundering herd.

The table

The same schema the Ruby version uses:

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

Ruby had a PollWorker base class. Go has no inheritance, so the shared mechanics live in one Poller struct and each queue fills in two functions: a Dispatch that routes a job name to a handler, and an optional Throttle that decides 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 poll loop recovers stale jobs on boot, then fetches and works 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 inside a per-job deadline, and finalizes in a deferred block that also covers panics. The status string, latency, and duration land on 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 a queue's tests can drive one job without spinning up the poll goroutine.

The claim is a conditional update, so a crashed job that a boot re-interrupts cannot be claimed 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 worker

Each queue package exports a NewWorker that returns a configured Poller. The Anthropic queue, the caches queue, and the rest each own their dispatch switch. Dependencies (an API client) are captured in the closure:

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 that decodes the JSONB payload and wraps the parse error with a stable prefix:

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
}

The Throttle is a pure function, so a queue's rate-limit policy is unit-testable without a clock. Apollo aims for 150 jobs a minute and backs off the matching window 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 leaves Throttle nil and runs back to back. 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 forked one process per worker. Go runs one goroutine per poller inside a single process. main holds the workers as a list of constructors, so a new queue adds one line and the composition root wires dependencies, not 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 sizes the Postgres pool cap at one connection per poller plus a small buffer, then each poller runs on 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 in-flight job and returns, so a deploy drains cleanly. Any job that a hard kill leaves started is reset to err: interrupted when its poller boots again.

Enqueuing

Insert writes one row with ON CONFLICT DO NOTHING 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 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 is resolved relative to the module root, computed once from the build-time path of insert.go, so it reads apollo/refresh_person.go:88 regardless of which worktree built the binary.

Scheduling

A Clock ticks every minute and inserts any 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, so 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 uses the same ON CONFLICT DO NOTHING, so a job still pending from the last tick is not duplicated.

Maintenance

A scheduled jobs.CleanUpQueues prunes old rows to keep the table and its indexes bounded. The old rows double as an idempotency guard: a handler can query for recent work before calling 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 carries the whole protocol. A handler returns (status, nil) for an expected terminal outcome that should finalize, and ("", err) for an unexpected failure worth capturing to Sentry. The poller turns the error into an err: ... status and skips the capture for expected cases like a cancelled context or a known long-job timeout.

← All articles