Trigger icon
Webhook icon
Schedule icon
Queries icon
Request icon
If icon
Fail icon
Query icon
Loop icon
Sequential icon
Publish icon
event_type }} icon
SlackIncomingWebhook icon
Exit icon

Relay a PostgreSQL Transactional Outbox to RabbitMQ, in Order and Without Duplicates

Relay a Postgres outbox to RabbitMQ with SKIP LOCKED leases, per-aggregate ordering, retries with backoff, dead letters and Slack lag alerts.

Categories
DataInfrastructure

A service that writes to its database and then publishes to a broker can lose events: the commit succeeds and the publish fails, or the reverse. The transactional outbox fixes this. The service inserts the event into an outbox table in the same transaction as its data, and a relay publishes the table to the broker later. The relay is where the hard parts are:

  • Two relays running at once must not both publish a row.
  • Events of one order, account or other aggregate must arrive in the order they were written. Consumers apply them as state changes.
  • A relay that dies mid-batch must not strand its rows, and one bad event must not stop the others.
  • Someone has to notice when events pile up.

This blueprint is that relay, for PostgreSQL and RabbitMQ. It starts as soon as rows are ready (JDBC trigger). It claims them with a lease, publishes them one at a time in id order with the outbox id as the AMQP message id, and records every outcome in the outbox row itself. It runs locally against postgres and rabbitmq:3-management containers.

How it works

  1. outbox_rows (PostgreSQL Trigger, event-based) starts a run when an event is ready: pending, out of backoff, not leased, and not waiting behind an older event of its aggregate. after_commit (Webhook) lets the application nudge the relay right after a commit. backstop (Schedule) runs the lag check when nothing is ready.
  2. ensure_outbox creates the outbox table, its partial indexes and a one-row state table if they are missing.
  3. check_exchange and check_bindings ask the RabbitMQ management API whether the exchange exists and has a bound queue. broker_guard (If + Fail) stops the run before anything is claimed if the broker is down, the exchange is missing, or the exchange would drop every message.
  4. claim_batch leases up to batch_size events in one UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED): claimed_by is the execution id and claimed_until is now + lease_seconds. For each aggregate it only takes events older than its first event that cannot go now (dead-lettered, backing off, or leased by another run).
  5. publish_each (Loop, one iteration at a time, in id order) runs one Sequential with allowFailure per event:
    • take_turn is the ordering, fencing and circuit-breaker guard. It continues only if this run still holds the lease, no older event of the aggregate is unpublished, and this run has failed fewer than stop_after_failures events. It then renews the lease; otherwise it hands the row back without counting an attempt.
    • publish (AMQP Publish) sends a persistent message with the routing key <aggregate_type>.<event_type>, the outbox id as messageId, the event type as type, and the aggregate in the headers.
    • mark_published sets status = 'published', only while this run still holds the lease.
    • On failure, record_failure counts the attempt and sets next_attempt_at with exponential backoff. After max_attempts it sets status = 'dead', and alert_dead_letter posts to Slack with the SQL to replay or discard the event.
    • Once stop_after_failures events have failed, the rest of the batch is handed back unsent: at that point the broker is the likely cause, not the events. The failure count is read from Postgres, because loop iterations are sub-executions and cannot see each other's outputs.
  6. release_unsent hands back rows the loop did not reach. check_lag measures the oldest unpublished event and flips a lagging flag; alert_lag posts when it turns on and when it clears. prune_published deletes published rows older than keep_published_days.
  7. circuit_open (If) ends the run FAILED after too many failures, so the errors alert fires, or WARNING after fewer.

Error handling

  • No double publishing between runs. Claims use FOR UPDATE SKIP LOCKED and a lease, and every write after the claim checks claimed_by and claimed_until. In testing, simultaneous runs never claimed the same row. A run that is killed keeps its lease until it expires, then another run takes the rows over.
  • Per-aggregate order. An event is never published while an older event of its aggregate is pending or dead. If an event fails, the later events of that aggregate wait. Other aggregates keep flowing.
  • Exactly-once for consumers, in practice. The relay is at-least-once: a crash between the publish and mark_published sends the event again after the lease. The message id is the outbox id, so consumers drop the copy with a processed-ids table or INSERT ... ON CONFLICT DO NOTHING.
  • Poison events go to a dead letter. The Publish task retries 3 times; the event then backs off (30 s, 60 s, 120 s, ...) and is dead-lettered after max_attempts. The alert names the event, its payload size, how many later events are held back, and the exact UPDATE to replay or discard it.
  • Broker problems do not burn attempts. The pre-flight check fails before claiming anything when the broker is down, the exchange is missing, or it has no bound queue. If the broker fails mid-batch, the loop stops after stop_after_failures and the rest is released without counting an attempt.
  • Lag is visible. If the oldest unpublished event is older than lag_alert_minutes, Slack gets one message saying why. The event may be held behind a dead letter, failing (with its last error), or not yet tried. A second message follows when it clears.
  • Any other failure releases the run's claims and alerts with the task and the cause.

What this blueprint teaches about Kestra

  • Making work exactly-once in the database when Kestra's own concurrency cannot help. Loop iterations are sub-executions, and the relay must allow parallel runs anyway.
  • A Loop with concurrencyLimit: 1 for ordered processing, a circuit breaker whose failure count lives in Postgres (iterations cannot share outputs), and a Sequential with allowFailure and local errors to isolate each item.
  • Using the JDBC Trigger as an event source with a query that matches exactly what the flow can process, so it never starts empty runs.
  • Turning silent failure modes into pre-flight checks. The AMQP Publish task has no publisher confirms or mandatory flag, so the flow asks the management API whether the exchange routes anywhere first.

Prerequisites

  • PostgreSQL 12 or later (tested on 16), and a role that can create and update the outbox tables.
  • RabbitMQ with the management plugin (tested on 3.13), and a topic exchange with at least one bound queue.
  • A Slack incoming webhook.

Your application inserts into outbox in the same transaction as its business change, for example INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ('order', 'ORD-1042', 'OrderPaid', '{"amount": 42}'). The relay manages every other column.

Secrets

  • POSTGRES_URL, POSTGRES_USER, POSTGRES_PASSWORD: the database that holds the outbox, e.g. jdbc:postgresql://localhost:5432/app.
  • RABBITMQ_USERNAME, RABBITMQ_PASSWORD: a user that can publish to the exchange and read it in the management API (the management user tag is enough for the read).
  • SLACK_WEBHOOK_URL: Slack incoming webhook.
  • OUTBOX_RELAY_WEBHOOK_KEY: a long random string, the key in the webhook URL ($KEY in the trigger example).

Inputs

  • exchange (STRING, default domain-events): the exchange to publish to.
  • batch_size (INT, default 50), lease_seconds (INT, default 300).
  • max_attempts (INT, default 5), backoff_seconds (INT, default 30), stop_after_failures (INT, default 3).
  • lag_alert_minutes (INT, default 5), keep_published_days (INT, default 7).
  • rabbitmq_host, rabbitmq_port, rabbitmq_vhost, rabbitmq_management_url, kestra_url: where things are.

Outputs

  • results (JSON): one entry per claimed event, in order, e.g. {"id": 12, "result": "published"}, "waiting for an older event of the same aggregate", "failed attempt 2" or "dead-lettered".
  • outbox (JSON): pending and dead-lettered counts, failures in this run, and the oldest pending event with its age. Outputs are set on SUCCESS runs. A run that ends FAILED, or WARNING through Exit, has none; its alerts and the outbox rows carry the details.

Quick start

  1. Start PostgreSQL and rabbitmq:3-management. Declare a topic exchange domain-events and bind a queue to it with #.
  2. Add the secrets and run the flow once: it creates the outbox table.
  3. Insert a few rows into outbox and run it again. In testing, 120 events over 12 orders reached the queue in order, with no duplicates, in 41 seconds over 3 runs.
  4. Enable the outbox_rows trigger (and backstop for lag alerts when nothing is ready), and add the OUTBOX_RELAY_WEBHOOK_KEY secret before calling the webhook.

How to extend

  • Kafka or another broker: replace publish with io.kestra.plugin.kafka.Produce keyed by aggregate_id; the claim, ordering and dead-letter logic stay the same. The catalog's postgres-transactional-outbox-kafka-fanout shows the Kafka side.
  • Several outboxes: add a topic or tenant column and pass it to the claim, or run one copy of the flow per database.
  • Higher volume: this relay publishes roughly 5 events per second, which suits business events. For thousands per second, read the WAL with Debezium's outbox event router and keep this flow's dead-letter and lag handling as the operations layer.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.