event_rail 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +283 -19
- data/lib/event_rail/current.rb +9 -1
- data/lib/event_rail/errors.rb +7 -4
- data/lib/event_rail/event.rb +6 -4
- data/lib/event_rail/internal/execution.rb +9 -0
- data/lib/event_rail/internal/identity.rb +51 -21
- data/lib/event_rail/internal/notifications.rb +1 -1
- data/lib/event_rail/internal/registry.rb +1 -0
- data/lib/event_rail/internal/stager.rb +99 -0
- data/lib/event_rail/internal/stamping.rb +93 -70
- data/lib/event_rail/job_context.rb +4 -1
- data/lib/event_rail/publish.rb +16 -2
- data/lib/event_rail/railtie.rb +6 -0
- data/lib/event_rail/stage.rb +79 -0
- data/lib/event_rail/staged_publication.rb +34 -0
- data/lib/event_rail/version.rb +1 -1
- data/lib/event_rail.rb +3 -0
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1306e3a4d6e6b69ce720544a4368fa1f7201e29c55651ec6a2e5d8a4d35efcd2
|
|
4
|
+
data.tar.gz: 58597eac171bf85d946e4b063edafe88b71869c0daba79061384ea649d1a27e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd0e456db39d2fce3e1c14fd5e302b011e5b67f4c9d04ecbed62ff19e545c0ab7f2964bce49945bed15f578a82d03b51b6cf307b30cbe50226c74e6b3424c391
|
|
7
|
+
data.tar.gz: 8f460442f9509d71b6132236252399a109456e22e0d72cda816ce618948215bc5a90de844067b31cb0cb7efbaf512fb75aae9acae1172ab4a3860668b6c2ef03
|
data/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,94 @@ here unless a release changes how they behave.
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.5.0] - 2026-09-29
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`EventRail.stage(event, identity:, source:)` records an event with the transaction that
|
|
21
|
+
changes the state it reports.** It stamps the event exactly as `publish` does, builds one
|
|
22
|
+
job per subscriber, and hands them all to your stager in one call instead of enqueuing
|
|
23
|
+
them. The stager persists them with the caller's transaction, all or nothing, and hands
|
|
24
|
+
them to the queue after the commit, so the intent to deliver commits or rolls back with the
|
|
25
|
+
data. This closes the gap at a boundary, such as a controller action, that has no job retry
|
|
26
|
+
to republish after a crash. EventRail ships no table or relay; the README gives two recipes,
|
|
27
|
+
an outbox table and a queue that shares the application's database.
|
|
28
|
+
- **`config.event_rail.stager`**: the name of a constant that responds to `stage(jobs)`,
|
|
29
|
+
resolved on every staging so a reloaded class is always the current one, or an object used
|
|
30
|
+
as given. Preparation fails for a name that resolves to nothing or a stager without `stage`.
|
|
31
|
+
An application that never stages configures nothing.
|
|
32
|
+
- **`EventRail::StagedPublication`**, what `stage` returns: the stamped event, the staged
|
|
33
|
+
jobs, and their subscriber classes. It has no accepted or skipped outcomes, because a
|
|
34
|
+
subscriber's enqueue callbacks run when its job is enqueued, not when it is staged.
|
|
35
|
+
- **`stage.event_rail`**, a fifth notification, carrying `subscriber_count` beside the usual
|
|
36
|
+
keys.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `EventRail::TransactionalPublicationError` now names staging through a configured stager
|
|
41
|
+
as the alternative to publishing after the commit. `publish` inside a transaction still
|
|
42
|
+
raises.
|
|
43
|
+
- Staging shares publication's duplicate check within one job attempt: staging or publishing
|
|
44
|
+
the same fact twice raises `EventRail::DuplicatePublicationError`.
|
|
45
|
+
|
|
46
|
+
## [0.4.0] - 2026-09-26
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **Breaking: an event with declared identity derives its ID from the fact, not from the
|
|
51
|
+
job that publishes it.** With `identity_by` or `identity:`, the ID now comes from the
|
|
52
|
+
event's source, event type and identity only: not the job class, the job's ID, the
|
|
53
|
+
execution or the schema version. The same fact has one ID wherever and however often it
|
|
54
|
+
is published, in a controller or in any job, and in every version of its event type.
|
|
55
|
+
Previously a caller that ran twice enqueued a new job with a new ID and published the same
|
|
56
|
+
fact under a new event ID.
|
|
57
|
+
- **Breaking: `publish(event, key:)` is now `publish(event, identity:)`.** A key used to be
|
|
58
|
+
scoped to one job; an identity names a fact across every job. `key:` raises with
|
|
59
|
+
directions, and `identity:` is refused for a class that declares `identity_by`. An explicit
|
|
60
|
+
identity derives the same ID as a single string `identity_by` attribute with the same value.
|
|
61
|
+
- **Breaking: every derived ID changes.** The derivation has a new namespace and separate,
|
|
62
|
+
documented rules for declared facts and for executions, pinned by vectors published in the
|
|
63
|
+
README so other implementations can reproduce them. Strings are hashed as UTF-8 and invalid
|
|
64
|
+
UTF-8 is refused.
|
|
65
|
+
- **Breaking: random IDs are UUIDv7**, time-ordered, for events without declared identity
|
|
66
|
+
published outside a job. Derived IDs remain UUIDv5.
|
|
67
|
+
- An event without declared identity published by a subscriber derives its ID from the
|
|
68
|
+
handled event's source as well as its ID, so causes from two producers that share an ID are
|
|
69
|
+
scoped apart.
|
|
70
|
+
- The duplicate check within one execution includes the source, so one job can publish the
|
|
71
|
+
same fact for two producers, and relay two events that share an ID from different sources.
|
|
72
|
+
- A relayed event keeps its lineage as it arrived: an absent causation is no longer filled
|
|
73
|
+
from the relaying context. A relay retried with a different payload, occurrence time or
|
|
74
|
+
extensions is refused, as a local retry is.
|
|
75
|
+
- One publication record per `(source, type, version, id)` within an execution: publishing
|
|
76
|
+
the stamped event of a failed local publication, for instance to recover from an
|
|
77
|
+
`EnqueueError`, is its retry, and publishing it after success is a duplicate. A relayed ID
|
|
78
|
+
is compared as opaque bytes, so an ID that is not valid UTF-8 relays as before.
|
|
79
|
+
- **Breaking: a declared identity attribute cannot be an empty string**, as it cannot be nil:
|
|
80
|
+
every blank event would otherwise be one fact.
|
|
81
|
+
- `EventRail.with_context` opened inside a running job keeps the job's execution, so an
|
|
82
|
+
undeclared event published in the block keeps its retry-stable ID and its duplicate check;
|
|
83
|
+
it used to get a random ID.
|
|
84
|
+
- Versioning: every version of an event type keeps its declared identity values; changing
|
|
85
|
+
what an event identifies needs a new event type, not a new version.
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- `publish(event, source:)` publishes a locally built event on behalf of another producer,
|
|
90
|
+
such as an inbound webhook; relaying an event under a different source than it carries is
|
|
91
|
+
refused.
|
|
92
|
+
|
|
93
|
+
### Upgrading
|
|
94
|
+
|
|
95
|
+
- Audit every `identity_by` and every former `key:` before upgrading. A value that was safe
|
|
96
|
+
within one job, such as a line number or a product ID for a fact that recurs, now names one
|
|
97
|
+
fact across every job and would merge distinct facts silently. Identity must name the
|
|
98
|
+
occurrence, not the thing it is about.
|
|
99
|
+
- IDs derived before and after the upgrade differ. A publication retried across the deploy, a
|
|
100
|
+
delayed job, a backfill or a dead-letter redelivery can deliver a fact again under a new ID;
|
|
101
|
+
subscribers that also deduplicate on a business key are unaffected. Draining publishing jobs
|
|
102
|
+
before deploying narrows the window.
|
|
103
|
+
|
|
16
104
|
## [0.3.0] - 2026-09-21
|
|
17
105
|
|
|
18
106
|
### Changed
|
|
@@ -100,7 +188,9 @@ here unless a release changes how they behave.
|
|
|
100
188
|
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
101
189
|
casting, context, serialization, and publication failures.
|
|
102
190
|
|
|
103
|
-
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.
|
|
191
|
+
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.5.0...HEAD
|
|
192
|
+
[0.5.0]: https://github.com/alexdmtv/event-rail/compare/v0.4.0...v0.5.0
|
|
193
|
+
[0.4.0]: https://github.com/alexdmtv/event-rail/compare/v0.3.0...v0.4.0
|
|
104
194
|
[0.3.0]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...v0.3.0
|
|
105
195
|
[0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
|
|
106
196
|
[0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ EventRail is narrow on purpose: durable fanout across a boundary inside one appl
|
|
|
27
27
|
|
|
28
28
|
### What it gives you
|
|
29
29
|
|
|
30
|
-
- **
|
|
30
|
+
- **Stable identity.** An event that declares what fact it is has one ID wherever and however often it is published, so a consumer recognises it arriving again; any other event keeps its ID across retries and redeliveries. Idempotency holds across hops, not only across one subscriber's retries.
|
|
31
31
|
- **Subscribers that are the jobs**, each keeping its own queue, retry policy, and concurrency limits rather than sharing the single wrapper job other gems route subscribers through.
|
|
32
32
|
- **Boot-time validation**: a wrong `perform` arity, a missing `EventRail::JobContext`, or a late declaration fails the boot that introduced it rather than the first publication in production.
|
|
33
33
|
- **A typed boundary**: a `Date`, a `BigDecimal`, a record, or a GlobalID is refused rather than serialized into something a worker cannot restore.
|
|
@@ -36,8 +36,8 @@ EventRail is narrow on purpose: durable fanout across a boundary inside one appl
|
|
|
36
36
|
|
|
37
37
|
### What it does not do
|
|
38
38
|
|
|
39
|
-
- **No event log and no synchronous handlers.** No history, replay, read-model rebuild, or browser UI, and every subscriber crosses the queue.
|
|
40
|
-
- **No outbox
|
|
39
|
+
- **No event log and no synchronous handlers.** No history, replay, read-model rebuild, or browser UI, and every subscriber crosses the queue. A subscriber can append every event to a store of your own, keyed on `(source, id)`, but that store is a downstream log, not event sourcing: publication follows the commit, with no ordering or expected-version guarantees, so the store is a copy of what happened rather than its source of truth.
|
|
40
|
+
- **No outbox of its own.** Publication is refused inside a transaction, so a publisher that publishes commits first, and a process that dies between the two loses the event. Publishing from a job makes that recoverable, because the retry republishes under the same identity (see [Replay-safe publishers](#replay-safe-publishers)). A controller action has no such retry, so at a boundary [stage the event](#staging-inside-a-database-transaction) instead: its subscriber jobs are recorded in the same transaction as the data, through a stager your application supplies, and handed to the queue after the commit. EventRail ships no table and no relay for it; the README gives two recipes.
|
|
41
41
|
- **Discovery is enforced, not advisory.** A subscriber outside a [discovery root](#where-discovery-looks) fails the boot rather than being quietly ignored: a silent delivery bug traded for a loud startup error, at the price of a layout rule.
|
|
42
42
|
|
|
43
43
|
## Requirements
|
|
@@ -230,7 +230,9 @@ Two exceptions. Code that is not reloadable at all, such as a gem or a file plai
|
|
|
230
230
|
|
|
231
231
|
### At-least-once delivery, and what that means for subscribers
|
|
232
232
|
|
|
233
|
-
Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent,
|
|
233
|
+
Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent. An event's full identity is `(source, id)`, so a subscriber records `(subscriber, event.source, event.id)` in the same transaction as its effect and does nothing when that record already exists; a business-key constraint (one refund per order) remains worth having beside it. The ID is stable across retries of the publishing execution and redeliveries of a subscriber's own cause, and, for an event with [declared identity](#identity-occurrence-time-and-source), across every publication of the fact.
|
|
234
|
+
|
|
235
|
+
Two copies of one fact, published by different executions, can differ: a later `occurred_at`, another causation, or a payload read from state that has since changed. EventRail stores nothing and orders nothing, so it cannot say which copy is authoritative; each subscriber decides, and publishers keep copies equal by building events from persisted data.
|
|
234
236
|
|
|
235
237
|
No ordering is promised, between subscribers or between events.
|
|
236
238
|
|
|
@@ -238,10 +240,167 @@ Retries, backoff, discarding, and dead-letter handling stay where they already a
|
|
|
238
240
|
|
|
239
241
|
### Publishing inside a database transaction
|
|
240
242
|
|
|
241
|
-
Don't. Publication raises `EventRail::TransactionalPublicationError` when a transaction is open, in every environment
|
|
243
|
+
Don't. Publication raises `EventRail::TransactionalPublicationError` when a transaction is open, in every environment. The fix is to publish after the transaction commits, or to [stage the event](#staging-inside-a-database-transaction) within it.
|
|
242
244
|
|
|
243
245
|
Both queue deferral settings are wrong inside a transaction, in opposite directions. With `enqueue_after_transaction_commit` on, the enqueue is deferred past the point where its failure can be reported, so a publication that silently enqueued nothing looks successful. With it off, the enqueue announces a fact that a rollback then contradicts. The check is on the open transaction itself, so it does not depend on the setting, or on Active Record being present at all.
|
|
244
246
|
|
|
247
|
+
### Staging inside a database transaction
|
|
248
|
+
|
|
249
|
+
`EventRail.stage` records an event with the transaction that changes the state it reports. It stamps the event exactly as `publish` does, builds one job per subscriber, and hands them all to your **stager** in one call instead of enqueuing them:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
staged = EventRail.stage(
|
|
253
|
+
Docs::OrderPlaced.new(order_id: "A-1003", total: "10.00", placed_at: Time.now.utc.iso8601)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
staged # an EventRail::StagedPublication
|
|
257
|
+
staged.event # the stamped, immutable fact, as publish would stamp it
|
|
258
|
+
staged.staged_jobs # one unenqueued job per subscriber, as the stager received them
|
|
259
|
+
staged.staged_subscribers # their classes
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The stager persists the jobs with the caller's transaction and hands them to the queue once it commits, so the intent to deliver commits or rolls back with the state change. Configure it once, by name:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# doc:illustrative
|
|
266
|
+
# config/application.rb
|
|
267
|
+
config.event_rail.stager = "StagedJob" # resolved on every staging, so a reload is followed
|
|
268
|
+
|
|
269
|
+
# app/models/order.rb
|
|
270
|
+
def ship!
|
|
271
|
+
transaction do
|
|
272
|
+
update!(state: "shipped")
|
|
273
|
+
EventRail.stage(Orders::OrderShipped.new(order_id: id))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`config.event_rail.stager` takes the name of a constant, as a String or a Symbol, resolved each time an event is staged, or an object used as given. Use the name for anything in your application's reloadable code: a class held by reference fails at boot or goes stale after a reload. An object suits a stager in a gem or under `autoload_once_paths`; `lib/` is reloadable once `config.autoload_lib` covers it. Preparation checks that the name resolves and that the stager responds to `stage`, so a typo fails the boot. `EventRail.stage` with no stager configured raises `EventRail::ConfigurationError`; an application that never stages configures nothing.
|
|
279
|
+
|
|
280
|
+
Staging shares publication's duplicate check. Staging or publishing the same fact twice in one job attempt raises `EventRail::DuplicatePublicationError`, and a stager that raises leaves the fact free to be staged again in that attempt, under the same ID. One consequence: a job that stages, has its transaction roll back, and stages again in the same attempt fails as a duplicate, and only its own `retry_on` runs it again. Outside a job there is no duplicate check, and an event without declared identity gets a new ID each time, so **an event staged at a boundary, where a request may be repeated, should declare its identity** or be staged with `identity:`.
|
|
281
|
+
|
|
282
|
+
#### The stager contract
|
|
283
|
+
|
|
284
|
+
A stager is any object with one method, `stage(jobs)`, which receives an array of unenqueued Active Job instances, possibly empty, and its own to change. EventRail calls it even for an event with no subscribers, so the stager's own checks hold either way. Everything else is the stager's:
|
|
285
|
+
|
|
286
|
+
- **All or nothing.** It persists the whole list with the caller's open transaction, or raises. A failure part-way through must not leave earlier jobs behind, even if the caller rescues the error and commits. Atomicity with the business state comes from letting the error propagate: rescuing a staging error and committing drops the event, as rescuing any failed write drops that write.
|
|
287
|
+
- **The transaction check.** Only the stager knows which database it writes to, so it decides whether a suitable transaction is open, and it should raise when none is. Staging outside one reopens the gap staging exists to close: an `update!` that autocommits, then a staged row written separately.
|
|
288
|
+
- **Persistence, not delivery.** What commits with the transaction is the intent to deliver. Delivery then depends on the hand-over, the relay, the queue, and each subscriber's own enqueue callbacks, which run when a job is enqueued rather than when it is staged. A callback that declines the job is the subscriber's decision; the stager decides what it means.
|
|
289
|
+
- **Deferral.** A stager that deletes a staged job must do so only after the queue has actually accepted it. Active Job reports a deferred enqueue as successful before it happens, so hand over only once every open transaction has committed (`ActiveRecord.after_all_transactions_commit`), when an enqueue is immediate whatever the setting. Rails 7.2 applications on `load_defaults "7.2"` defer by default. Whether deferral is in effect differs by version, and Active Job reads the setting when a job is enqueued: on 7.2, `:never` is off, `:always` is on, and any other value, `false` included, asks the queue adapter; on 8.0, `:never` and `:default` are off and `:always` is on, with a deprecation warning, and otherwise a truthy value is on; from 8.1 any truthy value is on, the old symbols included.
|
|
290
|
+
- **Hand-over errors.** A hand-over runs after the caller's transaction has committed, so an error there has nobody to go to: rescue it and leave the job for the relay.
|
|
291
|
+
- **Duplicates.** A job handed over twice is delivered twice under the same event ID. A retried staging builds new jobs, with new job IDs, under the same event ID. Subscribers are idempotent on `(source, id)` either way.
|
|
292
|
+
- **What a staged job carries.** It is built at staging, with the staging context's origin time already in place. Its queue and priority are resolved when the stager serializes or enqueues it; options passed through `set`, and anything a custom `perform_later` does, are not applied. It names its job class and holds EventRail's serialized event, so a renamed subscriber or a changed format follows the same rules as a job already in the queue.
|
|
293
|
+
- **Limits.** Staging the same fact again from inside `stage` is not supported. EventRail provides no storage, relay, ordering or retention for staged jobs.
|
|
294
|
+
|
|
295
|
+
For a database per module, one stager can route: stage into whichever store has an open application transaction, and raise when none or several do. A transaction should never span modules, so several open is a bug, and call sites stay `EventRail.stage(event)` when a module moves to its own database.
|
|
296
|
+
|
|
297
|
+
#### Recipe: an outbox table
|
|
298
|
+
|
|
299
|
+
A table in the application's database, written in the caller's transaction and drained after the commit, with a relay for anything the immediate hand-over missed:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
# doc:illustrative
|
|
303
|
+
# create_table :staged_jobs do |t|
|
|
304
|
+
# t.string :job_id, null: false, index: { unique: true }
|
|
305
|
+
# t.json :payload, null: false
|
|
306
|
+
# t.datetime :created_at, null: false
|
|
307
|
+
# end
|
|
308
|
+
class StagedJob < ApplicationRecord
|
|
309
|
+
# One insert_all! statement writes every row or none. Not insert_all, which skips a
|
|
310
|
+
# conflicting row without saying so.
|
|
311
|
+
def self.stage(jobs)
|
|
312
|
+
raise ArgumentError, "#{name}.stage needs an open transaction to commit with" unless current_transaction.open?
|
|
313
|
+
return if jobs.empty?
|
|
314
|
+
|
|
315
|
+
now = Time.current
|
|
316
|
+
insert_all!(jobs.map { |job| { job_id: job.job_id, payload: job.serialize, created_at: now } })
|
|
317
|
+
|
|
318
|
+
# After every open transaction commits, so the enqueue is immediate whatever the
|
|
319
|
+
# deferral setting, and a row is never deleted for an enqueue that was only deferred.
|
|
320
|
+
ActiveRecord.after_all_transactions_commit { jobs.each { |job| hand_over(job) } }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Deletes the row once the queue took the job, or once a subscriber's own enqueue callback
|
|
324
|
+
# declined it; anything else leaves it for the relay.
|
|
325
|
+
def self.hand_over(job)
|
|
326
|
+
job.enqueue
|
|
327
|
+
if job.enqueue_error
|
|
328
|
+
Rails.logger.warn("Staged job #{job.job_id} left for the relay: #{job.enqueue_error.message}")
|
|
329
|
+
else
|
|
330
|
+
Rails.logger.warn("Staged job #{job.job_id} declined by its enqueue callback") unless job.successfully_enqueued?
|
|
331
|
+
where(job_id: job.job_id).delete_all
|
|
332
|
+
end
|
|
333
|
+
rescue => error
|
|
334
|
+
Rails.logger.warn("Staged job #{job.job_id} left for the relay: #{error.class}: #{error.message}")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Run on a schedule, every second or so. A row that cannot even be loaded -- its job class
|
|
338
|
+
# renamed while it waited -- is left and logged, and the rows after it are still handed over.
|
|
339
|
+
def self.relay(older_than: 5.seconds)
|
|
340
|
+
where(created_at: ...older_than.ago).find_each do |row|
|
|
341
|
+
hand_over(ActiveJob::Base.deserialize(row.payload))
|
|
342
|
+
rescue => error
|
|
343
|
+
Rails.logger.warn("Staged job #{row.job_id} left for the relay: #{error.class}: #{error.message}")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
#### Recipe: a queue in your database
|
|
350
|
+
|
|
351
|
+
A queue whose tables share the application's database and connection, such as GoodJob or Solid Queue configured without a separate database, needs no outbox: enqueuing in the transaction is itself the staging. Two conditions EventRail cannot check for you: the queue must use the same connection as the transaction, and deferral must actually be off for every staged job, or the enqueue becomes an after-commit hook that is not durable. Subscribers' enqueue callbacks run inside the transaction here.
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
# doc:illustrative
|
|
355
|
+
module QueueStager
|
|
356
|
+
module_function
|
|
357
|
+
|
|
358
|
+
def stage(jobs)
|
|
359
|
+
raise ArgumentError, "QueueStager.stage needs an open transaction" unless ApplicationRecord.current_transaction.open?
|
|
360
|
+
|
|
361
|
+
deferred = jobs.map(&:class).uniq.select { |job_class| deferred?(job_class) }
|
|
362
|
+
raise ArgumentError, "#{deferred.map(&:name).join(", ")} defer enqueuing until after commit" if deferred.any?
|
|
363
|
+
|
|
364
|
+
# A savepoint, so a later job's failure takes the earlier ones with it even if the
|
|
365
|
+
# caller rescues the error and commits.
|
|
366
|
+
ApplicationRecord.transaction(requires_new: true) do
|
|
367
|
+
jobs.each do |job|
|
|
368
|
+
job.enqueue
|
|
369
|
+
raise job.enqueue_error if job.enqueue_error
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Whether Active Job would defer this class's enqueue to after the commit. The setting's
|
|
375
|
+
# meaning moved between versions, and it is interpreted when a job is enqueued, not when it
|
|
376
|
+
# is set.
|
|
377
|
+
def deferred?(job_class)
|
|
378
|
+
setting = job_class.enqueue_after_transaction_commit
|
|
379
|
+
|
|
380
|
+
if ActiveJob.version < Gem::Version.new("8.0")
|
|
381
|
+
# 7.2: the adapter decides unless the class said :never or :always.
|
|
382
|
+
case setting
|
|
383
|
+
when :never then false
|
|
384
|
+
when :always then true
|
|
385
|
+
else job_class.queue_adapter.enqueue_after_transaction_commit?
|
|
386
|
+
end
|
|
387
|
+
elsif ActiveJob.version < Gem::Version.new("8.1")
|
|
388
|
+
# 8.0: the old symbols still mean what they meant, with a deprecation warning.
|
|
389
|
+
case setting
|
|
390
|
+
when :never, :default then false
|
|
391
|
+
when :always then true
|
|
392
|
+
else setting ? true : false
|
|
393
|
+
end
|
|
394
|
+
else
|
|
395
|
+
# 8.1 on: any truthy value defers, the old symbols included.
|
|
396
|
+
setting ? true : false
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Both recipes are exercised against real transactions in EventRail's own test suite, adapted to its test tables, including a check of the deferral rule against what each supported Rails version actually does.
|
|
403
|
+
|
|
245
404
|
### Replay-safe publishers
|
|
246
405
|
|
|
247
406
|
A publisher that applies a state transition and then publishes should make the transition idempotent and attempt publication unconditionally:
|
|
@@ -261,22 +420,113 @@ Guarding publication behind "did I already transition?" is the failure mode to a
|
|
|
261
420
|
|
|
262
421
|
## Identity, occurrence time, and source
|
|
263
422
|
|
|
264
|
-
|
|
423
|
+
An event's ID says which fact it is, so a consumer can recognise the same fact arriving again. It is chosen by one of three rules, in order:
|
|
424
|
+
|
|
425
|
+
1. **An event that already carries an ID** — one reconstructed from an envelope — keeps it, with its source and its lineage.
|
|
426
|
+
2. **An event with declared identity** — its class's `identity_by` attributes, or an `identity:` passed to `publish` — derives its ID from its source, its event type and that identity, and from nothing else: not the job that published it, not the execution, not the schema version. The same fact has the same ID wherever and however often it is published, inside a job or in a controller, and in version 1 or version 2.
|
|
427
|
+
3. **Any other event** derives its ID from the execution publishing it: stable across that job's retries, and, for an event a subscriber publishes, across redeliveries of the event it handles. A separate job publishing it again gets a new ID. Outside a job it gets a random, time-ordered ID.
|
|
265
428
|
|
|
266
|
-
|
|
429
|
+
**Identity names the occurrence, not the thing it is about.** This is the rule to get right, because getting it wrong fails silently. `PaymentCaptured identity_by :reference` is right when a payment is captured once. `PriceChanged identity_by :product_id` is wrong: every price change of that product would carry one ID, and consumers would drop all but the first, with no error anywhere. A fact that can happen again for the same thing keys on what distinguishes each occurrence — a persisted change ID, a revision, an import row — never on a hash of the payload or a loop index:
|
|
267
430
|
|
|
268
431
|
```ruby
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
432
|
+
module Docs
|
|
433
|
+
module Inventory
|
|
434
|
+
# One base class per producer, so its source is declared once.
|
|
435
|
+
class Event < EventRail::Event
|
|
436
|
+
default_source "acme.inventory"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Stock is adjusted many times; each adjustment is one fact.
|
|
440
|
+
class StockAdjusted < Event
|
|
441
|
+
event_type "docs.stock_adjusted"
|
|
442
|
+
version 1
|
|
443
|
+
identity_by :adjustment_id
|
|
444
|
+
|
|
445
|
+
attribute :adjustment_id, :string
|
|
446
|
+
attribute :sku, :string
|
|
447
|
+
attribute :quantity, :integer
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
class StockCounted < Event
|
|
451
|
+
event_type "docs.stock_counted"
|
|
452
|
+
version 1
|
|
453
|
+
|
|
454
|
+
attribute :sku, :string
|
|
455
|
+
attribute :counted, :integer
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
first = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
|
|
461
|
+
again = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
|
|
462
|
+
raise "one adjustment, one ID" unless first.id == again.id
|
|
463
|
+
|
|
464
|
+
# A class without identity_by can name the fact at the call site instead.
|
|
465
|
+
EventRail.publish(Docs::Inventory::StockCounted.new(sku: "MUG", counted: 38), identity: "count-2026-09-01-MUG")
|
|
273
466
|
```
|
|
274
467
|
|
|
275
|
-
|
|
468
|
+
Beside that rule:
|
|
469
|
+
|
|
470
|
+
- **`identity:` names a fact across every job**, so it must be unique per source and event type. It is refused for a class that declares `identity_by`, so the two cannot disagree, and it derives the same ID as a single string `identity_by` attribute with the same value, so a class can move from one to the other without changing IDs; an integer `42` and the string `"42"` are different identities.
|
|
471
|
+
- **A tenant belongs in the identity** (or the source) when tenants number their facts independently; an extension is not part of an event's identity.
|
|
472
|
+
- **Never use personal data as identity.** The derivation is a hash, and a hash over an email address or a name is reversible by guessing.
|
|
473
|
+
- **Events that cross an application boundary should declare identity.** An execution-derived ID changes when the publishing code moves, a declared one does not.
|
|
474
|
+
- **Changing `source`, `event_type` or declared identity changes IDs**, so each is a breaking change. Renaming a subscriber class changes the IDs of undeclared events it publishes.
|
|
475
|
+
|
|
476
|
+
Upgrading from a release where `publish` took `key:`: that key was scoped to one job and is now `identity:`, scoped to every job. Before renaming it, and for every `identity_by`, check that the value names one occurrence per source and event type; a value that used to be safe inside one job, such as a line number, now merges facts.
|
|
477
|
+
|
|
478
|
+
`occurred_at` is the logical publication time: an explicit timezone-aware value is preserved as the same instant, and otherwise it is the start of the current execution, stable across that execution's retries and never inherited from a cause. Use a persisted domain timestamp when business occurrence time matters, and build an event from persisted data, so that two publications of one fact carry the same payload. Stored times are UTC at microsecond precision.
|
|
479
|
+
|
|
480
|
+
`source` names the logical producer of an event — `acme.orders` — not an environment, queue, topic, cluster, or deployment. An event ID is unique within its source, so `(source, id)` is an event's full identity, as in CloudEvents, and the source is half of every declared ID. Choose it for the producer rather than the deployment, and a module can move to another application without its events changing IDs. Declare it once, with `default_source` on a module's base event class; an event built on behalf of another producer can be published with `source:` (see [Crossing a network boundary](#crossing-a-network-boundary)). Any bounded non-empty string is accepted, and EventRail does no URI parsing. A follow-up event records its cause's ID as its causation, not its cause's source.
|
|
481
|
+
|
|
482
|
+
### How an ID is derived
|
|
483
|
+
|
|
484
|
+
Other implementations can derive the same IDs. An ID is a UUIDv5 ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562.html)) under the namespace `21fedac0-42c6-443f-b01c-6980aab52f32`, over a name that concatenates encoded components:
|
|
276
485
|
|
|
277
|
-
|
|
486
|
+
- **Declared identity:** `"fact"`, source, event type, and the identity as a list — the `identity_by` values in declaration order, or the one `identity:` value.
|
|
487
|
+
- **Execution:** `"execution"`, source, job class name, scope, event type, version, and a singleton marker. The scope is a regular job's Active Job ID, or, for a subscriber, the list `[source, id]` of the event it handles.
|
|
278
488
|
|
|
279
|
-
|
|
489
|
+
Each component is encoded as a tag, its byte length in decimal, a colon, and its bytes:
|
|
490
|
+
|
|
491
|
+
| Value | Tag | Bytes |
|
|
492
|
+
| --- | --- | --- |
|
|
493
|
+
| String | `s` | its UTF-8 bytes, without Unicode normalization; invalid UTF-8 is refused |
|
|
494
|
+
| Integer | `i` | decimal, with a leading `-` when negative |
|
|
495
|
+
| Decimal | `d` | plain notation: an optional `-`, the integer digits, `.`, and the fractional digits with trailing zeros removed but at least one kept (`12.5`, `100.0`, `0.0`, `-0.0`) |
|
|
496
|
+
| Float | `f` | 17 significant digits with trailing zeros removed, as C's `printf("%.17g")` writes them: positional notation, or exponent notation (`1e+22`, `9.9999999999999995e-08`) when the decimal exponent is below -4 or at least 17, the exponent with a sign and at least two digits; `-0` stays distinct from `0`; non-finite is refused |
|
|
497
|
+
| Boolean | `b` | `true` or `false` |
|
|
498
|
+
| Date | `D` | ISO 8601 (`2026-09-01`) |
|
|
499
|
+
| Timestamp | `T` | converted to UTC, truncated to microseconds, and written as ISO 8601 with six fractional digits (`2026-09-01T10:30:00.123456Z`) |
|
|
500
|
+
| List | `L` | the item count in decimal, a colon, then each item encoded |
|
|
501
|
+
| Singleton marker | `*` | empty |
|
|
502
|
+
|
|
503
|
+
These vectors are part of the contract, and the test suite asserts them. For declared identity the source is `acme.orders` and the event type `orders.order_placed`; for execution, the job class is `Orders::PlaceOrderJob` and the version `1`:
|
|
504
|
+
|
|
505
|
+
| Vector | Encoded name | ID |
|
|
506
|
+
| --- | --- | --- |
|
|
507
|
+
| a string | `s4:facts11:acme.orderss19:orders.order_placedL8:1:s3:o-1` | `13b2cda9-e1f7-57b6-ba1d-bb12a7553655` |
|
|
508
|
+
| a non-ASCII string | `s4:facts11:acme.orderss19:orders.order_placedL12:1:s7:Zürich` | `8ad8bbf8-3e10-5162-97da-53cb6752973f` |
|
|
509
|
+
| an integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:42` | `43230f76-4e80-55d0-bfaf-beab63ac76b9` |
|
|
510
|
+
| a negative integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:-7` | `4feffb91-0dec-5a7b-a5a0-67968abbe971` |
|
|
511
|
+
| true | `s4:facts11:acme.orderss19:orders.order_placedL9:1:b4:true` | `b1a75e27-6aa2-5f7e-9159-fb429bddc17f` |
|
|
512
|
+
| false | `s4:facts11:acme.orderss19:orders.order_placedL10:1:b5:false` | `631f1712-795b-59fa-b497-103b5f951306` |
|
|
513
|
+
| a decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:12.5` | `fc8e4be7-4912-5702-b22b-92d63d710e01` |
|
|
514
|
+
| an integral decimal | `s4:facts11:acme.orderss19:orders.order_placedL10:1:d5:100.0` | `4ab51db7-7864-591d-8e1a-b1f5d9567169` |
|
|
515
|
+
| a zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL8:1:d3:0.0` | `0a066901-77e5-55e3-99c4-a973b5018500` |
|
|
516
|
+
| a negative zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:-0.0` | `784aef76-53c9-517c-ba3f-732053cd537d` |
|
|
517
|
+
| a float | `s4:facts11:acme.orderss19:orders.order_placedL25:1:f19:0.10000000000000001` | `cc223477-2cb1-5a6d-8d0f-6b25aaf989b7` |
|
|
518
|
+
| an integral float | `s4:facts11:acme.orderss19:orders.order_placedL6:1:f1:1` | `901836d7-5477-5780-8e6b-dd39ed49d272` |
|
|
519
|
+
| a large float | `s4:facts11:acme.orderss19:orders.order_placedL10:1:f5:1e+22` | `d4e88ebb-fdaa-5cc4-9dbe-c576abae6f36` |
|
|
520
|
+
| a small float | `s4:facts11:acme.orderss19:orders.order_placedL28:1:f22:9.9999999999999995e-08` | `83b232ee-36bc-53dc-a455-5a9e31a003a2` |
|
|
521
|
+
| negative zero | `s4:facts11:acme.orderss19:orders.order_placedL7:1:f2:-0` | `7cab07fc-874c-59ff-a946-76d00585d229` |
|
|
522
|
+
| a date | `s4:facts11:acme.orderss19:orders.order_placedL16:1:D10:2026-09-01` | `84aaf8b3-7dc1-5446-880d-a002bfe8cab6` |
|
|
523
|
+
| a timestamp | `s4:facts11:acme.orderss19:orders.order_placedL33:1:T27:2026-09-01T10:30:00.123456Z` | `874e797f-7b0c-5742-b8e2-6b05ae3f738a` |
|
|
524
|
+
| a timestamp with an offset and sub-microsecond digits | `s4:facts11:acme.orderss19:orders.order_placedL33:1:T27:2026-09-01T10:30:01.234567Z` | `7fcd39f5-1a94-5ad2-b899-22ac849d510d` |
|
|
525
|
+
| several values | `s4:facts11:acme.orderss19:orders.order_placedL12:2:s3:o-1i1:2` | `fe61e6d0-cbb6-5db0-96a4-1fd78d86dab9` |
|
|
526
|
+
| a regular job's scope | `s9:executions11:acme.orderss21:Orders::PlaceOrderJobs5:job-1s19:orders.order_placedi1:1*0:` | `e3c5921d-a9bf-5816-b9a7-f36f426cb998` |
|
|
527
|
+
| a subscriber's scope | `s9:executions11:acme.orderss21:Orders::PlaceOrderJobL59:2:s13:acme.paymentss36:0b1e2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4ds19:orders.order_placedi1:1*0:` | `a3a31754-e302-5c23-a4a7-ea353accfeb6` |
|
|
528
|
+
|
|
529
|
+
Random IDs, for events without declared identity published outside a job, are UUIDv7, so a log indexes them in time order, to the millisecond, and the version digit tells a random ID (7) from a derived one (5).
|
|
280
530
|
|
|
281
531
|
## Logical context
|
|
282
532
|
|
|
@@ -336,7 +586,9 @@ OpenTelemetry is not required. When standard Active Job instrumentation is insta
|
|
|
336
586
|
|
|
337
587
|
## Versioning events
|
|
338
588
|
|
|
339
|
-
`event_type` and `version` form the durable, language-neutral contract, independent of Ruby class names. A compatible optional addition may keep the same version: an older worker preserves the unknown field as opaque data with no reader and includes it again on export. Removing, renaming, requiring, or retyping a field, changing its meaning
|
|
589
|
+
`event_type` and `version` form the durable, language-neutral contract, independent of Ruby class names. A compatible optional addition may keep the same version: an older worker preserves the unknown field as opaque data with no reader and includes it again on export. Removing, renaming, requiring, or retyping a field, or changing its meaning requires a new version and a distinct class, and the old class stays registered while messages that reference it can still arrive.
|
|
590
|
+
|
|
591
|
+
A new version is a new representation of the same fact, so every version of a type keeps the same declared identity values, in the same order (an attribute may be renamed), and a fact with declared identity carries one ID in every version; an ID derived from the execution includes the version. A consumer subscribed to both versions while old messages drain deduplicates them on that ID. Keeping both representations is a different need: an archive stores `(source, id, version)`, and it must receive both, because a broker or inbox that deduplicates on `(source, id)` before routing by version passes only one of them on. Changing what an event identifies is not a new version but a new event type.
|
|
340
592
|
|
|
341
593
|
EventRail provides no upcasting and no schema registry.
|
|
342
594
|
|
|
@@ -344,7 +596,7 @@ EventRail provides no upcasting and no schema registry.
|
|
|
344
596
|
|
|
345
597
|
| Value | Limit |
|
|
346
598
|
| --- | ---: |
|
|
347
|
-
| Event, correlation, causation, or boundary identifier | 512 bytes |
|
|
599
|
+
| Event, correlation, causation, or boundary identifier, or `identity:` | 512 bytes |
|
|
348
600
|
| Source | 255 bytes |
|
|
349
601
|
| Event type | 255 bytes |
|
|
350
602
|
| Extension entries | 32 |
|
|
@@ -367,7 +619,7 @@ assert_enqueued_with(job: Docs::Billing::ChargeCardJob, args: [ publication.even
|
|
|
367
619
|
perform_enqueued_jobs
|
|
368
620
|
```
|
|
369
621
|
|
|
370
|
-
`assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper; Active Job's helpers are the whole assertion surface.
|
|
622
|
+
`assert_enqueued_with(args:)` works because events are value objects. A staged event reaches the same assertions once its transaction commits and your stager hands its jobs over, so configure your real stager in the test environment: Rails' transactional tests hide their own transaction, so a transaction your code opens runs its commit callbacks, and the hand-over, when its block completes. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper; Active Job's helpers are the whole assertion surface.
|
|
371
623
|
|
|
372
624
|
### Tests that need their own fixtures
|
|
373
625
|
|
|
@@ -427,11 +679,12 @@ Activation replaces a process-wide registry, so it is not safe under `paralleliz
|
|
|
427
679
|
|
|
428
680
|
## Notifications
|
|
429
681
|
|
|
430
|
-
|
|
682
|
+
Five `ActiveSupport::Notifications` events, all in block form so Rails' own exception keys report failures:
|
|
431
683
|
|
|
432
684
|
| Name | Additional payload |
|
|
433
685
|
| --- | --- |
|
|
434
686
|
| `publish.event_rail` | `subscriber_count`, `accepted`, `skipped` |
|
|
687
|
+
| `stage.event_rail` | `subscriber_count` |
|
|
435
688
|
| `enqueue_subscriber.event_rail` | `job_class`, `outcome` (`accepted`, `skipped`, `failed`) |
|
|
436
689
|
| `deserialize.event_rail` | `format_version` |
|
|
437
690
|
| `perform_subscriber.event_rail` | `job_class` |
|
|
@@ -458,7 +711,18 @@ event_class = ACCEPTED.fetch([ envelope.event_type, envelope.version ])
|
|
|
458
711
|
EventRail.publish(envelope.to_event(event_class))
|
|
459
712
|
```
|
|
460
713
|
|
|
461
|
-
A relayed event keeps its origin's ID, source, occurrence time, correlation, and extensions; the relaying application's own context extensions are not merged into it
|
|
714
|
+
A relayed event keeps its origin's ID, source, occurrence time, correlation, causation (an absent one stays absent), and extensions; the relaying application's own context extensions are not merged into it. Relaying it with a different `source:` is refused rather than re-attributing somebody else's fact.
|
|
715
|
+
|
|
716
|
+
A producer that sends no event ID, a supplier's webhook say, is mapped to a local event built on the producer's behalf. Its declared identity and the producer's source make a retried delivery the same fact:
|
|
717
|
+
|
|
718
|
+
```ruby
|
|
719
|
+
EventRail.publish(
|
|
720
|
+
Docs::Inventory::StockAdjusted.new(adjustment_id: "delivery-4411", sku: "MUG", quantity: 120),
|
|
721
|
+
source: "supplier.warehouse"
|
|
722
|
+
)
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
Source is a claim, not a credential. Before relaying, an inbound adapter checks `envelope.source` against the sources that peer may speak for: a declared fact's ID is predictable, so an unchecked envelope claiming a local source could arrive first under a local fact's ID and make consumers drop the genuine one.
|
|
462
726
|
|
|
463
727
|
Loop prevention is application policy: an export policy that refuses to export an event whose source is not the local application stops a relayed event from bouncing back to the system it came from. EventRail has no concept of internal versus external, direction, topic, or transport marker.
|
|
464
728
|
|
data/lib/event_rail/current.rb
CHANGED
|
@@ -49,7 +49,15 @@ module EventRail
|
|
|
49
49
|
inherited[:extensions], extensions, error: InvalidContext
|
|
50
50
|
)
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
# A block opened inside a running job stays part of that job's execution: its
|
|
53
|
+
# publications keep the job's identity rules and its duplicate record. Only a
|
|
54
|
+
# block with no job around it is a boundary of its own.
|
|
55
|
+
running = Internal::Execution.current
|
|
56
|
+
execution = if running&.derives_identity?
|
|
57
|
+
running
|
|
58
|
+
else
|
|
59
|
+
Internal::Execution.new(scope: resolved_message_id, started_at: resolved_originated_at)
|
|
60
|
+
end
|
|
53
61
|
|
|
54
62
|
# A nested scope keeps its parent's causation: the message that caused this flow
|
|
55
63
|
# does not change because the application opened an inner block.
|
data/lib/event_rail/errors.rb
CHANGED
|
@@ -14,8 +14,10 @@ module EventRail
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# An application's own configuration is wrong: a discovery root that is not an autoload
|
|
17
|
-
# root of the application or any engine
|
|
18
|
-
#
|
|
17
|
+
# root of the application or any engine, or a stager that cannot be resolved or cannot
|
|
18
|
+
# stage. Distinct from a declaration fault, which is about a class body. Raised at
|
|
19
|
+
# preparation where it can be, so it fails the boot that introduced it, and by
|
|
20
|
+
# `EventRail.stage` when no stager is configured at all.
|
|
19
21
|
class ConfigurationError < Error
|
|
20
22
|
end
|
|
21
23
|
|
|
@@ -171,12 +173,13 @@ module EventRail
|
|
|
171
173
|
# Publication happened inside an open application database transaction. Both queue
|
|
172
174
|
# deferral settings are wrong there, in opposite directions: a deferred enqueue
|
|
173
175
|
# cannot report its own failure, and an immediate one announces a fact a rollback
|
|
174
|
-
# then contradicts.
|
|
176
|
+
# then contradicts. Staging is the way to record an event with the transaction.
|
|
175
177
|
class TransactionalPublicationError < PublicationError
|
|
176
178
|
def initialize(message = nil)
|
|
177
179
|
super(
|
|
178
180
|
message ||
|
|
179
|
-
"cannot publish inside an open database transaction; publish after the transaction commits"
|
|
181
|
+
"cannot publish inside an open database transaction; publish after the transaction commits, " \
|
|
182
|
+
"or stage the event within it through a configured stager (EventRail.stage)"
|
|
180
183
|
)
|
|
181
184
|
end
|
|
182
185
|
end
|
data/lib/event_rail/event.rb
CHANGED
|
@@ -232,17 +232,19 @@ module EventRail
|
|
|
232
232
|
self.class.send(:__event_rail_copy__, self, state: { :@metadata => stamped_metadata })
|
|
233
233
|
end
|
|
234
234
|
|
|
235
|
-
# A declared identity attribute that is nil cannot produce a canonical value,
|
|
236
|
-
#
|
|
235
|
+
# A declared identity attribute that is nil cannot produce a canonical value, and
|
|
236
|
+
# one that is empty would make every blank event the same fact, so neither can be
|
|
237
|
+
# published. The local constructor is the strict door and
|
|
237
238
|
# the only place whose backtrace points at the code that left the field empty.
|
|
238
239
|
# Trusted reconstruction stays permissive: an external event of this contract may
|
|
239
240
|
# legitimately omit a field, and it arrives with an identity already assigned.
|
|
240
241
|
def validate_local_identity!
|
|
241
|
-
missing = self.class.identity_by.select { |name| public_send(name).nil? }
|
|
242
|
+
missing = self.class.identity_by.select { |name| public_send(name).then { |value| value.nil? || value == "" } }
|
|
242
243
|
return if missing.empty?
|
|
243
244
|
|
|
244
245
|
raise InvalidEvent,
|
|
245
|
-
"#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil"
|
|
246
|
+
"#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil or empty: " \
|
|
247
|
+
"every event with a blank identity would be one fact"
|
|
246
248
|
end
|
|
247
249
|
|
|
248
250
|
def unknown_attributes_message(unknown)
|
|
@@ -73,6 +73,15 @@ module EventRail
|
|
|
73
73
|
@publications[key] = Record.new(event: event, succeeded: succeeded)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Puts back what `record` returned earlier: nil forgets the key entirely.
|
|
77
|
+
def restore!(key, record)
|
|
78
|
+
if record.nil?
|
|
79
|
+
@publications.delete(key)
|
|
80
|
+
else
|
|
81
|
+
@publications[key] = record
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
# What one logical publication looked like the last time this attempt tried it.
|
|
77
86
|
# Retaining the failed case is what lets the same attempt retry complete fanout
|
|
78
87
|
# under the identity it already stamped, instead of manufacturing a second one.
|