RabbitMQ #
RabbitMQ is a message broker implementing the AMQP (Advanced Message Queuing Protocol) — an open messaging standard supported by many languages and platforms. Unlike Kafka, which is based on a log stream, RabbitMQ is a true message queue: messages are deleted after successful consumption, it supports very flexible routing through exchanges and bindings, and it’s suitable for task queues, notifications, and request-reply (RPC) patterns. In Ruby, the Bunny gem is the most mature and widely used AMQP client — lightweight, thread-safe, and with a very clean API. For Rails integration, Sneakers provides a worker framework built on top of Bunny.
RabbitMQ Basic Concepts #
Understanding how messages flow through RabbitMQ is the key to designing correct messaging architectures:
flowchart LR
P[Producer] --> EX{Exchange}
EX -->|routing key: order.new| Q1[Queue: new_orders]
EX -->|routing key: order.paid| Q2[Queue: payments]
EX -->|routing key: order.*| Q3[Queue: audit_log]
Q1 --> C1[Consumer A\nOrder Service]
Q2 --> C2[Consumer B\nPayment Service]
Q3 --> C3[Consumer C\nAudit Service]Main RabbitMQ components:
Producer — an application that sends messages to an exchange
Exchange — receives messages from producers, routes to queues based on rules
Binding — a rule connecting an exchange to a queue
Queue — a buffer storing messages until consumed
Consumer — an application that reads and processes messages from a queue
Routing Key — the label an exchange uses to determine the destination
Exchange types:
direct — send to a queue whose binding key exactly matches the routing key
fanout — broadcast to ALL bound queues, ignoring routing keys
topic — routing keys with wildcards (* = one word, # = many words)
headers — routing based on message headers, not routing keys
Installation #
gem install bunny
# For Sneakers (a worker framework for Rails)
gem install sneakers
# Gemfile
gem 'bunny', '~> 2.22' # AMQP client
gem 'sneakers', '~> 2.12' # worker framework (optional, for Rails)
Connection and Channels #
require 'bunny'
# Basic connection
connection = Bunny.new(
host: "localhost",
port: 5672,
vhost: "/",
username: "guest",
password: "guest"
)
# Connection via URL (more concise)
connection = Bunny.new(
ENV.fetch("RABBITMQ_URL", "amqp://guest:***@localhost:5672")
)
# Connection with TLS (production)
connection = Bunny.new(
host: "rabbitmq.example.com",
port: 5671, # TLS port
username: ENV["RABBITMQ_USER"],
password: ENV["RABBITMQ_PASSWORD"],
tls: true,
tls_ca_certificates: ["/path/to/ca-cert.pem"]
)
# Additional options
connection = Bunny.new(
"amqp://localhost",
heartbeat: 10, # heartbeat every 10 seconds
connection_timeout: 5, # connection timeout
read_timeout: 30, # read timeout
automatically_recover: true, # auto-reconnect if disconnected
recovery_attempts: 10, # max 10 reconnect attempts
recovery_delay: 2 # 2 second delay between attempts
)
connection.start
# Channel — the AMQP communication unit, one connection can have many channels
# One channel per thread is the recommended practice
channel = connection.create_channel
puts "Connected to RabbitMQ #{connection.server_properties['version']}"
# Close the connection
connection.close
Exchange Types and Routing #
Direct Exchange — Exact Routing #
channel = connection.create_channel
# Declare an exchange
exchange = channel.direct(
"orders_exchange",
durable: true, # survives RabbitMQ restarts
auto_delete: false
)
# Declare a queue and binding
new_queue = channel.queue(
"new_orders",
durable: true,
arguments: { "x-message-ttl" => 86_400_000 } # 24 hour TTL
)
# Bind the queue to the exchange with a routing key
new_queue.bind(exchange, routing_key: "order.new")
# Send a message to the exchange with a routing key
exchange.publish(
JSON.generate({ order_id: 123, total: 150_000 }),
routing_key: "order.new",
persistent: true, # the message is stored on disk (not lost on restart)
content_type: "application/json"
)
Fanout Exchange — Broadcasting to All Queues #
# Fanout — send to all bound queues, ignoring routing keys
fanout = channel.fanout(
"notification_broadcast",
durable: true
)
# Create several queues and bind them to the fanout
["email_notifications", "sms_notifications", "push_notifications"].each do |queue_name|
q = channel.queue(queue_name, durable: true)
q.bind(fanout) # no routing key needed for fanout
end
# One publish → all queues receive it
fanout.publish(
JSON.generate({ event: "flash_sale", discount: 50, ends_at: "2024-08-15T23:59:59" }),
persistent: true
)
Topic Exchange — Routing with Wildcards #
# Topic exchange — routing keys with patterns
# * = exactly one word
# # = zero or more words
topic = channel.topic("event_bus", durable: true)
# Bindings with patterns
channel.queue("all_orders", durable: true)
.bind(topic, routing_key: "order.#") # all order events
channel.queue("new_and_paid_orders", durable: true)
.bind(topic, routing_key: "order.new")
.bind(topic, routing_key: "order.paid")
channel.queue("all_email_notifications", durable: true)
.bind(topic, routing_key: "*.email.#") # email from any domain
# Publish with hierarchical routing keys
topic.publish(
JSON.generate({ order_id: 456 }),
routing_key: "order.new",
persistent: true
)
topic.publish(
JSON.generate({ user_id: 99, template: "welcome" }),
routing_key: "user.email.welcome",
persistent: true
)
Producer — Sending Messages #
require 'bunny'
require 'json'
class OrderProducer
def initialize
@connection = Bunny.new(ENV["RABBITMQ_URL"])
@connection.start
@channel = @connection.create_channel
# Enable publisher confirms — confirmation from RabbitMQ that the message was received
@channel.confirm_select
@exchange = @channel.topic("event_bus", durable: true)
end
def send_order_created(order)
payload = JSON.generate({
event: "order_created",
order_id: order.id,
user_id: order.user_id,
total: order.total,
items: order.items.map { |i| { product_id: i.product_id, quantity: i.quantity } },
timestamp: Time.now.iso8601
})
@exchange.publish(
payload,
routing_key: "order.new",
persistent: true,
content_type: "application/json",
message_id: SecureRandom.uuid, # unique ID for idempotency
timestamp: Time.now.to_i
)
# Wait for confirmation from RabbitMQ (publisher confirms)
unless @channel.wait_for_confirms
raise "RabbitMQ did not confirm the message delivery!"
end
true
rescue Bunny::Exception => e
Rails.logger.error "Failed to send message to RabbitMQ: #{e.message}"
false
end
def close
@connection.close
end
end
Consumer — Reading Messages #
Basic Consumer #
require 'bunny'
require 'json'
connection = Bunny.new(ENV["RABBITMQ_URL"])
connection.start
channel = connection.create_channel
# Prefetch count — how many messages are sent to a consumer before an ack
# A value of 1 = the consumer only receives 1 message, processes it, acks, then gets the next
# Important for fair load distribution between consumers
channel.prefetch(1)
queue = channel.queue("new_orders", durable: true)
puts "Waiting for messages in queue '#{queue.name}'..."
# Subscribe — the consumer loop (blocking)
queue.subscribe(manual_ack: true, block: true) do |delivery_info, properties, body|
begin
data = JSON.parse(body, symbolize_names: true)
puts "Processing order #{data[:order_id]}"
# Process the message
process_order(data)
# ACK — tell RabbitMQ the message was processed successfully, remove it from the queue
channel.ack(delivery_info.delivery_tag)
puts "ACK: order #{data[:order_id]} done"
rescue JSON::ParserError => e
puts "Invalid message: #{e.message}"
# NACK without requeue — send to a Dead Letter Exchange if configured
channel.nack(delivery_info.delivery_tag, false, false)
rescue => e
puts "Error: #{e.message}"
# NACK with requeue — return to the queue to try again
channel.nack(delivery_info.delivery_tag, false, true)
end
end
Multi-Threaded Consumer #
require 'bunny'
connection = Bunny.new(ENV["RABBITMQ_URL"])
connection.start
# Create N worker threads, each with its own channel
WORKER_COUNT = 5
workers = WORKER_COUNT.times.map do |i|
Thread.new do
ch = connection.create_channel
ch.prefetch(1)
q = ch.queue("new_orders", durable: true)
q.subscribe(manual_ack: true) do |delivery_info, _properties, body|
begin
data = JSON.parse(body, symbolize_names: true)
puts "[Worker #{i}] Processing order #{data[:order_id]}"
process_order(data)
ch.ack(delivery_info.delivery_tag)
rescue => e
puts "[Worker #{i}] Error: #{e.message}"
ch.nack(delivery_info.delivery_tag, false, false)
end
end
# The thread keeps running until a stop signal
loop { sleep 1 }
end
end
# Wait for all workers
trap("SIGTERM") { connection.close; exit }
trap("SIGINT") { connection.close; exit }
workers.each(&:join)
Acknowledgment — ACK, NACK, Reject #
Acknowledgment is the mechanism that ensures messages aren’t lost when a consumer crashes:
# Three options after processing a message:
# 1. ACK — processed successfully, remove from the queue
channel.ack(delivery_info.delivery_tag)
# 2. NACK with requeue: true — return to the queue (another consumer can pick it up)
channel.nack(delivery_info.delivery_tag, false, true)
# CAREFUL: can cause an infinite loop if a message always fails!
# 3. NACK with requeue: false — remove from the queue
# If a Dead Letter Exchange exists → goes to the DLX
# If not → the message is lost
channel.nack(delivery_info.delivery_tag, false, false)
# 4. Reject — same as NACK but only for a single message
channel.reject(delivery_info.delivery_tag, false) # false = no requeue
# Recommended strategy:
# - Success → ACK
# - Temporary error (network, timeout) → NACK requeue: true (with backoff)
# - Permanent error (invalid data) → NACK requeue: false → goes to the DLX
Dead Letter Exchanges — Handling Failed Messages #
A DLX (Dead Letter Exchange) is an exchange where “dead” messages are sent — either because they were NACKed, their TTL expired, or the queue was full:
channel = connection.create_channel
# 1. Create a Dead Letter Exchange and Queue
dlx = channel.direct("orders_dlx", durable: true)
dlq = channel.queue("orders_dead_letter", durable: true)
dlq.bind(dlx, routing_key: "new_orders")
# 2. Create the main queue with DLX configuration
main_queue = channel.queue(
"new_orders",
durable: true,
arguments: {
"x-dead-letter-exchange" => "orders_dlx", # DLX name
"x-dead-letter-routing-key" => "new_orders", # routing key on the DLX
"x-message-ttl" => 3_600_000, # 1 hour TTL (optional)
"x-max-retries" => 3 # custom header for tracking
}
)
# 3. Bind the main queue to the main exchange
exchange = channel.topic("event_bus", durable: true)
main_queue.bind(exchange, routing_key: "order.new")
# 4. Consumer with retry logic and DLX
main_queue.subscribe(manual_ack: true, block: true) do |delivery_info, properties, body|
headers = properties.headers || {}
death_count = headers["x-death"]&.first&.dig("count") || 0
begin
process_order(JSON.parse(body, symbolize_names: true))
channel.ack(delivery_info.delivery_tag)
rescue => e
puts "Error (attempt #{death_count + 1}): #{e.message}"
if death_count >= 2 # failed 3 times (0, 1, 2)
puts "Giving up after 3 attempts, sending to DLX"
channel.nack(delivery_info.delivery_tag, false, false) # → DLX
else
# Return to the queue with a delay (simple implementation)
sleep(2 ** death_count) # 1s, 2s, 4s
channel.nack(delivery_info.delivery_tag, false, true) # requeue
end
end
end
# 5. DLQ consumer — monitor and process truly failed messages
dlq.subscribe(manual_ack: true) do |delivery_info, properties, body|
puts "Message in DLQ: #{body}"
# Log to a monitoring system
Monitoring.alert("Message in DLQ", body: body, headers: properties.headers)
# Optional: notify developers or save to a database for analysis
FailedOrder.create!(payload: body, failed_at: Time.now)
channel.ack(delivery_info.delivery_tag)
end
Priority Queues #
RabbitMQ supports queues with priorities — high-priority messages are processed first:
# Create a priority queue with a maximum of 10 priority levels
priority_queue = channel.queue(
"priority_notifications",
durable: true,
arguments: { "x-max-priority" => 10 }
)
exchange = channel.direct("notification_exchange", durable: true)
priority_queue.bind(exchange, routing_key: "notifications")
# Send messages with different priorities
# Priority 0 (lowest) through 10 (highest)
exchange.publish(
JSON.generate({ message: "Regular info", type: "info" }),
routing_key: "notifications",
priority: 1,
persistent: true
)
exchange.publish(
JSON.generate({ message: "Server down!", type: "critical" }),
routing_key: "notifications",
priority: 10, # highest — processed first
persistent: true
)
exchange.publish(
JSON.generate({ message: "Stock running low", type: "warning" }),
routing_key: "notifications",
priority: 5,
persistent: true
)
Request-Reply Pattern (RPC) #
RabbitMQ supports an RPC pattern where a producer waits for a response from a consumer:
# CLIENT — send a request and wait for the reply
class CalculatorRPCClient
def initialize
@connection = Bunny.new(ENV["RABBITMQ_URL"])
@connection.start
@channel = @connection.create_channel
# Exclusive queue for receiving replies (auto-deleted when disconnected)
@reply_queue = @channel.queue("", exclusive: true)
@exchange = @channel.default_exchange
# Hash to store waiting responses
@lock = Mutex.new
@cond = ConditionVariable.new
@responses = {}
# Subscribe to the reply queue
@reply_queue.subscribe do |_di, properties, body|
correlation_id = properties.correlation_id
@lock.synchronize do
@responses[correlation_id] = body
@cond.signal
end
end
end
def factorial(n)
correlation_id = SecureRandom.uuid
@exchange.publish(
n.to_s,
routing_key: "rpc_factorial",
reply_to: @reply_queue.name,
correlation_id: correlation_id
)
# Wait for the response with a 5 second timeout
@lock.synchronize do
@cond.wait(@lock, 5)
result = @responses.delete(correlation_id)
raise "RPC timeout!" unless result
JSON.parse(result)
end
end
def close
@connection.close
end
end
# SERVER — receive requests, process them, send replies
def run_rpc_server
connection = Bunny.new(ENV["RABBITMQ_URL"])
connection.start
channel = connection.create_channel
channel.prefetch(1)
queue = channel.queue("rpc_factorial", durable: false)
queue.subscribe(manual_ack: true, block: true) do |di, properties, body|
n = body.to_i
result = recursive_factorial(n)
# Send the reply to the reply_to queue with the same correlation_id
channel.default_exchange.publish(
JSON.generate({ result: result, input: n }),
routing_key: properties.reply_to,
correlation_id: properties.correlation_id
)
channel.ack(di.delivery_tag)
end
end
# RPC usage
client = CalculatorRPCClient.new
puts client.factorial(10) # => {"result"=>3628800, "input"=>10}
client.close
Sneakers — Background Jobs for Rails #
Sneakers is a RabbitMQ worker framework built on top of Bunny, similar to Sidekiq but for RabbitMQ:
# Gemfile
# gem 'sneakers', '~> 2.12'
# config/initializers/sneakers.rb
Sneakers.configure(
amqp: ENV.fetch("RABBITMQ_URL", "amqp://localhost"),
vhost: "/",
heartbeat: 10,
prefetch: 10,
workers: 4,
threads: 4,
log: Rails.root.join("log", "sneakers.log"),
pid_path: Rails.root.join("tmp", "pids", "sneakers.pid"),
daemonize: false,
metrics: Sneakers::Metrics::LoggingMetrics.new
)
# app/workers/order_worker.rb
class OrderWorker
include Sneakers::Worker
from_queue "new_orders",
durable: true,
ack: :manual, # manual acknowledgment
timeout_job_after: 60, # 60 second timeout per job
exchange: "event_bus",
exchange_type: :topic,
routing_key: "order.new"
def work(message)
data = JSON.parse(message, symbolize_names: true)
Rails.logger.info "Processing order: #{data[:order_id]}"
ActiveRecord::Base.transaction do
order = Order.find(data[:order_id])
order.update!(status: :processed)
SendOrderEmail.call(order)
UpdateStock.call(order)
end
ack! # success → ACK
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error "Order not found: #{e.message}"
reject! # invalid data → don't requeue, send to the DLX
rescue => e
Rails.logger.error "Error: #{e.message}"
requeue! # temporary error → return to the queue
end
end
# app/workers/notification_worker.rb
class NotificationWorker
include Sneakers::Worker
from_queue "email_notifications",
durable: true,
ack: :manual
def work(message)
data = JSON.parse(message, symbolize_names: true)
NotificationMailer.send(data[:email], data[:subject], data[:body]).deliver_now
ack!
rescue => e
Rails.logger.error "Failed to send notification: #{e.message}"
reject!
end
end
# Run all registered workers
bundle exec rake sneakers:run
# Or run specific workers
WORKERS=OrderWorker,NotificationWorker bundle exec rake sneakers:run
# As a daemon
bundle exec rake sneakers:run DAEMONIZE=true
Graceful Shutdown #
# Handle SIGTERM for a clean shutdown
require 'bunny'
connection = Bunny.new(ENV["RABBITMQ_URL"])
connection.start
channel = connection.create_channel
channel.prefetch(1)
queue = channel.queue("new_orders", durable: true)
running = true
messages_in_flight = 0
trap("SIGTERM") do
puts "SIGTERM received, waiting for in-flight messages to finish..."
running = false
end
trap("SIGINT") do
running = false
end
queue.subscribe(manual_ack: true) do |delivery_info, properties, body|
break unless running
messages_in_flight += 1
begin
process_order(JSON.parse(body))
channel.ack(delivery_info.delivery_tag)
rescue => e
channel.nack(delivery_info.delivery_tag, false, true)
ensure
messages_in_flight -= 1
end
end
# Wait for all messages to finish before closing
sleep(0.1) while messages_in_flight > 0
connection.close
puts "Shutdown complete"
Monitoring via the Management API #
RabbitMQ provides an HTTP API for monitoring:
require 'net/http'
require 'json'
class RabbitMQMonitor
BASE_URL = "http://localhost:15672/api"
def initialize(user: "guest", password: "guest")
@user = user
@password = password
end
def queue_stats(queue_name, vhost: "%2F")
response = get("/queues/#{vhost}/#{queue_name}")
{
messages_waiting: response["messages"],
messages_unacked: response["messages_unacknowledged"],
active_consumers: response["consumers"],
publish_rate: response.dig("message_stats", "publish_details", "rate")&.round(2)
}
end
def all_queues(vhost: "%2F")
get("/queues/#{vhost}").map do |q|
{ name: q["name"], messages: q["messages"], consumers: q["consumers"] }
end
end
def health_check
get("/healthchecks/node")["status"] == "ok"
end
private
def get(path)
uri = URI("#{BASE_URL}#{path}")
req = Net::HTTP::Get.new(uri)
req.basic_auth(@user, @password)
resp = Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
JSON.parse(resp.body)
end
end
monitor = RabbitMQMonitor.new
stats = monitor.queue_stats("new_orders")
puts "Messages waiting: #{stats[:messages_waiting]}"
puts "Active consumers: #{stats[:active_consumers]}"
if stats[:messages_waiting] > 1000
Monitoring.alert("Queue is full: new_orders has #{stats[:messages_waiting]} messages!")
end
Summary #
- Choose the right exchange type —
directfor specific routing,fanoutfor broadcasts,topicfor flexible routing with wildcards; choosing the wrong exchange type causes messages to never reach their destination.manual_ack: trueandprefetch(1)always — auto-ack can cause message loss if a consumer crashes; prefetch(1) ensures fair load distribution between consumers.persistent: truemessages for durability — without this, messages are lost if RabbitMQ restarts; combine it withdurable: truequeues.- Publisher confirms for producer reliability —
channel.confirm_selectandchannel.wait_for_confirmsensure RabbitMQ actually received the message before continuing.- Dead Letter Exchanges for error handling — always configure a DLX on production queues; failed messages go to the DLX for analysis instead of disappearing.
- Don’t NACK requeue: true without backoff — immediate requeues can cause a consumer to process the same message thousands of times per second; implement exponential backoff.
- One channel per thread — RabbitMQ channels aren’t thread-safe; create a new channel for every worker thread, don’t share channels between threads.
automatically_recover: truefor auto-reconnect — connections to RabbitMQ can drop; Bunny can reconnect automatically and resubscribe queues transparently.- Sneakers for Rails integration — this framework handles worker lifecycles, preforking, and ActiveRecord integration; easier than managing Bunny manually in Rails.
- Monitor queue depth as the primary metric — a queue that keeps growing without shrinking signals overwhelmed consumers; scale out consumers or optimize processing time.