Relay Implementations
nats_outbox.relay.base
Abstract base interface for outbox relay implementations.
BaseRelay
Bases: ABC
Common interface for all relay implementations.
A relay is a long-running worker that reads pending events from the outbox_events table and publishes them to NATS JetStream.
Implementations
- PollingRelay (V1): periodic SELECT polling with FOR UPDATE SKIP LOCKED.
- WALRelay (V2): logical replication stream from Postgres WAL.
Both implementations must be safe to run as multiple concurrent instances (horizontal scaling). Exclusive access to a batch of events must be coordinated via the database (e.g. SKIP LOCKED for polling, replication slot for WAL).
Source code in nats_outbox/relay/base.py
start()
abstractmethod
async
Start the relay loop.
Blocks until stop() is called or an unrecoverable error occurs. Should be run as a separate asyncio task or process.
stop()
abstractmethod
async
Signal the relay to stop gracefully.
Should allow the current in-progress tick/batch to complete, then exit the loop. Non-blocking: returns immediately.
nats_outbox.relay.polling
PollingRelay — V1 relay implementation.
Architecture
The polling relay is a single asyncio event loop that periodically queries Postgres for pending outbox events and publishes them to NATS JetStream.
Key design decisions
- SELECT FOR UPDATE SKIP LOCKED ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The hot query is: SELECT * FROM outbox_events WHERE status = 'pending' AND scheduled_at <= now() ORDER BY scheduled_at, id LIMIT batch_size FOR UPDATE SKIP LOCKED
FOR UPDATE: acquires a row-level write lock on the selected rows. SKIP LOCKED: if a row is already locked by another relay instance, skip it instead of blocking. This makes it safe to run multiple relay instances in parallel (horizontal scaling) without duplicating work or creating deadlocks.
Trade-off: SKIP LOCKED means events can be processed out-of-order if one relay instance holds a lock and another skips to the next batch. For strict ordering within a single aggregate, use one relay instance or implement aggregate-level partitioning (deferred to V2).
-
Drain-then-sleep pattern ~~~~~~~~~~~~~~~~~~~~~~~~~~ If a tick processes events, the relay immediately loops (no sleep) to drain any remaining queue. Only when a tick returns zero events does the relay sleep for polling_interval seconds. This gives near-zero latency when the queue is non-empty, while avoiding busy-waiting when idle.
-
Exponential backoff via scheduled_at ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ On publish failure, the relay does NOT sleep or busy-retry. Instead, it:
- Increments retry_count
- Pushes scheduled_at forward by backoff_seconds
- Commits and moves on to the next event
The event will re-appear in the polling query naturally once scheduled_at is in the past again. This approach: - Never blocks the relay from processing other events - Survives relay restarts (state is in the DB, not in memory) - Provides natural jitter if backoff_seconds varies per event
- Dead-lettering ~~~~~~~~~~~~~~~~ After max_retries failed attempts, status is set to 'failed'. The event remains in the table for audit/alerting. Set up a Prometheus alert on: outbox_events_failed_total > 0
V1 trade-off: dead-lettered events require manual intervention (requeue by resetting status='pending' and retry_count=0). A requeue CLI command is planned for V2.
- Session-per-tick ~~~~~~~~~~~~~~~~~~ Each tick creates and destroys its own AsyncSession (via the session factory). This avoids connection leaks from long-running sessions and ensures the SQLAlchemy identity map is always fresh (no stale cached state).
Trade-offs vs WAL tailing (V2)
Polling: + Simple: no Postgres superuser, no replication slot setup. + Survives Postgres restarts transparently. - Latency: up to polling_interval per event (default 1s). - DB load: constant read query even when outbox is empty. Mitigated by the partial index (cheap scan) but nonzero at scale. - Ordering: SKIP LOCKED weakens strict global ordering under concurrent relays.
WAL tailing: + Sub-millisecond latency (reacts to INSERT in real-time). + Zero read load (no SELECT polling). + Strong ordering via LSN sequence. - Requires a replication slot and REPLICATION privilege. - Reconnection and LSN tracking add implementation complexity. - Replication slots can block Postgres WAL cleanup if the relay is offline.
PollingRelay
Bases: BaseRelay
V1 relay: polls outbox_events periodically, publishes to NATS JetStream.
Horizontally scalable: multiple instances can run simultaneously without duplicate work, thanks to SELECT FOR UPDATE SKIP LOCKED.
Source code in nats_outbox/relay/polling.py
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | |
__init__(session_factory, publisher, settings, *, metrics=None)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_factory
|
async_sessionmaker[AsyncSession]
|
SQLAlchemy async_sessionmaker. Used to create a fresh session per polling tick. |
required |
publisher
|
NatsPublisher
|
NatsPublisher instance connected to JetStream. |
required |
settings
|
OutboxSettings
|
OutboxSettings loaded from environment. |
required |
metrics
|
Optional[OutboxMetrics]
|
Optional OutboxMetrics for Prometheus instrumentation. If None, metrics are silently skipped. |
None
|
Source code in nats_outbox/relay/polling.py
run_cleanup()
async
Delete published events older than retention_days.
Returns the number of rows deleted.
This can be called periodically (e.g. daily via cron or a scheduler) or triggered manually. It runs in its own transaction, separate from the polling loop.
Source code in nats_outbox/relay/polling.py
start()
async
Start the polling loop. Blocks until stop() is called.
Drain-then-sleep: loops immediately if events were found, sleeps polling_interval only when the outbox is empty.
Source code in nats_outbox/relay/polling.py
stop()
async
Signal the relay to stop after the current tick completes.