Events — ephemeral vs durable subscribers
OpenLoam has one domain event bus and two ways to subscribe. They are not interchangeable; picking the wrong one is a correctness bug, so the contract is explicit.
Publishing (unchanged)
OpenLoam::Events.publish("billing.invoice.paid", { id: invoice.id })
Names are domain.thing.happened. Publishing stamps tenant_id and actor_id
onto the payload. Entities that include OpenLoam::Eventful publish
created/updated/destroyed automatically on after_*_commit.
Payloads are ids and scalars by convention, never records — the same primitive crosses into a webhook body or a durable delivery row.
Ephemeral subscribers — OpenLoam::Events.subscribe
OpenLoam::Events.subscribe("billing.") { |name, payload| ... } # a whole domain
OpenLoam::Events.subscribe("billing.invoice.paid") { |name, payload| ... } # one event
- Runs inline in the publisher’s thread, synchronously.
- Best-effort, no persistence, no retry.
- An exception in the block propagates into whatever published the event — it can fail the request that triggered it.
Use it for cheap in-process fan-out where losing the callback on a crash is fine and where you want the work inline. The webhook dispatcher is the canonical example: it subscribes to every event and enqueues its own delivery jobs.
Durable subscribers — OpenLoam::DurableEvents.register
# in an initializer (boot-time, trusted code)
OpenLoam::DurableEvents.register(
key: "billing_grant_access", # stable id, stored on every delivery row
to: "billing.invoice.paid", # event name, or "billing." for a domain
call: "Billing::GrantAccessHandler" # responds to .call(event_name, payload)
)
On publish, for each matching durable subscriber OpenLoam:
- commits a
OpenLoam::EventDeliveryrow in the event’s tenant (statuspending), then - enqueues
OpenLoam::EventDeliveryJobto run the handler.
The job resolves the handler from the registry by key and calls it. On
success the row is delivered; on failure the row records the error, increments
attempts, and sets next_attempt_at to a backoff. Past MAX_ATTEMPTS (5) the
row is parked dead for an operator.
The guarantee
At-least-once, unordered. Handlers MUST be idempotent — a retry or the sweep can deliver the same event twice. If your handler grants access, granting twice must be harmless; if it must run exactly once, dedupe inside the handler (e.g. keyed on the payload id).
Durability is of delivery, not capture. An event whose process dies
between the after_commit and the publish leaves no row and is lost — exactly
as today. This feature makes what was published arrive; it cannot resurrect
what was never published.
The sweep is the real durability
perform_later at publish is only an accelerator. The durability comes from
OpenLoam::EventRedeliverySweepJob — registered per tenant on a 5-minute schedule —
which re-enqueues any pending row whose backoff has elapsed. So a delivery
survives a lost queue message, a crashed worker, or an async adapter that runs
the job before the creating transaction commits (the job no-ops on the invisible
row; the sweep picks it up once it commits).
Dead-letter
Admin::EventDeliveriesController (/admin/event_deliveries, manager-only)
lists dead deliveries with the last error and a Requeue button that re-arms
the row to pending and nudges a job — the fix-the-handler-then-retry loop.
Security
A durable handler is resolved from the in-memory registry, populated at boot
from trusted code — never constantized from the stored row. If the key is
unknown at delivery time (the subscriber was removed since enqueue), the row is
parked dead; an arbitrary class is never executed off a database value. This
is the same posture as the scheduler’s job_class allowlist.
Nil-tenant events are not durably delivered (the same decision the webhook dispatcher makes) — durable delivery is a tenant-scoped guarantee.
Choosing
| Ephemeral | Durable | |
|---|---|---|
| API | Events.subscribe |
DurableEvents.register |
| Runs | inline, sync | background job |
| Survives a crash | no | yes (row + sweep) |
| Retry | no | yes, backoff → dead-letter |
| Exception | propagates to publisher | contained in the job |
| Ordering | publish order, inline | unordered |
| Use for | cheap in-process fan-out | side effects that must not be lost |
The event log — capture, not delivery
Both tiers above describe delivery: who gets told, and how hard the system tries. Neither records that the event happened, which is why a publish nobody subscribed to used to leave no trace.
OpenLoam::EventLog is the capture half. Every publish becomes one append-only
OpenLoam::EventRecord row in the event’s tenant:
OpenLoam::EventLog.read("rental.") # whole domain, oldest first
OpenLoam::EventLog.read("rental.equipment.created") # one event name
OpenLoam::EventLog.read("billing.", since: 7.days.ago)
OpenLoam::EventLog.replay("billing.") do |name, payload|
# payload has STRING keys here — it came back from the row, as in durable
# delivery, not the symbols an inline subscriber receives.
end
Replay is a re-read of history, not a second publish: nothing else on the bus fires and a replayed event is not captured again, so a replay handler must be idempotent but cannot cascade.
Capture is on, and captures everything except the patterns in
OpenLoam.uncaptured_events (shipped default: open_loam.progress., since a
bulk import fires one tick per row). Capture runs inline, so a failed insert
propagates into the publishing operation. Nil-tenant events are not captured,
matching durable delivery and the webhook dispatcher.
Retention is OpenLoam.event_log_retention — 90 days by default, nil to keep
everything — swept daily per tenant by OpenLoam::EventLogPruneJob.
ADR 0007 has the reasoning: why capture-all is the default where broadcasting is opt-in, and why this stayed in-gem rather than adopting Rails Event Store.