A production-ready implementation of the Outbox Pattern packaged as a Ruby Gem / Rails Engine, featuring comprehensive observability, metrics reporting, and alerting based on this blog post.
- Overview
- Installation
- Configuration
- Usage
- The Four Critical Metrics
- Background Workers
- Monitoring & Alerts
- The Outbox Runbook
- Testing & Development
- Production Scaling & Maintenance
- Troubleshooting
The Outbox Pattern solves transactional consistency in distributed systems (guaranteeing that business database updates and outbound event publications happen atomically) but creates a critical piece of infrastructure that requires deep observability.
This engine provides:
- ✅ Transactional consistency - Events are stored atomically with business data.
- ✅ At-least-once delivery - Guaranteed event publishing with background job retries.
- ✅ Idempotency - Duplicate prevention via unique database constraint keys.
- ✅ Concurrency control - Multiple concurrent processors supported using
SKIP LOCKED. - ✅ Production observability - Four critical metrics tracked automatically.
- ✅ Sentry integration - Automatic breadcrumb reporting and performance tracing.
Add the gem to your application's Gemfile:
gem "outbox_rails"Then run the installation generator to copy the migrations to your host application and migrate the database:
# Copy migrations
bin/rails outbox_rails:install:migrations
# Run migrations
bin/rails db:migrateConfigure the gem by creating an initializer in your host application:
# config/initializers/outbox_rails.rb
OutboxRails.configure do |config|
# Number of events to process in each batch (default: 100)
config.batch_size = 100
# Define how events are published to your message broker (e.g. Kafka, RabbitMQ, AWS SNS, HTTP)
config.publish_proc = ->(event) do
# Replace with your actual broker/HTTP publisher. E.g.:
# KafkaProducer.publish(
# topic: event.event_type,
# key: event.idempotency_key,
# payload: event.payload
# )
Rails.logger.info("Publishing event #{event.id}: #{event.event_type} - #{event.payload}")
end
endPublish events transactionally alongside your business data:
ActiveRecord::Base.transaction do
user = User.create!(email: "user@example.com", name: "User")
# Simple usage (generates a UUID idempotency key automatically)
OutboxRails::Publisher.publish("user.created", { user_id: user.id, email: user.email })
endSpecify a custom key to prevent duplicate publishing on the business level:
OutboxRails::Publisher.publish(
"order.completed",
{ order_id: 456, total: 99.99 },
idempotency_key: "order-completed-456"
)You can query the database directly using the namespaced engine model:
# Count pending events
OutboxRails::OutboxEvent.pending.count
# Inspect recent failures
OutboxRails::OutboxEvent.failed.where("updated_at > ?", 1.hour.ago).count
# Retrieve the oldest pending event
oldest = OutboxRails::OutboxEvent.pending.order(created_at: :asc).first- Definition:
Time.current - oldest_pending_event.created_at - Why: The primary metric indicating if the pipeline is broken. A high age means the processor is stalled.
- Alert Threshold:
> 300 seconds (5 minutes)
- Definition: Total count of events in
pendingstatus. - Why: Indicates overall load. High depth with low age means the system is busy but functioning. High depth with high age means it is broken.
- Alert Threshold:
> 3 × baseline
- Definition: Time from event creation (
created_at) to publication (published_at). - Why: Monitors performance. Spikes usually signal downstream broker slowdowns.
- Alert Threshold:
> 3 × baseline
- Definition:
(failed_events / total_processed) × 100over the last hour. - Why: Detects systemic downstream errors or parsing/serialization issues.
- Alert Threshold:
> 5%
Whenever an event is published via OutboxRails::Publisher, an OutboxRails::PublishJob is automatically enqueued after the database transaction commits (utilizing self.enqueue_after_transaction_commit = true).
Ensure your queue processor (e.g. Solid Queue, Sidekiq) is running:
bin/jobsTo report the four critical observability metrics to Sentry, configure the OutboxRails::MetricsJob to run periodically (e.g. every minute).
For Solid Queue recurring jobs, edit config/recurring.yml:
production:
outbox_metrics_reporting:
class: OutboxRails::MetricsJob
queue: default
schedule: every minuteThe gem communicates with Sentry to log transaction spans and distribute metric indicators via breadcrumbs.
Configure metric alerts in your Sentry console matching these thresholds:
- Queue Age:
max(outbox.queue_age_seconds) > 300for 5 minutes. - Error Rate:
outbox.error_rate_percentage > 5for 10 minutes. - Zero Throughput: No publications registered in 15 minutes.
- Check if background workers are processing jobs:
# Check remaining default jobs SolidQueue::Job.where(queue_name: 'default').count
- Check the queue depth trend:
-
Steadily climbing
$\rightarrow$ Worker/processor is down or locked. -
Flat but high
$\rightarrow$ Processor is running but bottlenecked.
-
Steadily climbing
- Search Sentry for new exceptions in
OutboxRails::Processor.
- If Processor is Down:
# Restart background workers bin/rails solid_queue:restart # Check for a blocking/poison message bin/rails runner "puts OutboxRails::OutboxEvent.pending.order(:created_at).first.inspect"
- If Processor is Running:
Check for locking issues in your PostgreSQL database:
-- Check for DB lock contention SELECT * FROM pg_locks WHERE relation = 'outbox_rails_events'::regclass; -- Check for long-running queries SELECT pid, now() - query_start as duration, query FROM pg_stat_activity WHERE query LIKE '%outbox_rails_events%' ORDER BY duration DESC;
- Poison Message: Flag the message as failed to allow the queue to progress, then inspect:
OutboxRails::OutboxEvent.pending.order(:created_at).first.update!(status: :failed)
- DB Lock Contention: Terminate the query causing the database block.
- Downstream Outage: Broker (Kafka/AWS) is down; pause processing until restored.
If you are contributing to this gem, you can run database migrations and execute the test suite against the local test dummy application:
# Prep test database
bin/rails db:test:prepare
# Run all tests
bin/rails test test:system- Scale out background workers.
- The PostgreSQL
SKIP LOCKEDlock strategy guarantees that concurrent workers will never grab the same pending batch events.
Clean up published events to prevent database bloat (e.g. via a daily cron/runner script):
# Archive events older than 30 days
OutboxRails::OutboxEvent.where("published_at < ?", 30.days.ago).delete_all- Verify background workers are running.
- Manually trigger processing to trace issues:
OutboxRails::Processor.new.process_batch
- Check application logs for metric logs prefixed with
[METRIC].
- Ruby >= 3.0
- Rails >= 8.0.0
- PostgreSQL (highly recommended for production concurrency locks via
SKIP LOCKED)
MIT