event_rail 0.2.0 → 0.4.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 +74 -1
- data/README.md +198 -36
- data/lib/event_rail/current.rb +9 -1
- data/lib/event_rail/event.rb +6 -4
- data/lib/event_rail/internal/identity.rb +51 -21
- data/lib/event_rail/internal/stamping.rb +77 -68
- data/lib/event_rail/job_context.rb +4 -1
- data/lib/event_rail/publish.rb +12 -2
- data/lib/event_rail/subscriptions.rb +19 -4
- data/lib/event_rail/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3367f3d4d8f5eb0841f1cca4021af4faa7a3fac3bf1a94edb328f5206eccd516
|
|
4
|
+
data.tar.gz: 795013406723b9b46fc5e5ca4532a0a2b5f35c0ab9ca3a49240bb63c09b13ead
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3df1e24c288ab7f89544af2286456e5e2a9f64ed7155a83479794fda04728c86b71cbf9f8486d9dc09873a9a35920efe316acf25211be3eeb6111e3be326ee96
|
|
7
|
+
data.tar.gz: cd069c5dcd53f4195e5171db86cbe75cfece2dfbbf1218c36710cf2fc54e9b5a932a48eaa685d9b5abc8eb6b75ad6cef24fc00bb40bd070032ce5397b1c6a3ed
|
data/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,77 @@ here unless a release changes how they behave.
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.4.0] - 2026-09-26
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking: an event with declared identity derives its ID from the fact, not from the
|
|
21
|
+
job that publishes it.** With `identity_by` or `identity:`, the ID now comes from the
|
|
22
|
+
event's source, event type and identity only: not the job class, the job's ID, the
|
|
23
|
+
execution or the schema version. The same fact has one ID wherever and however often it
|
|
24
|
+
is published, in a controller or in any job, and in every version of its event type.
|
|
25
|
+
Previously a caller that ran twice enqueued a new job with a new ID and published the same
|
|
26
|
+
fact under a new event ID.
|
|
27
|
+
- **Breaking: `publish(event, key:)` is now `publish(event, identity:)`.** A key used to be
|
|
28
|
+
scoped to one job; an identity names a fact across every job. `key:` raises with
|
|
29
|
+
directions, and `identity:` is refused for a class that declares `identity_by`. An explicit
|
|
30
|
+
identity derives the same ID as a single string `identity_by` attribute with the same value.
|
|
31
|
+
- **Breaking: every derived ID changes.** The derivation has a new namespace and separate,
|
|
32
|
+
documented rules for declared facts and for executions, pinned by vectors published in the
|
|
33
|
+
README so other implementations can reproduce them. Strings are hashed as UTF-8 and invalid
|
|
34
|
+
UTF-8 is refused.
|
|
35
|
+
- **Breaking: random IDs are UUIDv7**, time-ordered, for events without declared identity
|
|
36
|
+
published outside a job. Derived IDs remain UUIDv5.
|
|
37
|
+
- An event without declared identity published by a subscriber derives its ID from the
|
|
38
|
+
handled event's source as well as its ID, so causes from two producers that share an ID are
|
|
39
|
+
scoped apart.
|
|
40
|
+
- The duplicate check within one execution includes the source, so one job can publish the
|
|
41
|
+
same fact for two producers, and relay two events that share an ID from different sources.
|
|
42
|
+
- A relayed event keeps its lineage as it arrived: an absent causation is no longer filled
|
|
43
|
+
from the relaying context. A relay retried with a different payload, occurrence time or
|
|
44
|
+
extensions is refused, as a local retry is.
|
|
45
|
+
- One publication record per `(source, type, version, id)` within an execution: publishing
|
|
46
|
+
the stamped event of a failed local publication, for instance to recover from an
|
|
47
|
+
`EnqueueError`, is its retry, and publishing it after success is a duplicate. A relayed ID
|
|
48
|
+
is compared as opaque bytes, so an ID that is not valid UTF-8 relays as before.
|
|
49
|
+
- **Breaking: a declared identity attribute cannot be an empty string**, as it cannot be nil:
|
|
50
|
+
every blank event would otherwise be one fact.
|
|
51
|
+
- `EventRail.with_context` opened inside a running job keeps the job's execution, so an
|
|
52
|
+
undeclared event published in the block keeps its retry-stable ID and its duplicate check;
|
|
53
|
+
it used to get a random ID.
|
|
54
|
+
- Versioning: every version of an event type keeps its declared identity values; changing
|
|
55
|
+
what an event identifies needs a new event type, not a new version.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- `publish(event, source:)` publishes a locally built event on behalf of another producer,
|
|
60
|
+
such as an inbound webhook; relaying an event under a different source than it carries is
|
|
61
|
+
refused.
|
|
62
|
+
|
|
63
|
+
### Upgrading
|
|
64
|
+
|
|
65
|
+
- Audit every `identity_by` and every former `key:` before upgrading. A value that was safe
|
|
66
|
+
within one job, such as a line number or a product ID for a fact that recurs, now names one
|
|
67
|
+
fact across every job and would merge distinct facts silently. Identity must name the
|
|
68
|
+
occurrence, not the thing it is about.
|
|
69
|
+
- IDs derived before and after the upgrade differ. A publication retried across the deploy, a
|
|
70
|
+
delayed job, a backfill or a dead-letter redelivery can deliver a fact again under a new ID;
|
|
71
|
+
subscribers that also deduplicate on a business key are unaffected. Draining publishing jobs
|
|
72
|
+
before deploying narrows the window.
|
|
73
|
+
|
|
74
|
+
## [0.3.0] - 2026-09-21
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- **`subscribes_to` now raises `EventRail::DeclarationError` when the event class it names
|
|
79
|
+
declares neither `event_type` nor `version`**, at the declaration itself. Previously such
|
|
80
|
+
a subscription was accepted and then never fired: publication matches subscribers by exact
|
|
81
|
+
event class, so a concrete subclass never reached the base's registration, and the class
|
|
82
|
+
without a contract could not be constructed to be published on its own. Because neither
|
|
83
|
+
declaration is inherited, this covers a shared abstract base such as `ApplicationEvent`
|
|
84
|
+
and equally a subclass of a concrete event that adds nothing of its own. Subscribe to each
|
|
85
|
+
concrete event class instead.
|
|
86
|
+
|
|
16
87
|
## [0.2.0] - 2026-09-17
|
|
17
88
|
|
|
18
89
|
### Added
|
|
@@ -87,6 +158,8 @@ here unless a release changes how they behave.
|
|
|
87
158
|
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
88
159
|
casting, context, serialization, and publication failures.
|
|
89
160
|
|
|
90
|
-
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.
|
|
161
|
+
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.4.0...HEAD
|
|
162
|
+
[0.4.0]: https://github.com/alexdmtv/event-rail/compare/v0.3.0...v0.4.0
|
|
163
|
+
[0.3.0]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...v0.3.0
|
|
91
164
|
[0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
|
|
92
165
|
[0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -2,9 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/alexdmtv/event-rail/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
When a Rails application grows, the code that places an order ends up naming everything that should happen next: send the confirmation email, update the loyalty balance, notify the warehouse, write the analytics row. Each addition is another dependency in the one place that can least afford them.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
There are two ways for one part of an application to reach another. The synchronous one — call a method, get an answer — Ruby hands you for free, and it is the one above: the caller names the callee. The other inverts it. One part announces a fact, and whoever cares subscribes. The dependency does not vanish: a subscriber still names the event, and the event belongs to the code that publishes it. But it points the other way, and it is far narrower: a dependency on something that happened, not on how the other side works. What matters is the side that accumulates. Code that invokes every interested party learns a new name for each one; code that announces a fact learns nothing.
|
|
8
|
+
|
|
9
|
+
Rails ships no way to express the second. `ActiveSupport::Notifications` is in-process and untyped, Active Record callbacks are the coupling you were trying to escape, and an event store asks you to adopt a storage model in order to send one message across a boundary.
|
|
10
|
+
|
|
11
|
+
EventRail is that second interface. An event is an immutable, versioned value. Every subscriber is an ordinary Active Job that keeps its own queue, retry policy, and concurrency limits, and delivery goes through the queue adapter you already run. The publisher never names a subscriber. There is no transport, no registration API, and no runtime of its own.
|
|
12
|
+
|
|
13
|
+
EventRail is pre-1.0: the public API may change in a minor release, and every change is documented in the [CHANGELOG](CHANGELOG.md). Delivery semantics, safety limits, and the notification contract are settled and documented below.
|
|
14
|
+
|
|
15
|
+
## When to use EventRail
|
|
16
|
+
|
|
17
|
+
EventRail is narrow on purpose: durable fanout across a boundary inside one application, where each subscriber retries independently and the same fact keeps the same identity across every retry and every hop. Reaching for an event store to get that means adopting its storage model too: entities as streams, state rebuilt from history. That is a large commitment for what is, at the boundary, one message. If you want that commitment, or something else entirely, these fit better.
|
|
18
|
+
|
|
19
|
+
| If you need | Use | Because |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| An event log: audit history, replay, rebuilding read models | [Rails Event Store](https://railseventstore.org) | It is a store. Events persist in the same transaction as the state that produced them, which also closes the dual-write gap below. |
|
|
22
|
+
| Handlers that run inside the same request | [downstream](https://github.com/palkan/downstream) | Delivery is synchronous by default, async opt-in per subscriber. |
|
|
23
|
+
| In-process decoupling and nothing more | `ActiveSupport::Notifications` | Already in your application. No gem, no contract, no queue. |
|
|
24
|
+
| Events to be the system of record, not a message | [Sequent](https://www.sequent.io) | A full event-sourcing framework: commands, aggregates, projectors. |
|
|
25
|
+
|
|
26
|
+
**Why not call `perform_later` three times?** That is already durable fanout, and for three jobs in one method it is the right answer. EventRail earns its place when the publisher should not know its subscribers, when the payload needs a versioned contract that outlives a worker running older code, and when a retry must not produce a second copy of a fact already recorded.
|
|
27
|
+
|
|
28
|
+
### What it gives you
|
|
29
|
+
|
|
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
|
+
- **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
|
+
- **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
|
+
- **A typed boundary**: a `Date`, a `BigDecimal`, a record, or a GlobalID is refused rather than serialized into something a worker cannot restore.
|
|
34
|
+
- **Your own layout**: discovery matches a path suffix against the autoload roots Rails already has, covering engines and a packwerk-style `packs/billing/app/jobs` with no configuration.
|
|
35
|
+
- **No Active Record**, table, migration, or initializer.
|
|
36
|
+
|
|
37
|
+
### What it does not do
|
|
38
|
+
|
|
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, and so a dual-write gap.** Publication is refused inside a transaction, so a publisher commits and then publishes, 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 retry to repeat the publication, though an event with declared identity keeps its ID when anything does. An application that wants recovery beyond that records the intent to publish in the same transaction as its data, and a sweeper or a relay job publishes it; an event with [declared identity](#identity-occurrence-time-and-source) derives the same ID however many times it is republished, so consumers see a repetition rather than a new fact.
|
|
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.
|
|
8
42
|
|
|
9
43
|
## Requirements
|
|
10
44
|
|
|
@@ -20,7 +54,7 @@ EventRail depends on Active Support, Active Model, Active Job, Railties, and Zei
|
|
|
20
54
|
gem "event_rail"
|
|
21
55
|
```
|
|
22
56
|
|
|
23
|
-
Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose
|
|
57
|
+
Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose, [where it looks for events and subscribers](#where-discovery-looks), has a default that most applications never change.
|
|
24
58
|
|
|
25
59
|
## Job integration
|
|
26
60
|
|
|
@@ -88,13 +122,13 @@ event.metadata # immutable metadata; not yet stamped
|
|
|
88
122
|
|
|
89
123
|
`attributes` is Active Model's own meaning and is what application code reads. `data` is the single written form that both the queue representation and the public envelope use, so a `Date`, a `BigDecimal`, or a nested Ruby object never reaches a queue adapter or a codec.
|
|
90
124
|
|
|
91
|
-
Casting refuses to discard information rather than substituting a plausible value. `"abc"` is not `0`, `true` is not `"t"`, a timestamp needs an explicit offset, and a date attribute will not silently drop a time of day. An untyped attribute accepts only recursively JSON-like values
|
|
125
|
+
Casting refuses to discard information rather than substituting a plausible value. `"abc"` is not `0`, `true` is not `"t"`, a timestamp needs an explicit offset, and a date attribute will not silently drop a time of day. An untyped attribute accepts only recursively JSON-like values: strings, integers, finite floats, booleans, `nil`, arrays, and string-keyed hashes. Those are bounded in nesting depth and forbidden from using the `_aj_` prefix Active Job reserves in its own argument encoding. Records, GlobalID values, arbitrary Ruby objects, non-finite numbers, undeclared attributes, and callable defaults are rejected.
|
|
92
126
|
|
|
93
127
|
Constructed events, nested data, metadata, and every contained value are recursively immutable. Two events of one class with equal payload and equal metadata are equal values, which is what lets `assert_enqueued_with(args: [event])` match a published event.
|
|
94
128
|
|
|
95
129
|
### Custom attribute types
|
|
96
130
|
|
|
97
|
-
A custom Active Model type may be used as an attribute type. Rails' `serialize` is documented as producing a value "usable by the database", and a database driver accepts a `Date` or a `BigDecimal` object
|
|
131
|
+
A custom Active Model type may be used as an attribute type. Rails' `serialize` is documented as producing a value "usable by the database", and a database driver accepts a `Date` or a `BigDecimal` object. A queue does not, and Active Job does not recurse into a serializer's output, so such a value would reach the adapter raw. Including `EventRail::PortableType` narrows the promise to a JSON primitive, array, or string-keyed hash, and `portable_examples` makes the promise checkable:
|
|
98
132
|
|
|
99
133
|
```ruby
|
|
100
134
|
module Docs
|
|
@@ -124,17 +158,37 @@ Docs::ParcelShipped.new(parcel_id: "P-1", weight: 2500).data
|
|
|
124
158
|
# => { "parcel_id" => "P-1", "weight" => 2500 }
|
|
125
159
|
```
|
|
126
160
|
|
|
127
|
-
Every example is round-tripped through JSON when the attribute is declared, so a type that cannot hold up fails at class definition rather than at the first enqueue. A type whose cast value is already a portable scalar
|
|
161
|
+
Every example is round-tripped through JSON when the attribute is declared, so a type that cannot hold up fails at class definition rather than at the first enqueue. A type whose cast value is already a portable scalar (a plain `:string` subclass, say) needs none of this.
|
|
128
162
|
|
|
129
163
|
## Publishing
|
|
130
164
|
|
|
131
165
|
```ruby
|
|
132
166
|
module Docs
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
# app/jobs/billing/charge_card_job.rb
|
|
168
|
+
module Billing
|
|
169
|
+
class ChargeCardJob < ApplicationJob
|
|
170
|
+
queue_as :payments
|
|
171
|
+
retry_on Timeout::Error, attempts: 10
|
|
135
172
|
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
subscribes_to OrderPlaced
|
|
174
|
+
|
|
175
|
+
def perform(event)
|
|
176
|
+
Rails.logger.info("charging #{event.order_id} idempotently on #{event.id}")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# packs/analytics/app/jobs/analytics/record_order_job.rb
|
|
182
|
+
module Analytics
|
|
183
|
+
class RecordOrderJob < ApplicationJob
|
|
184
|
+
queue_as :low
|
|
185
|
+
discard_on ActiveJob::DeserializationError
|
|
186
|
+
|
|
187
|
+
subscribes_to OrderPlaced
|
|
188
|
+
|
|
189
|
+
def perform(event)
|
|
190
|
+
Rails.logger.info("recording #{event.order_id}")
|
|
191
|
+
end
|
|
138
192
|
end
|
|
139
193
|
end
|
|
140
194
|
end
|
|
@@ -148,6 +202,8 @@ publication.accepted_subscribers # subscribers whose enqueue was accepted
|
|
|
148
202
|
publication.skipped_subscribers # subscribers whose own enqueue callback declined
|
|
149
203
|
```
|
|
150
204
|
|
|
205
|
+
Billing and analytics do not know about each other, and the publishing code names neither. Adding a third subscriber is adding a file; the code that publishes `OrderPlaced` never changes. Each one keeps its own queue and its own failure policy, because each one is just a job. A payment that retries for ten attempts and an analytics write that is discarded when its event can no longer be loaded are the same fanout, configured separately.
|
|
206
|
+
|
|
151
207
|
Subscribers are discovered from the conventional `app/events` and `app/jobs` roots of the host application and every engine, during Rails preparation. There is no registration API, no initializer, and no registry to query.
|
|
152
208
|
|
|
153
209
|
### Where discovery looks
|
|
@@ -160,21 +216,23 @@ config.event_rail.roots << "app/subscribers" # default: %w[app/events app/jobs
|
|
|
160
216
|
|
|
161
217
|
Each entry is matched as a path suffix against the autoload roots Rails already has, so one entry covers the host application, every engine, and a packwerk-style `packs/billing/app/jobs` without naming any of them. Appending is additive: the defaults stay in effect.
|
|
162
218
|
|
|
163
|
-
Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload
|
|
219
|
+
Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload; moving the file is usually the better fix. And a root you name that is not an autoload root of the application or any engine fails preparation with `EventRail::ConfigurationError`, so a typo is a boot error rather than a directory that silently discovers nothing. A *default* root the application has not created is simply skipped, so a fresh application with no `app/events` directory boots normally.
|
|
164
220
|
|
|
165
|
-
`subscribes_to` is exact and not inherited
|
|
221
|
+
`subscribes_to` is exact and not inherited on either side. A subclass of a subscriber is a different job and receives nothing. A subscriber must define its own `perform` taking exactly one required positional event parameter, must not have subclasses, and must include `EventRail::JobContext`. Each of those is checked during preparation, so a mistake fails the boot that introduced it rather than the first publication. The event class a subscription names must declare a contract of its own. Neither `event_type` nor `version` is inherited, so a shared abstract base and a subclass that adds nothing of its own are both refused, and refused at the declaration itself rather than during preparation, because the class is already loaded by the time the macro reads it. A class that declares one of the two and not the other is a broken contract rather than a base, and still fails preparation naming the event class.
|
|
166
222
|
|
|
167
|
-
A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported
|
|
223
|
+
A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported; name the class first. The check is on the name the class carries, not on whether a constant resolves to it, so a class with a name nothing resolves to is still dropped from the registry without comment; that is a deliberate limit, not an oversight.
|
|
168
224
|
|
|
169
225
|
One rule covers both halves of discovery: **an event contract or a subscription declared after preparation has sealed the registry raises**, naming the file and line and the two ways to fix it. EventRail refuses to run with a subscriber that would receive nothing, or with an event class a worker could not reconstruct from the queue.
|
|
170
226
|
|
|
171
227
|
Adding the directory to `config.eager_load_paths` is never enough on its own, and the reason is Rails' own ordering: prepare callbacks run *before* `eager_load!`, so a reloadable class outside a discovery root cannot be loaded in time in any environment. Either move the file under a discovery root, or [add its root](#where-discovery-looks).
|
|
172
228
|
|
|
173
|
-
Two exceptions. Code that is not reloadable at all
|
|
229
|
+
Two exceptions. Code that is not reloadable at all, such as a gem or a file plainly required from an initializer, ran its declaration before preparation, so it is already registered; such a file has to bring its own event class and job base, because initializers run before the main autoloader exists. And a test suite loads after preparation by definition, which is what [`EventRail::TestHelper`](#tests-that-need-their-own-fixtures) is for.
|
|
174
230
|
|
|
175
231
|
### At-least-once delivery, and what that means for subscribers
|
|
176
232
|
|
|
177
|
-
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.
|
|
178
236
|
|
|
179
237
|
No ordering is promised, between subscribers or between events.
|
|
180
238
|
|
|
@@ -184,7 +242,7 @@ Retries, backoff, discarding, and dead-letter handling stay where they already a
|
|
|
184
242
|
|
|
185
243
|
Don't. Publication raises `EventRail::TransactionalPublicationError` when a transaction is open, in every environment, and the fix is to publish after the transaction commits.
|
|
186
244
|
|
|
187
|
-
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
|
|
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.
|
|
188
246
|
|
|
189
247
|
### Replay-safe publishers
|
|
190
248
|
|
|
@@ -205,22 +263,113 @@ Guarding publication behind "did I already transition?" is the failure mode to a
|
|
|
205
263
|
|
|
206
264
|
## Identity, occurrence time, and source
|
|
207
265
|
|
|
208
|
-
|
|
266
|
+
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:
|
|
267
|
+
|
|
268
|
+
1. **An event that already carries an ID** — one reconstructed from an envelope — keeps it, with its source and its lineage.
|
|
269
|
+
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.
|
|
270
|
+
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.
|
|
209
271
|
|
|
210
|
-
|
|
272
|
+
**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:
|
|
211
273
|
|
|
212
274
|
```ruby
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
275
|
+
module Docs
|
|
276
|
+
module Inventory
|
|
277
|
+
# One base class per producer, so its source is declared once.
|
|
278
|
+
class Event < EventRail::Event
|
|
279
|
+
default_source "acme.inventory"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Stock is adjusted many times; each adjustment is one fact.
|
|
283
|
+
class StockAdjusted < Event
|
|
284
|
+
event_type "docs.stock_adjusted"
|
|
285
|
+
version 1
|
|
286
|
+
identity_by :adjustment_id
|
|
287
|
+
|
|
288
|
+
attribute :adjustment_id, :string
|
|
289
|
+
attribute :sku, :string
|
|
290
|
+
attribute :quantity, :integer
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
class StockCounted < Event
|
|
294
|
+
event_type "docs.stock_counted"
|
|
295
|
+
version 1
|
|
296
|
+
|
|
297
|
+
attribute :sku, :string
|
|
298
|
+
attribute :counted, :integer
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
218
302
|
|
|
219
|
-
|
|
303
|
+
first = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
|
|
304
|
+
again = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
|
|
305
|
+
raise "one adjustment, one ID" unless first.id == again.id
|
|
220
306
|
|
|
221
|
-
|
|
307
|
+
# A class without identity_by can name the fact at the call site instead.
|
|
308
|
+
EventRail.publish(Docs::Inventory::StockCounted.new(sku: "MUG", counted: 38), identity: "count-2026-09-01-MUG")
|
|
309
|
+
```
|
|
222
310
|
|
|
223
|
-
|
|
311
|
+
Beside that rule:
|
|
312
|
+
|
|
313
|
+
- **`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.
|
|
314
|
+
- **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.
|
|
315
|
+
- **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.
|
|
316
|
+
- **Events that cross an application boundary should declare identity.** An execution-derived ID changes when the publishing code moves, a declared one does not.
|
|
317
|
+
- **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.
|
|
318
|
+
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
`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.
|
|
322
|
+
|
|
323
|
+
`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.
|
|
324
|
+
|
|
325
|
+
### How an ID is derived
|
|
326
|
+
|
|
327
|
+
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:
|
|
328
|
+
|
|
329
|
+
- **Declared identity:** `"fact"`, source, event type, and the identity as a list — the `identity_by` values in declaration order, or the one `identity:` value.
|
|
330
|
+
- **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.
|
|
331
|
+
|
|
332
|
+
Each component is encoded as a tag, its byte length in decimal, a colon, and its bytes:
|
|
333
|
+
|
|
334
|
+
| Value | Tag | Bytes |
|
|
335
|
+
| --- | --- | --- |
|
|
336
|
+
| String | `s` | its UTF-8 bytes, without Unicode normalization; invalid UTF-8 is refused |
|
|
337
|
+
| Integer | `i` | decimal, with a leading `-` when negative |
|
|
338
|
+
| 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`) |
|
|
339
|
+
| 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 |
|
|
340
|
+
| Boolean | `b` | `true` or `false` |
|
|
341
|
+
| Date | `D` | ISO 8601 (`2026-09-01`) |
|
|
342
|
+
| Timestamp | `T` | converted to UTC, truncated to microseconds, and written as ISO 8601 with six fractional digits (`2026-09-01T10:30:00.123456Z`) |
|
|
343
|
+
| List | `L` | the item count in decimal, a colon, then each item encoded |
|
|
344
|
+
| Singleton marker | `*` | empty |
|
|
345
|
+
|
|
346
|
+
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`:
|
|
347
|
+
|
|
348
|
+
| Vector | Encoded name | ID |
|
|
349
|
+
| --- | --- | --- |
|
|
350
|
+
| a string | `s4:facts11:acme.orderss19:orders.order_placedL8:1:s3:o-1` | `13b2cda9-e1f7-57b6-ba1d-bb12a7553655` |
|
|
351
|
+
| a non-ASCII string | `s4:facts11:acme.orderss19:orders.order_placedL12:1:s7:Zürich` | `8ad8bbf8-3e10-5162-97da-53cb6752973f` |
|
|
352
|
+
| an integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:42` | `43230f76-4e80-55d0-bfaf-beab63ac76b9` |
|
|
353
|
+
| a negative integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:-7` | `4feffb91-0dec-5a7b-a5a0-67968abbe971` |
|
|
354
|
+
| true | `s4:facts11:acme.orderss19:orders.order_placedL9:1:b4:true` | `b1a75e27-6aa2-5f7e-9159-fb429bddc17f` |
|
|
355
|
+
| false | `s4:facts11:acme.orderss19:orders.order_placedL10:1:b5:false` | `631f1712-795b-59fa-b497-103b5f951306` |
|
|
356
|
+
| a decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:12.5` | `fc8e4be7-4912-5702-b22b-92d63d710e01` |
|
|
357
|
+
| an integral decimal | `s4:facts11:acme.orderss19:orders.order_placedL10:1:d5:100.0` | `4ab51db7-7864-591d-8e1a-b1f5d9567169` |
|
|
358
|
+
| a zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL8:1:d3:0.0` | `0a066901-77e5-55e3-99c4-a973b5018500` |
|
|
359
|
+
| a negative zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:-0.0` | `784aef76-53c9-517c-ba3f-732053cd537d` |
|
|
360
|
+
| a float | `s4:facts11:acme.orderss19:orders.order_placedL25:1:f19:0.10000000000000001` | `cc223477-2cb1-5a6d-8d0f-6b25aaf989b7` |
|
|
361
|
+
| an integral float | `s4:facts11:acme.orderss19:orders.order_placedL6:1:f1:1` | `901836d7-5477-5780-8e6b-dd39ed49d272` |
|
|
362
|
+
| a large float | `s4:facts11:acme.orderss19:orders.order_placedL10:1:f5:1e+22` | `d4e88ebb-fdaa-5cc4-9dbe-c576abae6f36` |
|
|
363
|
+
| a small float | `s4:facts11:acme.orderss19:orders.order_placedL28:1:f22:9.9999999999999995e-08` | `83b232ee-36bc-53dc-a455-5a9e31a003a2` |
|
|
364
|
+
| negative zero | `s4:facts11:acme.orderss19:orders.order_placedL7:1:f2:-0` | `7cab07fc-874c-59ff-a946-76d00585d229` |
|
|
365
|
+
| a date | `s4:facts11:acme.orderss19:orders.order_placedL16:1:D10:2026-09-01` | `84aaf8b3-7dc1-5446-880d-a002bfe8cab6` |
|
|
366
|
+
| a timestamp | `s4:facts11:acme.orderss19:orders.order_placedL33:1:T27:2026-09-01T10:30:00.123456Z` | `874e797f-7b0c-5742-b8e2-6b05ae3f738a` |
|
|
367
|
+
| 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` |
|
|
368
|
+
| several values | `s4:facts11:acme.orderss19:orders.order_placedL12:2:s3:o-1i1:2` | `fe61e6d0-cbb6-5db0-96a4-1fd78d86dab9` |
|
|
369
|
+
| a regular job's scope | `s9:executions11:acme.orderss21:Orders::PlaceOrderJobs5:job-1s19:orders.order_placedi1:1*0:` | `e3c5921d-a9bf-5816-b9a7-f36f426cb998` |
|
|
370
|
+
| 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` |
|
|
371
|
+
|
|
372
|
+
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).
|
|
224
373
|
|
|
225
374
|
## Logical context
|
|
226
375
|
|
|
@@ -234,7 +383,7 @@ end
|
|
|
234
383
|
|
|
235
384
|
A nested scope inherits lineage and may add extensions or repeat identical values; replacing an inherited identifier, origin time, or extension value fails rather than rewriting the lineage of a flow already in progress.
|
|
236
385
|
|
|
237
|
-
Context extensions are durable baggage: opted-in child jobs carry them, and published events merge them with event-local extensions, rejecting conflicting values. They are not the place for domain data
|
|
386
|
+
Context extensions are durable baggage: opted-in child jobs carry them, and published events merge them with event-local extensions, rejecting conflicting values. They are not the place for domain data; that belongs in declared attributes.
|
|
238
387
|
|
|
239
388
|
### Reading context
|
|
240
389
|
|
|
@@ -268,9 +417,9 @@ class SendReceiptJob < ApplicationJob
|
|
|
268
417
|
end
|
|
269
418
|
```
|
|
270
419
|
|
|
271
|
-
Reach for `Current` in a subscriber only to hand lineage to something that does not take the event
|
|
420
|
+
Reach for `Current` in a subscriber only to hand lineage to something that does not take the event: a log line, an outbound request header, an APM tag.
|
|
272
421
|
|
|
273
|
-
Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers
|
|
422
|
+
Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers, whether a fiber-per-request server or a worker that runs jobs on fibers, must set `config.active_support.isolation_level = :fiber`**, or lineage will be shared between concurrent fibers. That setting is Rails-wide rather than EventRail's, and it governs both `EventRail::Current` and EventRail's own publication state. EventRail documents the requirement rather than claiming an isolation it cannot provide.
|
|
274
423
|
|
|
275
424
|
Leaving it wrong fails loudly rather than silently: under thread isolation two concurrent fibers each establishing context raise `InvalidContext`, because the second inherits the first's identifiers and replacing them is refused. The rule that stops lineage being rewritten mid-flow doubles as a misconfiguration detector.
|
|
276
425
|
|
|
@@ -280,7 +429,9 @@ OpenTelemetry is not required. When standard Active Job instrumentation is insta
|
|
|
280
429
|
|
|
281
430
|
## Versioning events
|
|
282
431
|
|
|
283
|
-
`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
|
|
432
|
+
`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.
|
|
433
|
+
|
|
434
|
+
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.
|
|
284
435
|
|
|
285
436
|
EventRail provides no upcasting and no schema registry.
|
|
286
437
|
|
|
@@ -288,7 +439,7 @@ EventRail provides no upcasting and no schema registry.
|
|
|
288
439
|
|
|
289
440
|
| Value | Limit |
|
|
290
441
|
| --- | ---: |
|
|
291
|
-
| Event, correlation, causation, or boundary identifier | 512 bytes |
|
|
442
|
+
| Event, correlation, causation, or boundary identifier, or `identity:` | 512 bytes |
|
|
292
443
|
| Source | 255 bytes |
|
|
293
444
|
| Event type | 255 bytes |
|
|
294
445
|
| Extension entries | 32 |
|
|
@@ -307,11 +458,11 @@ Application tests use Active Job's own helpers and nothing from EventRail:
|
|
|
307
458
|
# doc:illustrative
|
|
308
459
|
publication = EventRail.publish(Docs::OrderPlaced.new(order_id: "A-1005", total: "1.00", placed_at: Time.now.utc.iso8601))
|
|
309
460
|
|
|
310
|
-
assert_enqueued_with(job: Docs::
|
|
461
|
+
assert_enqueued_with(job: Docs::Billing::ChargeCardJob, args: [ publication.event ])
|
|
311
462
|
perform_enqueued_jobs
|
|
312
463
|
```
|
|
313
464
|
|
|
314
|
-
`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
|
|
465
|
+
`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.
|
|
315
466
|
|
|
316
467
|
### Tests that need their own fixtures
|
|
317
468
|
|
|
@@ -365,7 +516,7 @@ end
|
|
|
365
516
|
|
|
366
517
|
The previous registry is restored when the block exits, including when it raises, and nested activations are additive. Activation applies the same validation preparation does, so an abstract fixture fails there rather than at delivery.
|
|
367
518
|
|
|
368
|
-
Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing
|
|
519
|
+
Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing: **give each fixture a unique `event_type`, and declare it once, at file scope.** Two live classes claiming one type and version fail every later rebuild for the rest of the process, and a declaration inside a test method reopens the same constant and runs the writer again on a sealed registry.
|
|
369
520
|
|
|
370
521
|
Activation replaces a process-wide registry, so it is not safe under `parallelize(with: :threads)`. Process-based parallelisation, the Rails default, is unaffected.
|
|
371
522
|
|
|
@@ -402,7 +553,18 @@ event_class = ACCEPTED.fetch([ envelope.event_type, envelope.version ])
|
|
|
402
553
|
EventRail.publish(envelope.to_event(event_class))
|
|
403
554
|
```
|
|
404
555
|
|
|
405
|
-
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
|
|
556
|
+
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.
|
|
557
|
+
|
|
558
|
+
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:
|
|
559
|
+
|
|
560
|
+
```ruby
|
|
561
|
+
EventRail.publish(
|
|
562
|
+
Docs::Inventory::StockAdjusted.new(adjustment_id: "delivery-4411", sku: "MUG", quantity: 120),
|
|
563
|
+
source: "supplier.warehouse"
|
|
564
|
+
)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
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.
|
|
406
568
|
|
|
407
569
|
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.
|
|
408
570
|
|
|
@@ -416,7 +578,7 @@ The private Active Job representation and the job context entry each carry their
|
|
|
416
578
|
2. Only then deploy a release that writes the new form.
|
|
417
579
|
3. Remove the old reader only after every queued message in the old form is drained or expired.
|
|
418
580
|
|
|
419
|
-
Rolling back reverses that: a rollback is safe only to a release that can still read what the newer release wrote. A change to how an individual value is written is a different matter
|
|
581
|
+
Rolling back reverses that: a rollback is safe only to a release that can still read what the newer release wrote. A change to how an individual value is written is a different matter: that encoding is shared with the public envelope, so it is a breaking public change rather than a private format bump.
|
|
420
582
|
|
|
421
583
|
## Development
|
|
422
584
|
|
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/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)
|
|
@@ -1,38 +1,56 @@
|
|
|
1
1
|
require "active_support/core_ext/digest/uuid"
|
|
2
|
+
require "active_support/core_ext/object/blank"
|
|
2
3
|
require "bigdecimal"
|
|
3
4
|
require "date"
|
|
4
5
|
|
|
5
6
|
module EventRail
|
|
6
7
|
module Internal
|
|
7
|
-
#
|
|
8
|
+
# Publication identity: the ID an event derives, so that the same fact published
|
|
9
|
+
# again derives the same ID and a consumer can collapse the duplicates at-least-once
|
|
10
|
+
# delivery guarantees it will see.
|
|
8
11
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# Two derivations, told apart by a leading tag so their inputs never coincide:
|
|
13
|
+
#
|
|
14
|
+
# fact ["fact", source, event_type, identity_list]
|
|
15
|
+
# for an event with declared identity. Nothing about who published it,
|
|
16
|
+
# when, or in which schema version: one fact, one ID, everywhere.
|
|
17
|
+
# execution ["execution", source, job_class, scope, event_type, version, SINGLETON]
|
|
18
|
+
# for an event without declared identity, published inside a job. Stable
|
|
19
|
+
# across that job's retries and across redeliveries of the event a
|
|
20
|
+
# subscriber handles, and nothing more.
|
|
21
|
+
#
|
|
22
|
+
# The namespace, the tags, the component order and the byte encoding are permanent
|
|
23
|
+
# compatibility state, documented in the README with vectors other implementations
|
|
24
|
+
# can reproduce; changing any of them silently gives every future publication a
|
|
25
|
+
# different ID for an unchanged fact, which is why the vectors are asserted rather
|
|
26
|
+
# than merely computed.
|
|
15
27
|
module Identity
|
|
16
28
|
module_function
|
|
17
29
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
NAMESPACE = Digest::UUID.uuid_v5(
|
|
22
|
-
Digest::UUID::URL_NAMESPACE, "https://github.com/event_rail/event_rail/identity/v1"
|
|
23
|
-
).freeze
|
|
30
|
+
# A literal, owned by this project and named nowhere else, so no one else's
|
|
31
|
+
# derivation can land in the same space.
|
|
32
|
+
NAMESPACE = "21fedac0-42c6-443f-b01c-6980aab52f32".freeze
|
|
24
33
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
34
|
+
# Closes the execution rule's name: an undeclared event is identified by the
|
|
35
|
+
# execution and its type alone, which is why one execution publishes it once.
|
|
27
36
|
SINGLETON = :__event_rail_singleton__
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
38
|
+
# `identity` is always a list: the declared attributes' values in declaration
|
|
39
|
+
# order, or one explicit publication identity. So an explicit "a" and a single
|
|
40
|
+
# declared attribute whose value is "a" name the same fact.
|
|
41
|
+
def fact(source:, event_type:, identity:)
|
|
42
|
+
raise ArgumentError, "fact identity must be a list; got #{identity.inspect}" unless identity.is_a?(Array)
|
|
43
|
+
|
|
44
|
+
uuid_v5(encode([ "fact", source, event_type, identity ]))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execution(source:, job_class:, scope:, event_type:, version:)
|
|
48
|
+
uuid_v5(encode([ "execution", source, job_class, scope, event_type, version, SINGLETON ]))
|
|
34
49
|
end
|
|
35
50
|
|
|
51
|
+
def uuid_v5(name) = Digest::UUID.uuid_v5(NAMESPACE, name)
|
|
52
|
+
private_class_method :uuid_v5
|
|
53
|
+
|
|
36
54
|
# Length-delimited and type-tagged. Ruby's own Hash#hash is per-process, inspect
|
|
37
55
|
# is not a format, and a plain join makes ["ab", "c"] and ["a", "bc"] the same
|
|
38
56
|
# string. A tag also keeps 1 and "1" apart, so a call site that passes an integer
|
|
@@ -44,7 +62,7 @@ module EventRail
|
|
|
44
62
|
def encode_component(value)
|
|
45
63
|
tag, bytes = case value
|
|
46
64
|
when SINGLETON then [ "*", "" ]
|
|
47
|
-
when String then [ "s", value
|
|
65
|
+
when String then [ "s", utf8_bytes(value) ]
|
|
48
66
|
when true then [ "b", "true" ]
|
|
49
67
|
when false then [ "b", "false" ]
|
|
50
68
|
when Integer then [ "i", value.to_s ]
|
|
@@ -67,6 +85,18 @@ module EventRail
|
|
|
67
85
|
"#{tag}#{bytes.bytesize}:#{bytes}"
|
|
68
86
|
end
|
|
69
87
|
|
|
88
|
+
# A string is hashed as its UTF-8 bytes, with no Unicode normalization: the same
|
|
89
|
+
# text in another normal form is another identity, so a producer must send the same
|
|
90
|
+
# bytes. Bytes that are not valid UTF-8 have no portable spelling at all.
|
|
91
|
+
def utf8_bytes(value)
|
|
92
|
+
utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
93
|
+
reject!(value, "valid UTF-8") unless utf8.valid_encoding?
|
|
94
|
+
utf8.b
|
|
95
|
+
rescue EncodingError
|
|
96
|
+
reject!(value, "valid UTF-8")
|
|
97
|
+
end
|
|
98
|
+
private_class_method :utf8_bytes
|
|
99
|
+
|
|
70
100
|
# An identity component that is a structure cannot be canonicalized without
|
|
71
101
|
# choosing an ordering and a separator that would then be permanent, and one
|
|
72
102
|
# that is null cannot identify anything.
|
|
@@ -3,7 +3,7 @@ require "securerandom"
|
|
|
3
3
|
module EventRail
|
|
4
4
|
module Internal
|
|
5
5
|
# Turns a proposal into a stamped fact: resolves source, selects logical identity,
|
|
6
|
-
# derives
|
|
6
|
+
# derives its ID, fixes occurrence time, and installs lineage. Fanout
|
|
7
7
|
# is a separate concern layered on top of this.
|
|
8
8
|
module Stamping
|
|
9
9
|
module_function
|
|
@@ -14,17 +14,21 @@ module EventRail
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def prepare(event,
|
|
17
|
+
def prepare(event, identity: nil, source: nil)
|
|
18
18
|
unless event.is_a?(Event)
|
|
19
19
|
raise InvalidEvent, "#{event.inspect} is not an EventRail::Event"
|
|
20
20
|
end
|
|
21
|
+
Metadata.validate_source!(source) unless source.nil?
|
|
21
22
|
|
|
22
|
-
return relay(event,
|
|
23
|
+
return relay(event, identity: identity, source: source) if event.stamped?
|
|
24
|
+
|
|
25
|
+
validate_identity!(event, identity)
|
|
26
|
+
source = (source || event.class.default_source)&.dup&.freeze
|
|
27
|
+
raise InvalidMetadata, "source is required to publish #{event.class}" unless source
|
|
23
28
|
|
|
24
|
-
validate_key!(key)
|
|
25
29
|
execution = Execution.current
|
|
26
|
-
|
|
27
|
-
logical_key =
|
|
30
|
+
derived_id = derive_id(event, execution, source, declared_identity(event, identity))
|
|
31
|
+
logical_key = record_key(source, event, derived_id)
|
|
28
32
|
|
|
29
33
|
recorded = execution&.derives_identity? ? execution.record(logical_key) : nil
|
|
30
34
|
if recorded&.succeeded?
|
|
@@ -36,8 +40,7 @@ module EventRail
|
|
|
36
40
|
extensions = Extensions.merge!(Current.extensions, event.extensions, error: InvalidEvent)
|
|
37
41
|
detect_retry_mismatch!(event, recorded, extensions) if recorded
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
event_id = recorded&.event&.id || derive_id(event, execution, source, logical_identity)
|
|
43
|
+
event_id = recorded&.event&.id || derived_id
|
|
41
44
|
occurred_at = event.occurred_at || recorded&.event&.occurred_at || default_occurred_at(execution)
|
|
42
45
|
|
|
43
46
|
stamped = event.send(
|
|
@@ -62,20 +65,26 @@ module EventRail
|
|
|
62
65
|
execution.record!(prepared.logical_key, event: prepared.event, succeeded: true)
|
|
63
66
|
end
|
|
64
67
|
|
|
65
|
-
# A relayed fact belongs to its origin. Its ID, source, occurrence time,
|
|
66
|
-
#
|
|
67
|
-
# context
|
|
68
|
-
# somebody else's event.
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
# A relayed fact belongs to its origin. Its ID, source, occurrence time, lineage
|
|
69
|
+
# -- an absent causation included -- and extensions are preserved, and the relaying
|
|
70
|
+
# application's own context is deliberately not merged in: local baggage is not
|
|
71
|
+
# part of somebody else's event. Two producers may use the same ID, so the source is
|
|
72
|
+
# part of what makes a relay a repetition, and a relay retried with a different
|
|
73
|
+
# payload is refused exactly as a local publication is.
|
|
74
|
+
def relay(event, identity:, source:)
|
|
75
|
+
unless identity.nil?
|
|
72
76
|
raise PublicationError,
|
|
73
77
|
"#{event.event_type.inspect} already carries the event ID #{event.id.inspect}, so a publication " \
|
|
74
|
-
"
|
|
78
|
+
"identity cannot apply to it"
|
|
79
|
+
end
|
|
80
|
+
if source && source != event.source
|
|
81
|
+
raise InvalidMetadata,
|
|
82
|
+
"#{event.event_type.inspect} #{event.id.inspect} already comes from #{event.source.inspect}; " \
|
|
83
|
+
"publishing it as #{source.inspect} would re-attribute somebody else's fact"
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
execution = Execution.current
|
|
78
|
-
logical_key =
|
|
87
|
+
logical_key = record_key(event.source, event, event.id)
|
|
79
88
|
|
|
80
89
|
if execution&.derives_identity?
|
|
81
90
|
recorded = execution.record(logical_key)
|
|
@@ -84,71 +93,71 @@ module EventRail
|
|
|
84
93
|
event_type: event.event_type, version: event.version, event_id: event.id
|
|
85
94
|
)
|
|
86
95
|
end
|
|
96
|
+
detect_retry_mismatch!(event, recorded, event.extensions) if recorded
|
|
97
|
+
execution.record!(logical_key, event: event, succeeded: false)
|
|
87
98
|
end
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
event.send(
|
|
91
|
-
:__stamp__,
|
|
92
|
-
id: event.id,
|
|
93
|
-
source: event.source,
|
|
94
|
-
occurred_at: event.occurred_at,
|
|
95
|
-
correlation_id: event.correlation_id,
|
|
96
|
-
causation_id: Current.message_id,
|
|
97
|
-
extensions: event.extensions
|
|
98
|
-
)
|
|
99
|
-
else
|
|
100
|
-
event
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
execution.record!(logical_key, event: relayed, succeeded: false) if execution&.derives_identity?
|
|
104
|
-
|
|
105
|
-
Prepared.new(event: relayed, execution: execution, logical_key: logical_key, relayed: true)
|
|
100
|
+
Prepared.new(event: event, execution: execution, logical_key: logical_key, relayed: true)
|
|
106
101
|
end
|
|
107
102
|
private_class_method :relay
|
|
108
103
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
# One record per published event, whether it was stamped here or arrives stamped:
|
|
105
|
+
# so publishing the stamped result of a failed local publication is a retry of it,
|
|
106
|
+
# and publishing it again after success is a duplicate. Frozen binary copies, so a
|
|
107
|
+
# caller mutating the string it passed cannot move a record, and so a relayed ID is
|
|
108
|
+
# compared as the opaque bytes it is, whatever they hold.
|
|
109
|
+
def record_key(source, event, id)
|
|
110
|
+
[ source.b.freeze, event.event_type, event.version, id.b.freeze ].freeze
|
|
111
|
+
end
|
|
112
|
+
private_class_method :record_key
|
|
115
113
|
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
# The fact an event declares itself to be, as a list: an explicit publication
|
|
115
|
+
# identity, or the class's declared identity attributes in declaration order. Nil
|
|
116
|
+
# when it declares none. An explicit identity and a single declared attribute with
|
|
117
|
+
# the same value name the same fact.
|
|
118
|
+
def declared_identity(event, identity)
|
|
119
|
+
return [ identity ] if identity
|
|
118
120
|
|
|
119
|
-
declared
|
|
121
|
+
declared = event.class.identity_by
|
|
122
|
+
declared.empty? ? nil : declared.map { |name| event.public_send(name) }
|
|
120
123
|
end
|
|
121
|
-
private_class_method :
|
|
124
|
+
private_class_method :declared_identity
|
|
122
125
|
|
|
123
|
-
# A
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
# A publication identity names a fact across every execution, so it is a string --
|
|
127
|
+
# an integer at one call site and its decimal spelling at another would be two
|
|
128
|
+
# facts -- and it cannot contradict an identity the class already declares.
|
|
129
|
+
def validate_identity!(event, identity)
|
|
130
|
+
return if identity.nil?
|
|
127
131
|
|
|
128
|
-
unless
|
|
129
|
-
raise PublicationError,
|
|
132
|
+
unless event.class.identity_by.empty?
|
|
133
|
+
raise PublicationError,
|
|
134
|
+
"#{event.class} declares #{event.class.identity_by.join(", ")} as its identity, so a publication " \
|
|
135
|
+
"identity cannot apply to it"
|
|
136
|
+
end
|
|
137
|
+
unless identity.is_a?(String) && !identity.empty? && identity.valid_encoding?
|
|
138
|
+
raise PublicationError, "publication identity must be a non-empty string; got #{identity.inspect}"
|
|
130
139
|
end
|
|
131
|
-
if
|
|
132
|
-
raise PublicationError, "publication
|
|
140
|
+
if identity.bytesize > Limits::MAX_IDENTIFIER_BYTES
|
|
141
|
+
raise PublicationError, "publication identity exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
|
|
133
142
|
end
|
|
134
143
|
end
|
|
135
|
-
private_class_method :
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
private_class_method :validate_identity!
|
|
145
|
+
|
|
146
|
+
# A declared fact's ID depends on nothing but the fact. Without one, an ID derived
|
|
147
|
+
# from the execution is stable across its retries and redeliveries; outside any
|
|
148
|
+
# execution there is nothing stable to derive from, and the ID is random, in time
|
|
149
|
+
# order so a downstream log indexes it well.
|
|
150
|
+
def derive_id(event, execution, source, declared)
|
|
151
|
+
if declared
|
|
152
|
+
Identity.fact(source: source, event_type: event.event_type, identity: declared).freeze
|
|
153
|
+
elsif execution&.derives_identity?
|
|
154
|
+
Identity.execution(
|
|
155
|
+
source: source, job_class: execution.job_class, scope: execution.scope,
|
|
156
|
+
event_type: event.event_type, version: event.version
|
|
157
|
+
).freeze
|
|
158
|
+
else
|
|
159
|
+
SecureRandom.uuid_v7.freeze
|
|
142
160
|
end
|
|
143
|
-
|
|
144
|
-
Identity.derive(
|
|
145
|
-
source: source,
|
|
146
|
-
job_class: execution.job_class,
|
|
147
|
-
scope: execution.scope,
|
|
148
|
-
event_type: event.event_type,
|
|
149
|
-
version: event.version,
|
|
150
|
-
logical_identity: logical_identity
|
|
151
|
-
).freeze
|
|
152
161
|
end
|
|
153
162
|
private_class_method :derive_id
|
|
154
163
|
|
|
@@ -106,8 +106,11 @@ module EventRail
|
|
|
106
106
|
entry["extensions"], error: InvalidContext
|
|
107
107
|
)
|
|
108
108
|
|
|
109
|
+
# The scope an undeclared follow-up's ID derives from. A subscriber's is the event
|
|
110
|
+
# it handles, by source as well as ID, since two producers may use one ID; its
|
|
111
|
+
# logical message and causation stay the event's ID.
|
|
109
112
|
execution = Internal::Execution.new(
|
|
110
|
-
job_class: self.class.name, scope: message_id, started_at: started_at
|
|
113
|
+
job_class: self.class.name, scope: event ? [ event.source, event.id ] : message_id, started_at: started_at
|
|
111
114
|
)
|
|
112
115
|
|
|
113
116
|
Internal::Context.establish(
|
data/lib/event_rail/publish.rb
CHANGED
|
@@ -7,6 +7,9 @@ module EventRail
|
|
|
7
7
|
#
|
|
8
8
|
# publication = EventRail.publish(Orders::OrderPlaced.new(order_id: order.id))
|
|
9
9
|
#
|
|
10
|
+
# `identity:` names the fact for an event whose class declares no `identity_by`;
|
|
11
|
+
# `source:` publishes a locally built event on behalf of another producer.
|
|
12
|
+
#
|
|
10
13
|
# Individual enqueue calls, not `perform_all_later`. Bulk enqueue skips each job's
|
|
11
14
|
# own `enqueue` callbacks on several adapters, and those callbacks are exactly where
|
|
12
15
|
# an application puts uniqueness, concurrency, and feature-flag decisions that this
|
|
@@ -15,10 +18,17 @@ module EventRail
|
|
|
15
18
|
# Delivery is at-least-once. Jobs already accepted are not rolled back when a later
|
|
16
19
|
# subscriber's enqueue fails, and the retry repeats complete fanout under the same
|
|
17
20
|
# event ID, so a subscriber may see the same event more than once.
|
|
18
|
-
def publish(event,
|
|
21
|
+
def publish(event, identity: nil, source: nil, **unknown)
|
|
22
|
+
if unknown.key?(:key)
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"publish's key: is now identity:, and it names a fact across every job rather than within one; " \
|
|
25
|
+
"check the value names one occurrence per source and event type before renaming it"
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.keys.map(&:inspect).join(", ")}" if unknown.any?
|
|
28
|
+
|
|
19
29
|
Internal::Transaction.check!
|
|
20
30
|
|
|
21
|
-
prepared = Internal::Stamping.prepare(event,
|
|
31
|
+
prepared = Internal::Stamping.prepare(event, identity: identity, source: source)
|
|
22
32
|
stamped = prepared.event
|
|
23
33
|
subscribers = Internal::Registry.subscribers_for(stamped.class)
|
|
24
34
|
|
|
@@ -16,10 +16,12 @@ module EventRail
|
|
|
16
16
|
# missing that inclusion fails application preparation with a precise message
|
|
17
17
|
# instead of a bare NoMethodError on the macro.
|
|
18
18
|
module Subscriptions
|
|
19
|
-
# Declares the exact event classes this job handles.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# Declares the exact event classes this job handles. Only what can be judged
|
|
20
|
+
# immediately is judged here: that each argument is an event class, that it declares an
|
|
21
|
+
# event_type or a version, that it is not a repeat, and that this job has a name. The rest
|
|
22
|
+
# is recorded for preparation to validate, because `perform` is usually defined after
|
|
23
|
+
# this line and whether this class has subclasses is not knowable until everything is
|
|
24
|
+
# loaded.
|
|
23
25
|
def subscribes_to(*event_classes)
|
|
24
26
|
if event_classes.empty?
|
|
25
27
|
raise DeclarationError, "#{self} must name at least one event class to subscribe to"
|
|
@@ -32,6 +34,19 @@ module EventRail
|
|
|
32
34
|
raise DeclarationError,
|
|
33
35
|
"#{self} cannot subscribe to #{event_class.inspect}, which is not an EventRail::Event class"
|
|
34
36
|
end
|
|
37
|
+
# Judged here rather than during preparation because it is already knowable: the
|
|
38
|
+
# constant reference above has loaded the event class, so in any ordinary load order
|
|
39
|
+
# its contract is final. A class that declares neither half is an abstract base, or a
|
|
40
|
+
# subclass that declared none of its own, and either way publication -- which matches
|
|
41
|
+
# subscribers by exact class -- could never reach it.
|
|
42
|
+
unless event_class.concrete?
|
|
43
|
+
raise DeclarationError,
|
|
44
|
+
"#{self} cannot subscribe to #{event_class}, which declares neither event_type nor version and so " \
|
|
45
|
+
"can never be published; the subscription would receive nothing. Neither declaration is inherited, " \
|
|
46
|
+
"so a subclass of a concrete event needs its own. Subscribe to each concrete event class instead, " \
|
|
47
|
+
"or, if #{event_class} declares its contract outside its own class body, make that run before this " \
|
|
48
|
+
"subscription is declared."
|
|
49
|
+
end
|
|
35
50
|
if own.include?(event_class)
|
|
36
51
|
raise DeclarationError, "#{self} already declares a subscription to #{event_class}"
|
|
37
52
|
end
|
data/lib/event_rail/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: event_rail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Dmitriev
|
|
@@ -109,9 +109,9 @@ dependencies:
|
|
|
109
109
|
- - "<"
|
|
110
110
|
- !ruby/object:Gem::Version
|
|
111
111
|
version: '4'
|
|
112
|
-
description: EventRail adds immutable domain events,
|
|
113
|
-
context propagation, and Rails-native durable fanout without replacing Active
|
|
114
|
-
or its queue adapter.
|
|
112
|
+
description: EventRail adds immutable domain events, stable, fact-derived identity,
|
|
113
|
+
logical context propagation, and Rails-native durable fanout without replacing Active
|
|
114
|
+
Job or its queue adapter.
|
|
115
115
|
email:
|
|
116
116
|
- alexey.dmitriev6238@gmail.com
|
|
117
117
|
executables: []
|