Stage 7 · Master
Background Jobs & Async Work
Worker Pools in Go
Goroutines stay cheap right up until unbounded background work makes memory growth your real queue.
Why Not a Goroutine per Job
Go makes concurrency cheap enough that the first wrong solution looks elegant: fetch a job, go handle(job), repeat forever. That works until the queue is faster than the work. Then you have not built concurrency; you have built unbounded admission. Fieldwork's background jobs are not academic examples. They are task export generation, stale-session cleanup, webhook delivery, attachment metadata processing, and eventually outbox relay. Several of those touch Postgres, Redis, or object storage. If we let every available job spawn immediately, the true queue moves from Postgres into heap memory and open connections. The process still looks busy, but the system is less controllable than before.
We rejected goroutine per job for the same reason we rejected handler-level response caching earlier: it hides the real capacity boundary. Fieldwork needed bounded concurrency, explicit queueing, and a place to apply backpressure when downstream dependencies were saturated. A worker pool is not exciting architecture. That is exactly why it is the right one here. It makes the system admit work at a rate we can reason about.
| Approach | Why it looks good at first | Why Fieldwork rejected or chose it |
|---|---|---|
| Spawn a goroutine for every claimed job | Minimal code and great demo throughput | No hard bound on memory, connection use, or in-flight retries |
| One giant serial worker | Easy correctness reasoning | Leaves available CPU and I/O parallelism unused |
| Bounded worker pool | More plumbing up front | Chosen because concurrency becomes explicit and tunable per process |
The moment jobs enter your process, they are consuming memory and operational attention. A bounded worker pool keeps the queue where it belongs and limits how much damage one burst can do.
The Shape of Fieldwork's Pool
The pool has three moving pieces: a fetch loop that claims ready jobs from Postgres, a buffered channel that represents local capacity, and a fixed number of workers that execute one job at a time. The buffer size is not arbitrary. It is set to a small multiple of the worker count so the fetcher can stay ahead without hoarding thousands of jobs in memory. That was a deliberate rejection of prefetch-heavy designs borrowed from message brokers. Fieldwork's database-backed queue already stores the backlog durably. Local prefetch should be about smoothing, not ownership.
Notice what the pool does not do: it does not know how to fetch from the queue, it does not know retry policy, and it does not open a new goroutine inside Run. Those omissions are design choices. If the runner reintroduces unconstrained fan-out, the pool becomes decorative. If retry logic lives inside the pool, every job kind inherits the same failure policy whether that makes sense or not. The pool's job is concurrency control. Nothing more.
- Keep local queue size small enough that durable backlog stays in Postgres, not memory.
- Make worker count explicit configuration so different job processes can run different concurrency profiles.
- Treat
Submitblocking as useful pressure, not a bug to be bypassed with more goroutines.
Backpressure, Errors, and Observability
Backpressure is the point of the design, so we have to admit it visibly. When the local channel is full, the fetch loop stops claiming more jobs. That means the durable queue length rises in Postgres. Good. That is an honest signal that downstream capacity is saturated. The rejected alternative was to keep claiming jobs and rely on a larger in-memory buffer. That only delays the same reality while making restarts more painful. We also emit metrics that match the boundaries we actually care about: jobs claimed, local queue depth, active workers, job duration by kind, and job failures by kind. Without those, bounded concurrency is hard to tune and easy to cargo-cult.
If dashboarding only shows local worker utilization, you miss the real symptom. The system can look healthy at 100% busy while the durable backlog is silently growing for hours.
Where the Pool Fits
The worker pool sits between job acquisition and job-specific business logic. That sounds mundane, but it keeps concerns separate. Queue semantics — scheduling, retries, next run time, leasing — belong to the queue layer. Business logic — send webhook, generate export, process outbox row — belongs to the runner. The pool only decides how many of those may happen concurrently in one process. That boundary also lets us run multiple worker deployments with different profiles. An attachment-processing worker might run with low concurrency because it streams to object storage. An outbox relay might run with higher concurrency because most of its work is short network writes.
| Layer | Owns | Does not own |
|---|---|---|
| Queue | Claiming, leases, retries, scheduling | Per-process concurrency |
| Worker pool | Bounded local execution | Business-specific retry semantics |
| Runner | Job-specific logic and instrumentation | Global queue ordering |
The Decision
Fieldwork uses a bounded worker pool built from channels and a fixed number of goroutines. We rejected unbounded go per job execution because it moves overload into memory and connections, where it is harder to reason about and harder to recover from. The pool is intentionally narrow in responsibility: accept jobs only when there is local capacity, run them with explicit concurrency, and expose the metrics that tell us when that concurrency is wrong. The goal is not maximum theoretical throughput. The goal is controlled, observable throughput that degrades honestly when the rest of the system is under pressure.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.