event_rail 0.2.0 → 0.3.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 +15 -1
- data/README.md +80 -24
- data/lib/event_rail/subscriptions.rb +19 -4
- data/lib/event_rail/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a5dd4da961fafe8b7a42cadb79cc9fcad3f9da337181a6e53f93ac2e2495f80
|
|
4
|
+
data.tar.gz: adea011ca9a862030527c8178d67e7165e2d9e17b8902328ed96f698910c9835
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 382236795f226f5dad4979cb2fbb29cac28fd31a4bd0ca7bc77bd8b7b0f7f7279275a4e97f3df1550d3f7dd5fd826e2ed96a682ccdd8b792d0c6d6bd7af73e3a
|
|
7
|
+
data.tar.gz: b84bba4b3e33f2fd809c7f833cd8913655f555f298828f7080bc8c27e8925cbd02125e3503d2658444d27f16061bc555749235229935dfa1e9cf4342e2be470b
|
data/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,19 @@ here unless a release changes how they behave.
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.3.0] - 2026-09-21
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`subscribes_to` now raises `EventRail::DeclarationError` when the event class it names
|
|
21
|
+
declares neither `event_type` nor `version`**, at the declaration itself. Previously such
|
|
22
|
+
a subscription was accepted and then never fired: publication matches subscribers by exact
|
|
23
|
+
event class, so a concrete subclass never reached the base's registration, and the class
|
|
24
|
+
without a contract could not be constructed to be published on its own. Because neither
|
|
25
|
+
declaration is inherited, this covers a shared abstract base such as `ApplicationEvent`
|
|
26
|
+
and equally a subclass of a concrete event that adds nothing of its own. Subscribe to each
|
|
27
|
+
concrete event class instead.
|
|
28
|
+
|
|
16
29
|
## [0.2.0] - 2026-09-17
|
|
17
30
|
|
|
18
31
|
### Added
|
|
@@ -87,6 +100,7 @@ here unless a release changes how they behave.
|
|
|
87
100
|
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
88
101
|
casting, context, serialization, and publication failures.
|
|
89
102
|
|
|
90
|
-
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.
|
|
103
|
+
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.3.0...HEAD
|
|
104
|
+
[0.3.0]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...v0.3.0
|
|
91
105
|
[0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
|
|
92
106
|
[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
|
+
- **Retry-stable identity**, derived from the publishing execution rather than generated, so a redelivered cause produces the same event rather than a second copy of it. 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.
|
|
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)); from a controller action there is no such guarantee. An application that wants recovery beyond that can record an intent and republish it from a sweeper, but a republication from a different job derives a different event ID, so the intent's own key belongs in the payload for consumers to deduplicate on.
|
|
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
|
|
172
|
+
|
|
173
|
+
subscribes_to OrderPlaced
|
|
135
174
|
|
|
136
|
-
|
|
137
|
-
|
|
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,17 +216,17 @@ 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
|
|
|
@@ -184,7 +240,7 @@ Retries, backoff, discarding, and dead-letter handling stay where they already a
|
|
|
184
240
|
|
|
185
241
|
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
242
|
|
|
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
|
|
243
|
+
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
244
|
|
|
189
245
|
### Replay-safe publishers
|
|
190
246
|
|
|
@@ -205,7 +261,7 @@ Guarding publication behind "did I already transition?" is the failure mode to a
|
|
|
205
261
|
|
|
206
262
|
## Identity, occurrence time, and source
|
|
207
263
|
|
|
208
|
-
Inside an opted-in job, an event's ID is derived from a permanent EventRail namespace and the resolved source, executing job class, execution scope, event type, version, and logical identity. The execution scope is the same value the event records as its causation: a regular job's own ID, or, for a subscriber, the ID of the event it is handling. That is what makes identity survive more than one hop
|
|
264
|
+
Inside an opted-in job, an event's ID is derived from a permanent EventRail namespace and the resolved source, executing job class, execution scope, event type, version, and logical identity. The execution scope is the same value the event records as its causation: a regular job's own ID, or, for a subscriber, the ID of the event it is handling. That is what makes identity survive more than one hop: a follow-up event published while handling a redelivered cause derives the ID it derived the first time.
|
|
209
265
|
|
|
210
266
|
Logical identity is chosen in this order: an explicit event ID (an inbound external event), an explicit `key:` passed to `publish`, the class's `identity_by` attributes, or a singleton marker for the first publication of that type in the execution. Provide an explicit key when neither declared identity nor the singleton default can tell two legitimate publications apart:
|
|
211
267
|
|
|
@@ -220,7 +276,7 @@ Changing `identity_by`, changing `default_source`, or renaming a subscriber clas
|
|
|
220
276
|
|
|
221
277
|
`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. Stored times are UTC at microsecond precision.
|
|
222
278
|
|
|
223
|
-
`source` identifies a logical producer
|
|
279
|
+
`source` identifies a logical producer, not an environment, queue, topic, cluster, or deployment. Any bounded non-empty string is accepted; a stable namespaced value such as `acme.orders` is recommended, and EventRail does no URI parsing.
|
|
224
280
|
|
|
225
281
|
## Logical context
|
|
226
282
|
|
|
@@ -234,7 +290,7 @@ end
|
|
|
234
290
|
|
|
235
291
|
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
292
|
|
|
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
|
|
293
|
+
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
294
|
|
|
239
295
|
### Reading context
|
|
240
296
|
|
|
@@ -268,9 +324,9 @@ class SendReceiptJob < ApplicationJob
|
|
|
268
324
|
end
|
|
269
325
|
```
|
|
270
326
|
|
|
271
|
-
Reach for `Current` in a subscriber only to hand lineage to something that does not take the event
|
|
327
|
+
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
328
|
|
|
273
|
-
Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers
|
|
329
|
+
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
330
|
|
|
275
331
|
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
332
|
|
|
@@ -307,11 +363,11 @@ Application tests use Active Job's own helpers and nothing from EventRail:
|
|
|
307
363
|
# doc:illustrative
|
|
308
364
|
publication = EventRail.publish(Docs::OrderPlaced.new(order_id: "A-1005", total: "1.00", placed_at: Time.now.utc.iso8601))
|
|
309
365
|
|
|
310
|
-
assert_enqueued_with(job: Docs::
|
|
366
|
+
assert_enqueued_with(job: Docs::Billing::ChargeCardJob, args: [ publication.event ])
|
|
311
367
|
perform_enqueued_jobs
|
|
312
368
|
```
|
|
313
369
|
|
|
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
|
|
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.
|
|
315
371
|
|
|
316
372
|
### Tests that need their own fixtures
|
|
317
373
|
|
|
@@ -365,7 +421,7 @@ end
|
|
|
365
421
|
|
|
366
422
|
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
423
|
|
|
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
|
|
424
|
+
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
425
|
|
|
370
426
|
Activation replaces a process-wide registry, so it is not safe under `parallelize(with: :threads)`. Process-based parallelisation, the Rails default, is unaffected.
|
|
371
427
|
|
|
@@ -416,7 +472,7 @@ The private Active Job representation and the job context entry each carry their
|
|
|
416
472
|
2. Only then deploy a release that writes the new form.
|
|
417
473
|
3. Remove the old reader only after every queued message in the old form is drained or expired.
|
|
418
474
|
|
|
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
|
|
475
|
+
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
476
|
|
|
421
477
|
## Development
|
|
422
478
|
|
|
@@ -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