Outbox Dispatcher — Runbook & Local Verification¶
Inter-module events flow through an in-process dispatcher, not Kafka. The transactional outbox pattern is documented in conventions.md § Transactional Outbox; the @on_event handler convention in conventions.md § Event Handlers. This runbook covers the relay — the OutboxPoller that drains committed outbox rows and dispatches each event to the registered @on_event handlers via the EventDispatcher.
Kafka is deferred until a module is extracted to its own service. When that happens, swap the dispatcher target for a Kafka producer/consumer — the @on_event handler signatures and the published events stay identical, only the transport changes.
Topology¶
┌────────────────────────┐ same SQL txn ┌─────────────────────┐
│ command handler │ ────────────────▶ │ outbox table │
│ + OutboxEventPublisher │ INSERT row + │ status = pending │
└────────────────────────┘ NOTIFY outbox_new └──────────┬──────────┘
│ LISTEN/NOTIFY
▼
┌─────────────────────┐
│ OutboxPoller │
│ FOR UPDATE │
│ SKIP LOCKED │
└──────────┬──────────┘
│ per event:
│ fresh AsyncSession
▼
┌─────────────────────┐
│ EventDispatcher │
│ asyncio.gather over │
│ @on_event handlers │
└─────────────────────┘
All of this runs inside the FastAPI process (src/main.py lifespan starts the poller as a background asyncio task). There are no external broker services.
| Component | Code | Purpose |
|---|---|---|
OutboxEventPublisher |
src/shared/outbox/publisher.py |
Request-scoped; writes an outbox row + NOTIFY outbox_new inside the command's transaction. Injected via EventPublisherDep. |
OutboxPoller |
src/shared/outbox/poller.py |
Background task; claims pending rows, dispatches each event, marks rows completed/failed. |
EventDispatcher |
src/shared/events/dispatcher.py |
Per-event-type handler registry; runs handlers concurrently via asyncio.gather. |
@on_event handlers |
src/modules/*/application/event_handlers.py |
Subscribe to events; imported at startup by load_all_handlers() (src/shared/events/autoload.py). |
Startup wiring¶
src/main.py lifespan, in order:
load_all_handlers()walkssrc/modules/*/application/event_handlers.pyand imports each so the@on_eventdecorators fire and register into the default dispatcher. This must run before the poller starts — otherwise the dispatcher is empty and every outbox row drains tocompletedwithout side effects.OutboxPolleris constructed againstget_jobs_session_factory()(JOBS_DB_URL→ thegrinsystem_jobsrole, which holdsBYPASSRLS). The poller's claim query drains every tenant's events without settingapp.current_tenant, so it must bypass theoutboxtable'stenant_isolationRLS policy. See GRI-152.- The poller runs as a named
asynciotask (outbox-poller) viarun_forever().
Shutdown cancels the poller task before disposing the engine, so the in-flight batch can roll back cleanly. Partially claimed rows are left pending on restart — never half-marked.
Poll loop¶
OutboxPoller.run_forever() repeats: process_batch(), then wait for a NOTIFY or timeout.
- Claim (
_claim_batch):SELECT ... WHERE status = 'pending' ORDER BY created_at LIMIT OUTBOX_BATCH_SIZE FOR UPDATE SKIP LOCKED. TheSKIP LOCKEDmeans any number of pollers can run safely against the same table — peers skip rows another worker already holds. - Dispatch (
_dispatch_one): each event runs in its own freshAsyncSessionminted from the session factory, wrapped in aCONSUMERspan parented on the publishing request's trace. Handlers never share the poller's lock-holding session, so a handler's rollback can't poison the batch's marking transaction. The batch's events are dispatched concurrently viaasyncio.gather(..., return_exceptions=True). - Mark: rows whose handlers all returned cleanly are bulk-updated to
completedwithprocessed_at; failed rows get a per-row update tofailedwithprocessed_at,retry_count + 1, and a truncatederrorstring (OUTBOX_ERROR_MAX_LEN). - Wait (
_wait_for_notify): blocks on PostgresLISTEN outbox_newfor a low-latency wake-up, falling back toOUTBOX_POLL_TIMEOUT_SECSso it still drains on a missed NOTIFY.
Tuning¶
| Setting | Default | Source |
|---|---|---|
OUTBOX_BATCH_SIZE |
100 | src/config.py — rows claimed per cycle. |
OUTBOX_POLL_TIMEOUT_SECS |
1.0 | src/config.py — fallback wake-up interval when no NOTIFY arrives. |
OUTBOX_ERROR_MAX_LEN |
1000 | src/config.py — must match the outbox.error column width. |
Status lifecycle¶
| Status | Set by | Meaning |
|---|---|---|
pending |
publisher (row insert) | Owed dispatch; the only status the poller claims. |
completed |
poller (all handlers clean) | Delivered; processed_at set. Disposable after the retention window — see outbox-retention.md. |
failed |
poller (any handler raised) | A handler raised; processed_at, retry_count, and error set. Left for investigation; no automatic retry/back-off yet (a future milestone). |
An event whose event_type has no registered subclass reconstructs as the bare DomainEvent envelope; the dispatcher sees zero handlers and the row is marked completed without side effects — the correct semantics for an unknown event type.
Metrics¶
The dispatcher emits four metrics (full table in observability.md § Outbox dispatcher):
| Name | Type | Use it for |
|---|---|---|
grinsystem.shared.outbox_lag_seconds |
histogram | Seconds between outbox.created_at and dispatch. Alert when P95 climbs above target latency. |
grinsystem.shared.dispatcher_batch_size |
histogram | Rows claimed per process_batch. Sustained P95 ≈ OUTBOX_BATCH_SIZE means dispatch can't keep up with publish rate. |
grinsystem.shared.events_processed_total |
counter | One increment per handler invocation; labels handler, status ∈ {success, failure}. |
grinsystem.shared.events_failed_total |
counter | Failure-only counter for alerting; labels handler, error_class. |
Each dispatch also emits a shared.consume_<EventType> CONSUMER span linked back to the HTTP request that produced the row, so one SigNoz trace spans HTTP request → handler → outbox row → consume → downstream handler.
Common failure modes¶
Rows stay pending, never dispatched¶
- Poller not running. Confirm the
outbox-pollertask started — look for theoutbox_dispatcher_startinglog line at startup. Ifhandler_countsis empty, no handlers registered (see next). - RLS hides the rows. The poller must connect as a
BYPASSRLSrole. In productionJOBS_DB_URLpoints atgrinsystem_jobs; in dev/test it falls back toDATABASE_URL(the bootstrap superuser, which also bypasses RLS). IfJOBS_DB_URLis misconfigured to a non-BYPASSRLSrole, the claim query returns nothing.
Rows drain to completed but nothing happens downstream¶
The dispatcher is empty — load_all_handlers() didn't import the handler module, or no module wires @on_event for that event type yet. Check the outbox_dispatcher_starting log's handler_counts. A module's handlers must live in src/modules/<m>/application/event_handlers.py to be autodiscovered.
Rows land in failed¶
A handler raised. Inspect the row's error column (truncated to OUTBOX_ERROR_MAX_LEN) and retry_count. There is no automatic retry today — re-dispatch is a manual/operational decision until the retry milestone lands. Because delivery is at-least-once, handlers must be idempotent (processed_events dedupe — see conventions.md § Handler-side idempotency).
Dispatch lag climbs (outbox_lag_seconds P95 high)¶
Publish rate outruns dispatch. Confirm dispatcher_batch_size P95 is pinned near OUTBOX_BATCH_SIZE (the tell). Raise OUTBOX_BATCH_SIZE, or — once a module is extracted — this is the signal to move that module's events onto Kafka.
Local verification¶
The outbox_dispatcher integration tests (GRI-116) exercise the full publish → poll → dispatch → mark path against a real Postgres:
No broker, no Kafka Connect, no extra services — the dispatcher runs in-process, so the tests need only the database.