Skip to content
yadidiah.k
← Writing & notes

note · N5 · updated 2026-08

Backend patterns for AI work

AI calls are slow, expensive, and unreliable. The backend around them needs durable jobs, worker-owned state, retry-safe writes, and a way to recover when a queue drops a message.

Backend · Durable jobs · Reliability — 8 min

A request handler that calls a model inline blocks for seconds, can't be retried safely, and loses its work if the process restarts. The fix is to treat AI work as a durable job and move it off the request path.

AI work is a job, not a function call

The request creates a row, returns 202 Accepted, and a worker does the work. The job's state lives in Postgres so a client can poll for it and an operator can inspect it.

POST /jobs/{id}/process   ->  202, job stays queued

queued -> dispatching -> running -> succeeded
                                 \-> failed

running means a worker took ownership — the route never sets it.
succeeded / failed are final and worker-owned.

Persist attempt metadata with the job — attempt count, started_at, completed_at, token usage, cost — so a stuck or repeatedly-failing job is debuggable from the row alone.

The dual-write problem

The route needs to do two things: record the job in Postgres and enqueue it in Redis. If the DB write succeeds and the enqueue fails, the job exists but nothing will ever run it.

  • First mitigation: an explicit dispatching state. If the enqueue throws, roll the job back to queued and return 503. The gap is visible and recoverable.
  • Stronger fix — the outbox pattern: the route writes the job state and an outbox_events row in one Postgres transaction. A separate dispatcher process reads pending events and delivers them to the queue, retrying independently. Business state and delivery intent commit together or not at all.
API        writes job + outbox event   (one transaction)
dispatcher reads pending events -> enqueues -> marks delivered / failed
worker     pulls from queue -> runs job -> writes result to Postgres

Postgres is the source of truth. Redis is delivery.

Idempotency keys

A client that times out will retry a POST it isn't sure landed. Without protection, that creates a second job. With an Idempotency-Key header, the server hashes method + path + body against the stored key: same key and same request replays the original response; same key and a different request is a 409.

Workers own their own resources

A background worker opens its own database session and builds its own repositories and service objects inside the worker boundary. Request-scoped dependencies must not leak into background work — their session lifecycle is tied to a request that has already returned.

Provider calls need budgets

  • Explicit timeout per call — set at the client/adapter boundary, not scattered through services.
  • Bounded retries — use the provider client's retry system, cap it (e.g. ≤ 5), and validate the config at startup. Unbounded retries multiply cost and hide outages.
  • A stable adapter interface — services depend on SummaryProcessor / EmbeddingProvider, never on a vendor SDK class. Tests use fakes and never hit the network by default.

Health vs readiness

GET /health     process is alive          — do NOT fail it on a dependency outage
GET /readiness  can serve real traffic     — checks Postgres (SELECT 1), Redis (PING)
                                              returns 503 if a required dep is down

Logs you can query

Structured JSON logs with a per-request X-Request-ID carried through contextvars, so one failing call can be traced end to end: method, path, status, duration, request id, and — for AI jobs — job id, status, attempt count, timestamps.

The recurring rule: the API accepts and records work; the worker executes it; Postgres holds the truth; Redis only moves messages. Every failure mode above is a version of "what happens if this step succeeds but the next one doesn't".

Working notes, kept accurate against implementation. Corrections welcome — say so.

Next noteDeep learning, stated precisely →