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.
Enable it#
rails generate pgbus:add_outbox # add the outbox migration
rails generate pgbus:add_outbox --database=pgbus # for a separate database
rails db:migratePgbus.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
endPublish 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:
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 })
endHow 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.
idempotent! means a re-published entry is handled exactly once. See Event bus.