Event bus
Publish a topic once; AMQP-style patterns fan it out to idempotent subscribers.
Publish an event#
An event is a routing key plus a payload. Publish it now, or schedule it with a delay:
Pgbus.publish(
"orders.created",
{ order_id: order.id, total: order.total }
)
Pgbus.publish_later(
"invoices.due",
{ invoice_id: invoice.id },
delay: 30.days
)Pgbus.publish / Pgbus.publish_later are top-level shortcuts for Pgbus::EventBus::Publisher.publish / .publish_later (symmetric with Pgbus.stream). The long form still works if you prefer it.
Subscribe with a handler#
A handler subclasses Pgbus::EventBus::Handler and implements #handle. Register it against a pattern in an initializer:
class OrderCreatedHandler < Pgbus::EventBus::Handler
idempotent! # deduplicate by (event_id, handler_class)
def handle(event)
order_id = event.payload["order_id"]
Analytics.track_order(order_id)
InventoryService.reserve(order_id)
end
endPgbus::EventBus::Registry.instance.subscribe("orders.created", OrderCreatedHandler)Topic routing#
AMQP-style patterns: * for one segment, # for many.
Patterns match dotted routing keys the way AMQP topic exchanges do: * matches exactly one segment, # matches zero or more. One publish fans out to every subscriber whose pattern matches.
| Pattern | Matches | Doesn't match |
|---|---|---|
orders.created | orders.created | orders.updated |
orders.* | orders.created, orders.updated | orders.line.added |
orders.# | orders.created, orders.line.added | payments.captured |
# Audit everything under the orders.* namespace, at any depth:
Pgbus::EventBus::Registry.instance.subscribe("orders.#", OrderAuditHandler)Current attributes#
Request context travels with the event.
The same config.current_attributes switch that carries Current.tenant / Current.user / Current.request_id into jobs (Active Job guide) carries it into event handlers. With it on, Pgbus.publish snapshots the assigned attributes of each persisted class into the event envelope under pgbus_current — same filters (only: / except:), same ActiveJob::Arguments serialization — and the consumer restores them around handle:
# In the request: Current.tenant = tenant; then…
Pgbus.publish("orders.created", { order_id: order.id })
# In the handler, on the consumer:
class OrderCreatedHandler < Pgbus::EventBus::Handler
def handle(event)
Current.tenant # => the publisher's tenant
event.context # the raw stored form, if you want it explicitly
end
end- The context lives in the envelope (next to
event_id), never inevent.payload, and is copied to every subscriber queue the topic fans out to. PreviousCurrentvalues come back after eachhandle. Pgbus::Outbox.publish_eventcaptures at write time — inside your transaction, whereCurrentis set — and the relay carries it to the bus unchanged.- GlobalIDs inside the context are gated by
allowed_global_id_modelsbefore anything is loaded, exactly like job arguments. Pgbus::Testinground-trips it: fake-mode events exposecontext, anddrain!/ inline dispatch restore it around handlers.- The dashboard's pending-events rows show a Context card (through the same parameter filter as the payload).
Idempotent handlers#
Safe to re-run — deduplicated by (event_id, handler).
At-least-once delivery means a handler can be invoked more than once for the same event (a retry after a crash mid-handle). Declaring idempotent! records each (event_id, handler_class) in pgbus_processed_events with a unique index — a second delivery of the same event to the same handler is skipped, backed by an in-memory cache to avoid the round trip when it can.
idempotency_ttl (default 7 days). The dispatcher purges older rows. Set it long enough to cover your worst-case retry window.