Card-Platform Job Spine

liveThe background-job spine: typed queues over pg-boss in the card-platform database, a dedicated workers service, and transactional enqueue from producers· 2026-06-10
0

Why a Job Spine

Work that should not run inside an HTTP request — SMS sends, slow third-party polls, partner callbacks, daily reports — gets shared rails: a producer writes a job to a durable queue and answers immediately; a separate workers service does the slow part, with retries, dead-lettering, and tracing built in. Every background feature is then a queue definition and a handler.

Waiting for the rails, roughly in order:

Background needShape of the work
SMS / email notifications (wallet-provisioning OTP — first real consumer) Webhook-triggered job: ack Tribe fast, deliver async
3DS TOTP delivery Webhook-triggered job, same ack-fast shape
KYC status polling Recurring poll against a slow third party
Partner callback outbox Retried outbound delivery with backoff
AMF daily reporting Daily cron
Tribe file batches Scheduled batch ingest / processing

The first real consumer is Wallet Provisioning OTP delivery — until it ships, the spine is exercised end-to-end by a temporary hello smoke path (covered below).

1

Architecture — Producer, Queue, Worker

Three parts, no new infrastructure: producers (today events-ingest) enqueue with a send-only client; the queue is pg-boss tables in the pgboss schema of the card-platform Postgres — the same database the domain rows live in; the workers service, a separate deployable, polls and runs handlers.

↓ no new broker — the queue is rows in the same Postgres the domain writes land in ↓
producer (any card-platform service)queue storage (domain database)workers (separate deployable)dead-letter queue

Why the queue lives in the domain database

Because the queue is just tables, enqueueing can join the domain transaction — pg-boss's fromPrisma adapter (re-exported by @setldpay/queue) runs the enqueue INSERT on the same Prisma transaction as the domain write:

ts
import { fromPrisma } from '@setldpay/queue'

// the pattern every real producer will follow — enqueue inside the
// domain transaction, acknowledge the caller only after commit
await prisma.$transaction(async (tx) => {
  const row = await persistNotification(tx, parsed)
  await sender.send(someQueue, { notificationId: row.id }, { db: fromPrisma(tx) })
})

Rollback, and the job vanishes with it — no ghost job pointing at a row that never committed. Commit, and the job is durably committed too — no lost job from a process dying between persist and enqueue. A transactional outbox without building one.

2

How Jobs Run — Queueing, Polling, Concurrency

This section is pg-boss machinery rather than our code, but it is the runtime contract every producer and handler inherits. The wrapper pins the defaults; per-queue overrides go on the worker registration.

The job state machine

Net effect: delivery is at-least-once — expiration redelivery means a job can run twice, so every handler must be idempotent: check whether the work is already done before doing it.

Claiming work — why concurrency is safe

The worker loop — what “every 2 s” means

One Node thread per replica: handlers that await I/O interleave across all queue loops; a CPU-bound handler stalls every loop in the process.

The knobs

KnobValueControls
pollingIntervalSeconds 2 s — runtime default, per-queue override Idle pickup latency and the cycle floor — a fast handler still sleeps out the interval
batchSize 1 — fixed by the runtime Jobs claimed per poll
localConcurrency 1 — runtime default, per-queue override Jobs one worker instance runs at once
retryLimit · retryDelay · retryBackoff · retryDelayMax Per queue, on the definition Retry budget and backoff curve
expireInSeconds 900 s — pg-boss default Max time in active before the attempt counts as failed — the dead-worker recovery clock
deletion / retention 7 d after completion · 14 d max lifetime — pg-boss defaults When maintenance deletes finished jobs and abandons never-fetched ones
warningQueueSize 1 — set on every DLQ Emits the warning event on the first dead-lettered job

Only the workers service runs pg-boss's supervisor (supervise: true) — expiration, retention, and queue monitoring happen there. Senders run none of it.

DLQ jobs keep their payload, so they can be inspected and replayed from the dashboard once the bug is fixed.

3

The Three Primitives — @setldpay/queue

packages/shared/queue wraps pg-boss in three primitives, each owning one side of the contract. Services never import pg-boss directly.

PrimitiveSideOwns
defineQueue shared definition Queue name, TypeBox payload schema, retry/backoff options, DLQ naming (<name>-dlq)
createQueueSender producer Validated, lazy, migrate-free sends — recovers per-send when deployed before the worker
createWorkerRuntime consumer The pgboss schema: migration, queue + DLQ provisioning, validation → DLQ routing, OTel spans, graceful stop

defineQueue — the typed registry

Declared once in card-platform/common and imported by both sides, so payload typing is end-to-end: queue name, TypeBox schema, retry options. Every queue gets <name>-dlq unless it opts out with deadLetter: false.

ts
// packages/card-platform/common/src/queues.ts — the context's queue registry
export const helloQueue = defineQueue({
  name: 'hello',
  schema: Type.Object({
    message: Type.String({ minLength: 1, maxLength: 256 }),
    /** When true the handler throws — exercises retry → DLQ → warning. */
    fail: Type.Optional(Type.Boolean()),
  }),
  options: { retryLimit: 2, retryDelay: 1, retryBackoff: true },
})  // deadLetter defaults to 'hello-dlq'

createQueueSender — send-only, migrate-free

Deliberately powerless: no migration, no maintenance, no scheduling. It validates the payload before sending (fail loud at the producer) and starts lazily on first send. Deployed before the workers service has created the schema? Sends fail — and the sender rebuilds its pg-boss instance per send, so it self-recovers once the worker migrates, no restart.

createWorkerRuntime — owns the schema, runs the handlers

Sole owner of the pgboss schema. Start: migrate (advisory-lock protected) → provision every queue and DLQ, converging options on existing ones → poll. Per job: re-validate the payload (invalid → DLQ), wrap the handler in an OTel span (queue.process <name> with job id, queue, attempt), hand it a child logger with the same context. stop() drains in-flight jobs up to 25 s — under the ECS 30 s stopTimeout.

4

Evolving Payload Schemas

Rolling deploys make producer/consumer version skew routine — jobs enqueued under yesterday's shape meet today's consumer, and vice versa.

RuleWhy
Evolve additively — new fields are Type.Optional with handler-side defaults Jobs already in flight under the old shape still validate under the new schema
Never set additionalProperties: false on a payload schema Deploy-skew tolerance: a not-yet-redeployed consumer must accept a newer producer's payload carrying fields it doesn't know
A breaking shape is a new queue name, not a v2 of the schema The worker registers handlers for both queues until the old one drains, then the old definition is deleted — explicit, cheap, no union-typed handlers
Prefer reference payloads (row ids) over entity data The worker re-reads current state from the database, so shape churn migrates to the DB layer where Prisma migrations already handle it

When a rule is violated anyway, the payload fails consume-side validation and routes to the DLQ intact — visible, alertable, replayable. Never silent loss.

5

The Hello Smoke Path (Temporary)

One knob exercises the whole machine: the payload is message plus an optional fail flag that makes the handler throw.

enqueue POST /dev/hello-jobs on events-ingest

Flag-gated behind ENABLE_DEV_ROUTES — an unauthenticated enqueue vector, so off is the resting state everywhere deployed (on in docker-compose). Validates the body against the queue's own payload schema, sends, returns 202 with the job id.

consume The hello worker handles the job

The workers handler logs the message with job context (job id, queue, attempt) — proof the registry, runtime, polling, and observability path work.

fail path fail: true walks the failure machinery

The handler throws, pg-boss retries twice with backoff (retryLimit: 2), and the job lands on hello-dlq — firing the warning. One request walks retry → DLQ → warning.

6

Local Tooling — Compose and the Dashboard

mise run dev includes the spine: workers (nodemon hot reload, no published port — health probed in-container) and dashboard, the official pg-boss dashboard on host port 3013. The dashboard starts only after workers is healthy, because workers is what migrates the pgboss schema.

bash
mise run dev                  # full local stack — postgres, services, workers, dashboard
mise run queue:dashboard      # pg-boss dashboard against the local compose DB (port 3013)

# against a deployed env — requires --owner until read-only grants land,
# and owner mode refuses to start without the basic-auth env vars
mise run queue:dashboard -- --env dev --owner

Without --env the dashboard points at local compose; with --env <env> it pulls the deployed database URL from Secrets Manager and runs on your dev box — no exposed admin surface. Two interim constraints: --owner is required until read-only grants land, and owner mode refuses to start without basic-auth credentials (PGBOSS_DASHBOARD_AUTH_USERNAME / PGBOSS_DASHBOARD_AUTH_PASSWORD).

7

Deploy State and Ordering

Two ordering rules and one configuration invariant keep producers and the consumer in step:

ConstraintWhy
The workers service deploys (and migrates) before producers enqueue The sender is migrate-free by design — sends fail (and self-recover) until the workers service has created the pgboss schema
Deployed ENABLE_DEV_ROUTES stays off until workers is live — and any temporary enablement is paired with a network-level restriction The dev route is an unauthenticated, internet-facing enqueue vector, and its sends are inert until the schema exists anyway
Producer and consumer must agree on QUEUE_SCHEMA A producer enqueueing into one schema while the worker polls another means jobs are never picked up

The schema-agreement rule is why both services default QUEUE_SCHEMA to pgboss and treat overriding it as a deliberate, paired change.