event_rail 0.3.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 +60 -1
- data/README.md +122 -16
- 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/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,64 @@ 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
|
+
|
|
16
74
|
## [0.3.0] - 2026-09-21
|
|
17
75
|
|
|
18
76
|
### Changed
|
|
@@ -100,7 +158,8 @@ here unless a release changes how they behave.
|
|
|
100
158
|
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
101
159
|
casting, context, serialization, and publication failures.
|
|
102
160
|
|
|
103
|
-
[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
|
|
104
163
|
[0.3.0]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...v0.3.0
|
|
105
164
|
[0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
|
|
106
165
|
[0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ EventRail is narrow on purpose: durable fanout across a boundary inside one appl
|
|
|
27
27
|
|
|
28
28
|
### What it gives you
|
|
29
29
|
|
|
30
|
-
- **
|
|
30
|
+
- **Stable identity.** An event that declares what fact it is has one ID wherever and however often it is published, so a consumer recognises it arriving again; any other event keeps its ID across retries and redeliveries. Idempotency holds across hops, not only across one subscriber's retries.
|
|
31
31
|
- **Subscribers that are the jobs**, each keeping its own queue, retry policy, and concurrency limits rather than sharing the single wrapper job other gems route subscribers through.
|
|
32
32
|
- **Boot-time validation**: a wrong `perform` arity, a missing `EventRail::JobContext`, or a late declaration fails the boot that introduced it rather than the first publication in production.
|
|
33
33
|
- **A typed boundary**: a `Date`, a `BigDecimal`, a record, or a GlobalID is refused rather than serialized into something a worker cannot restore.
|
|
@@ -36,8 +36,8 @@ EventRail is narrow on purpose: durable fanout across a boundary inside one appl
|
|
|
36
36
|
|
|
37
37
|
### What it does not do
|
|
38
38
|
|
|
39
|
-
- **No event log and no synchronous handlers.** No history, replay, read-model rebuild, or browser UI, and every subscriber crosses the queue.
|
|
40
|
-
- **No outbox, 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));
|
|
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
41
|
- **Discovery is enforced, not advisory.** A subscriber outside a [discovery root](#where-discovery-looks) fails the boot rather than being quietly ignored: a silent delivery bug traded for a loud startup error, at the price of a layout rule.
|
|
42
42
|
|
|
43
43
|
## Requirements
|
|
@@ -230,7 +230,9 @@ Two exceptions. Code that is not reloadable at all, such as a gem or a file plai
|
|
|
230
230
|
|
|
231
231
|
### At-least-once delivery, and what that means for subscribers
|
|
232
232
|
|
|
233
|
-
Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent,
|
|
233
|
+
Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent. An event's full identity is `(source, id)`, so a subscriber records `(subscriber, event.source, event.id)` in the same transaction as its effect and does nothing when that record already exists; a business-key constraint (one refund per order) remains worth having beside it. The ID is stable across retries of the publishing execution and redeliveries of a subscriber's own cause, and, for an event with [declared identity](#identity-occurrence-time-and-source), across every publication of the fact.
|
|
234
|
+
|
|
235
|
+
Two copies of one fact, published by different executions, can differ: a later `occurred_at`, another causation, or a payload read from state that has since changed. EventRail stores nothing and orders nothing, so it cannot say which copy is authoritative; each subscriber decides, and publishers keep copies equal by building events from persisted data.
|
|
234
236
|
|
|
235
237
|
No ordering is promised, between subscribers or between events.
|
|
236
238
|
|
|
@@ -261,22 +263,113 @@ Guarding publication behind "did I already transition?" is the failure mode to a
|
|
|
261
263
|
|
|
262
264
|
## Identity, occurrence time, and source
|
|
263
265
|
|
|
264
|
-
|
|
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.
|
|
265
271
|
|
|
266
|
-
|
|
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:
|
|
267
273
|
|
|
268
274
|
```ruby
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
302
|
+
|
|
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
|
|
306
|
+
|
|
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")
|
|
273
309
|
```
|
|
274
310
|
|
|
275
|
-
|
|
311
|
+
Beside that rule:
|
|
276
312
|
|
|
277
|
-
|
|
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.
|
|
278
318
|
|
|
279
|
-
`
|
|
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).
|
|
280
373
|
|
|
281
374
|
## Logical context
|
|
282
375
|
|
|
@@ -336,7 +429,9 @@ OpenTelemetry is not required. When standard Active Job instrumentation is insta
|
|
|
336
429
|
|
|
337
430
|
## Versioning events
|
|
338
431
|
|
|
339
|
-
`event_type` and `version` form the durable, language-neutral contract, independent of Ruby class names. A compatible optional addition may keep the same version: an older worker preserves the unknown field as opaque data with no reader and includes it again on export. Removing, renaming, requiring, or retyping a field, changing its meaning
|
|
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.
|
|
340
435
|
|
|
341
436
|
EventRail provides no upcasting and no schema registry.
|
|
342
437
|
|
|
@@ -344,7 +439,7 @@ EventRail provides no upcasting and no schema registry.
|
|
|
344
439
|
|
|
345
440
|
| Value | Limit |
|
|
346
441
|
| --- | ---: |
|
|
347
|
-
| Event, correlation, causation, or boundary identifier | 512 bytes |
|
|
442
|
+
| Event, correlation, causation, or boundary identifier, or `identity:` | 512 bytes |
|
|
348
443
|
| Source | 255 bytes |
|
|
349
444
|
| Event type | 255 bytes |
|
|
350
445
|
| Extension entries | 32 |
|
|
@@ -458,7 +553,18 @@ event_class = ACCEPTED.fetch([ envelope.event_type, envelope.version ])
|
|
|
458
553
|
EventRail.publish(envelope.to_event(event_class))
|
|
459
554
|
```
|
|
460
555
|
|
|
461
|
-
A relayed event keeps its origin's ID, source, occurrence time, correlation, and extensions; the relaying application's own context extensions are not merged into it
|
|
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.
|
|
462
568
|
|
|
463
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.
|
|
464
570
|
|
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
|
|
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: []
|