Skip to content

Dispatcher Deep-Dive

The OutboxDispatcher is the bridge between the PostgreSQL outbox and Celery workers. It runs as a dedicated long-lived process (WORKER_TYPE=dispatcher).

Internal Architecture

Connection Management

The Dispatcher uses raw asyncpg (not SQLAlchemy) for two critical reasons:

  1. LISTEN/NOTIFY requires a persistent connection — SQLAlchemy's session-based model doesn't support it well
  2. Performance — direct asyncpg avoids ORM overhead for high-throughput draining
python
# Two separate connections:
self.conn = await asyncpg.connect(...)      # Dedicated listener connection
await self.conn.add_listener("new_outbox_event", self.handle_notification)

self.pool = await asyncpg.create_pool(...)  # Pool for drain operations

Drain Loop Mechanics

The drain loop is event-driven, not polling-based:

python
async def drain_loop(self):
    while True:
        await self._drain_event.wait()   # Block until notification or poll
        self._drain_event.clear()
        
        while True:
            processed = await self.drain_outbox()
            if not processed:            # No more pending events
                break                    # Wait for next trigger

Each drain operation claims up to 100 events per batch using FOR UPDATE SKIP LOCKED, processes them sequentially, and dispatches each to Celery.

Retry Logic

When dispatch fails (e.g., Redis is down):

retry_count < 3  →  Reset to PENDING, increment retry_count
retry_count >= 3  →  Mark as FAILED (Dead Letter Queue)

Background Tasks

TaskIntervalPurpose
Safety Poll60sTriggers drain in case LISTEN/NOTIFY was missed
Stuck Sweeper5 minResets PROCESSING events older than 5 min; resets DISPATCHED events older than 60 min
Lifecycle Sweeper1 hourDeletes PROCESSED events older than 7 days
Heartbeat30sSELECT 1 on listener connection to detect disconnection

Reconnection Strategy

When the PostgreSQL connection drops:

  1. Cancel all background tasks
  2. Close the connection pool
  3. Record reconnection reason in Prometheus metrics
  4. Wait with exponential backoff + jitter (3–10 seconds)
  5. Reconnect and restart all tasks
  6. Immediately trigger a drain (catch up on any missed events)
python
jitter = random.uniform(3, 10)
logger.info(f"Reconnecting dispatcher in {jitter:.2f} seconds...")
await asyncio.sleep(jitter)

Prometheus Metrics

MetricTypeDescription
outbox_dispatch_totalCounterEvents successfully dispatched
dispatch_latency_secondsHistogramTime from event creation to dispatch
listener_reconnects_totalCounterReconnection count by reason
stuck_processing_events_totalCounterEvents rescued by sweeper

Built with VitePress