The queue behind our own ports
Parent: events
Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.
The upper layers (domains, the admin panel, MCP) depend only on our own interfaces: events.Recorder, and jobs.Jobs, jobs.Inspector and jobs.Runtime in internal/infra/jobs. River is the current implementation behind the job ports; a later adapter would not change any upper-layer code. The transport is swappable. The outbox is not: whatever runs underneath, an event is first written to the outbox in the same transaction as the domain change.
| Interface | Methods | Implementations |
|---|---|---|
events.Recorder (fixed) | With(tx) Recorder, Record(ctx, ownerID, type, subject, data) error | the outbox writer (Postgres), the only one |
jobs.Jobs | With(tx) Jobs, Enqueue(ctx, kind, args, EnqueueOpts) (JobID, error) | River adapter |
jobs.Inspector | Overview(kind), List(filter), Get, Retry, Cancel, Periodic, RunPeriodic | River adapter |
jobs.Runtime | Jobs + Inspector + Wait(ctx, id, max), Start, Stop | River adapter |
jobs.Handler | func(ctx, args json.RawMessage) error, idempotent | job kinds and subscriptions |
- There is no separate
Transportinterface. Subscriptions are data (events.Subscription) that the bus turns into job kinds; the relay enqueues throughjobs.Jobs. Another transport would replace the relay insideinternal/infra/events; no domain code would change. - Job kinds (
jobs.Kind) and periodic jobs (jobs.Periodic{Name, Every, Run}) are data, handed to the runtime at construction. There is noPeriodic(...)registration method. EnqueueOptshasRunAt(schedule later) andUniqueByArgs(one job per args, used by webhook fan-out).- States:
pending,running,retryable,completed,discarded,cancelled.
The rules that keep it swappable
- Strings and JSON only.
typeandkindare strings;dataandargsare JSON. On River, kinds stay strings through one raw-JSON args type whoseKindAliases()lists every declared kind (internal/infra/jobs/river/worker.go). River's generic arg types never leak upward. - The contract does not change with the implementation: at-least-once delivery, idempotent handlers, no ordering across events.
- A gate keeps River behind the port.
check-queue-behind-port.shfails whengithub.com/riverqueue/**is imported outsideinternal/infra/jobs/river(tests included). No exclusion list. - A conformance suite states the contract. About 27 UTs cover the
Jobs/Inspector/Runtimecontract. They run against River now and against any future implementation unchanged. The suite plus a green full e2e acceptance is the proof that upper layers do not notice.
Where it lives
internal/infra/jobs holds the ports, the queue table, the failure classes and DefaultBackoff. internal/infra/jobs/river is the adapter: River's migrator (jobsriver.Migrate, run at boot right after pgstore.Migrate), the worker, periodic jobs, the inspector and Wait. Neither knows a domain. internal/infra/events holds the Recorder, the relay, retention and webhook signing (event-model).
River in brief
River (riverqueue.com) v0.47 is a Go job queue that runs on Postgres. It is pgx/v5 native. Its license is MPL-2.0, which is compatible with AGPL.
| River feature | What we use it for |
|---|---|
InsertTx (transactional insert) | a job commits with the change that caused it |
SKIP LOCKED claims | workers on many processes never take the same job |
| LISTEN/NOTIFY wake-up | jobs start without waiting for a poll |
| retries with backoff | the single retry owner (retry-has-one-owner) |
| unique-by-args inserts | one webhook.deliver per (endpoint, event); not used for coalescing (relay-claims-rows-not-cursor) |
| leader-elected periodic jobs | the periodic jobs run once, not once per process (the relay is not one of them) |
| rescuer | a job stuck in running past the threshold is rescued and retried |
| job cleaner | completed rows go after 24 h, discarded after 7 days (River's defaults) |
| job rows as a queryable log | the tasks-panel reads them |
- No new service: River adds a few tables (
river_job,river_leader, …) to the Postgres we already run. - River owns its DDL: its own migrator creates the tables at boot, and
schema.sqldoes not copy them (a copy would drift on the next River upgrade). - River UI is not adopted: it is a separate service with its own auth. The tasks-panel follows its information layout on dispatcher ops.
Why a queue on Postgres at all: why-not-a-broker.