Guide

# Transactional outbox

Publish inside a transaction — the event commits with your data, or rolls back with it.

## The dual-write problem

Write a row and publish an event and you have two systems to keep in sync. If the publish happens before commit, a rollback leaves a **phantom event** for data that never landed. If it happens after commit, a crash in between **loses the event**. The outbox closes that gap: the event is written to an outbox table in the same transaction as your data, and a poller publishes it afterward.

one DB transaction

INSERT order

INSERT outbox row

Outbox.publish(...)

COMMIT

outbox poller

SKIP LOCKED

PGMQ queue

ROLLBACK

nothing published

outbox row rolled back too

The outbox row commits with the business row — or rolls back with it. A poller moves only committed rows into PGMQ.

## Enable it

```shell
rails generate pgbus:add_outbox                  # add the outbox migration
rails generate pgbus:add_outbox --database=pgbus # for a separate database
rails db:migrate
```

```ruby
Pgbus.configure do |config|
  config.outbox_enabled       = true
  config.outbox_poll_interval = 1.0   # seconds
  config.outbox_batch_size    = 100
  config.outbox_retention     = 1.day # Duration also accepted
end
```

## Publish inside a transaction

Call `Pgbus::Outbox.publish` (a queue) or `publish_event` (a topic) inside the transaction that writes your data. Both the row and the outbox entry commit together — or roll back together:

```ruby
ActiveRecord::Base.transaction do
  order = Order.create!(params)

  # Committed atomically with the order. If the transaction rolls back,
  # so does the outbox entry — no phantom event.
  Pgbus::Outbox.publish("default", { order_id: order.id })

  # For the topic-based event bus:
  Pgbus::Outbox.publish_event("orders.created", { order_id: order.id })
end
```

## How the poller works

The outbox poller is a supervised process. Each cycle it claims a batch of unpublished entries with `FOR UPDATE SKIP LOCKED` (so multiple pollers never double-publish), sends them to PGMQ, and marks them published. An entry that fails to publish is simply skipped and retried next cycle. Published entries are purged after `outbox_retention`.

> **Tip:** The outbox pairs naturally with idempotent event handlers: at-least-once delivery plus `idempotent!` means a re-published entry is handled exactly once. See [Event bus](https://pgbus.zoolutions.llc/docs/event-bus).