New to Kestra?
Use blueprints to kickstart your first workflows.
Implement the transactional outbox pattern with Kestra: poll Postgres outbox tables safely with SKIP LOCKED, publish to Kafka, and track delivery status.
In distributed systems and microservices architectures, updating a local database and publishing a streaming event in a single atomic transaction is impossible without distributed two-phase commits (2PC), which introduces significant latency and operational fragility. The Transactional Outbox Pattern solves this dual-write dilemma: microservices commit domain data and corresponding integration events into a local outbox_events relational table within the same ACID transaction. An asynchronous worker then polls the outbox and reliably relays the events to an event streaming broker like Apache Kafka.
This blueprint implements a production-grade Transactional Outbox relay using Kestra. It uses PostgreSQL's native FOR UPDATE SKIP LOCKED inside an atomic Common Table Expression (CTE) to lease batches of pending events (status = 'CLAIMED') with fenced execution tokens (claim_owner = execution.id), maintains monotonic event sequencing (event_sequence) to guarantee strict per-aggregate FIFO order even across multi-event batches and simultaneous transaction commits, streams records to topic partitions keyed by aggregate_id, includes explicit event_id and event_sequence headers for consumer idempotency, marks events as DISPATCHED, and alerts on-call engineers via Slack if broker connectivity or database operations fail.
Before running this workflow, create the outbox_events table in your PostgreSQL database:
-- Required for gen_random_uuid() on PostgreSQL 10-12 (built into PostgreSQL 13+)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_sequence BIGINT GENERATED ALWAYS AS IDENTITY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
correlation_id VARCHAR(128),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
claim_owner VARCHAR(64),
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_at TIMESTAMPTZ,
dispatched_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_outbox_aggregate_fifo
ON outbox_events (aggregate_type, aggregate_id, event_sequence ASC)
WHERE status IN ('PENDING', 'CLAIMED');
CREATE INDEX IF NOT EXISTS idx_outbox_polling_queue
ON outbox_events (event_sequence ASC)
WHERE status IN ('PENDING', 'CLAIMED');
concurrency: { behavior: QUEUE, limit: 1 } so overlapping scheduled intervals or incoming webhooks serialize neatly, preventing multi-producer race conditions across batches.poll_pending_outbox_events (io.kestra.plugin.jdbc.postgresql.Query) runs an atomic UPDATE ... FROM CTE RETURNING statement. The query:batch_size candidate rows ordered by monotonic event_sequence ASC with FOR UPDATE SKIP LOCKED.claim_owner = '{{ execution.id }}' and recording claimed_at = NOW().SELECT ... ORDER BY event_sequence ASC.claimed_at < NOW() - INTERVAL '15 minutes'.evaluate_batch_content (io.kestra.plugin.core.flow.If) evaluates outputs.poll_pending_outbox_events.size > 0. If the outbox is empty, the flow logs a clean state and finishes cleanly with minimal overhead.dispatch_events_to_kafka (io.kestra.plugin.core.flow.Loop) iterates through each claimed event sequentially using item.value:publish_event_to_kafka (io.kestra.plugin.kafka.Produce) publishes the record via the from property to {{ inputs.kafka_topic_prefix }}.{{ aggregate_type }} using aggregate_id as the message key for hash-based partition locality and acks: all for high durability. It explicitly includes event_id, event_sequence, event_type, and correlation_id in the Kafka message headers to empower downstream consumers with end-to-end idempotency and deduplication.mark_event_dispatched (io.kestra.plugin.jdbc.postgresql.Query) updates the record status to DISPATCHED fenced with claim_owner = '{{ execution.id }}' and records dispatched_at = NOW().log_dispatch_success (io.kestra.plugin.core.log.Log) outputs the count of successfully relayed messages.alert_on_pipeline_failure in the errors block posts an immediate incident alert to Slack with execution context and connection parameters.SKIP LOCKED and atomic lease transitions.event_id and event_sequence in Kafka record headers.dispatched_at timestamps).Building custom background daemons in Go, Python, or Java to poll outbox tables requires writing custom connection pools, backoff retry loops, Kafka producer boilerplate, and monitoring hooks. Kestra provides declarative, cloud-native orchestration with built-in connection retries, secret masking, fine-grained concurrency control, and Slack alerting out of the box.
pgcrypto enabled for gen_random_uuid() on PostgreSQL 10-12) with the outbox_events table configured.Configure the following secrets in your Kestra namespace:
POSTGRES_USER: Database user credentials with SELECT and UPDATE privileges on outbox_events.POSTGRES_PASSWORD: Database user password.KAFKA_BOOTSTRAP_SERVERS: Comma-separated list of Kafka broker endpoints (e.g. broker1:9092,broker2:9092).SLACK_WEBHOOK_URL: Slack Incoming Webhook URL for error alerts.OUTBOX_WEBHOOK_KEY: Secret authentication key for the manual webhook trigger.POSTGRES_USER, POSTGRES_PASSWORD, KAFKA_BOOTSTRAP_SERVERS, SLACK_WEBHOOK_URL, OUTBOX_WEBHOOK_KEY) in your Kestra namespace.pgcrypto (if on PostgreSQL 10-12) and create outbox_events and its partial index.outbox_events:INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', 'ORD-10492', 'OrderCreated', '{"order_id": "ORD-10492", "customer_id": "CUST-881", "total": 149.50}');
DISPATCHED.periodic_outbox_poller to poll on an ongoing schedule.retry_count and marks records as POISON if JSON deserialization fails.io.kestra.plugin.aws.s3.Upload or io.kestra.plugin.gcp.gcs.Upload task to sweep old DISPATCHED records into Parquet archives after 30 days.item.value to route events across dynamic tenant Kafka topics.