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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a5dd4da961fafe8b7a42cadb79cc9fcad3f9da337181a6e53f93ac2e2495f80
4
- data.tar.gz: adea011ca9a862030527c8178d67e7165e2d9e17b8902328ed96f698910c9835
3
+ metadata.gz: 3367f3d4d8f5eb0841f1cca4021af4faa7a3fac3bf1a94edb328f5206eccd516
4
+ data.tar.gz: 795013406723b9b46fc5e5ca4532a0a2b5f35c0ab9ca3a49240bb63c09b13ead
5
5
  SHA512:
6
- metadata.gz: 382236795f226f5dad4979cb2fbb29cac28fd31a4bd0ca7bc77bd8b7b0f7f7279275a4e97f3df1550d3f7dd5fd826e2ed96a682ccdd8b792d0c6d6bd7af73e3a
7
- data.tar.gz: b84bba4b3e33f2fd809c7f833cd8913655f555f298828f7080bc8c27e8925cbd02125e3503d2658444d27f16061bc555749235229935dfa1e9cf4342e2be470b
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.3.0...HEAD
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
- - **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.
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)); 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.
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, and `event.id` is the key to be idempotent on: it is stable across retries of the publishing execution and across redeliveries of a subscriber's own cause.
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
- 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.
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
- 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:
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
- EventRail.publish(
270
- Docs::OrderPlaced.new(order_id: "A-1003", total: "5.00", placed_at: Time.now.utc.iso8601),
271
- key: "adjustment-7"
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
- Changing `identity_by`, changing `default_source`, or renaming a subscriber class all change the identities that derive from them, so each is a breaking change. Outside a job execution, events receive random IDs.
311
+ Beside that rule:
276
312
 
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.
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
- `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.
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, or changing identity requires a new version and a distinct class, and the old class stays registered while messages that reference it can still arrive.
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, and only a missing causation is filled.
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
 
@@ -49,7 +49,15 @@ module EventRail
49
49
  inherited[:extensions], extensions, error: InvalidContext
50
50
  )
51
51
 
52
- execution = Internal::Execution.new(scope: resolved_message_id, started_at: resolved_originated_at)
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.
@@ -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, so
236
- # the event can never be published. The local constructor is the strict door and
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
- # Retry-stable publication identity.
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
- # The whole point is that the same logical fact, published again by the same
10
- # execution, derives the same event ID -- so a consumer can collapse the
11
- # duplicates at-least-once delivery guarantees it will see. That makes both the
12
- # namespace and the encoding permanent compatibility state: changing either
13
- # silently gives every future publication a different ID for an unchanged fact,
14
- # which is why the golden vectors are asserted rather than merely computed.
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
- # Derived from the URL namespace rather than written as a literal, so the input
19
- # that produced it stays visible and a typo cannot masquerade as a deliberate
20
- # value. The golden-vector test pins the result.
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
- # The first publication of an event type in one execution needs no key: there is
26
- # nothing to distinguish it from.
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
- def derive(source:, job_class:, scope:, event_type:, version:, logical_identity:)
30
- Digest::UUID.uuid_v5(
31
- NAMESPACE,
32
- encode([ source, job_class, scope, event_type, version, logical_identity ])
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.b ]
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 a retry-stable ID, fixes occurrence time, and installs lineage. Fanout
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, key: nil)
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, key: key) if event.stamped?
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
- logical_identity = resolve_logical_identity(event, key)
27
- logical_key = [ event.event_type, event.version, Identity.encode_component(logical_identity) ].freeze
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
- source = event.class.default_source
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
- # correlation, and extensions are preserved, and the relaying application's own
67
- # context extensions are deliberately not merged in: local baggage is not part of
68
- # somebody else's event. Only a missing causation is filled, because the local
69
- # message genuinely is what caused this relay.
70
- def relay(event, key:)
71
- unless key.nil?
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
- "key cannot apply to it"
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 = [ event.event_type, event.version, Identity.encode_component(event.id) ].freeze
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
- relayed = if event.causation_id.nil? && Current.message_id
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
- # 1. An explicit event ID wins, which is the relay path above.
110
- # 2. An explicit call-site key.
111
- # 3. The event class's declared identity attributes.
112
- # 4. A singleton marker, for the first publication of that type in an execution.
113
- def resolve_logical_identity(event, key)
114
- return key if key
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
- declared = event.class.identity_by
117
- return Identity::SINGLETON if declared.empty?
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.map { |name| event.public_send(name) }
121
+ declared = event.class.identity_by
122
+ declared.empty? ? nil : declared.map { |name| event.public_send(name) }
120
123
  end
121
- private_class_method :resolve_logical_identity
124
+ private_class_method :declared_identity
122
125
 
123
- # A key spelled as an integer at one call site and as its decimal string at
124
- # another would derive two identities for one fact, and nothing would report it.
125
- def validate_key!(key)
126
- return if key.nil?
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 key.is_a?(String) && !key.empty? && key.valid_encoding?
129
- raise PublicationError, "publication key must be a non-empty string; got #{key.inspect}"
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 key.bytesize > Limits::MAX_IDENTIFIER_BYTES
132
- raise PublicationError, "publication key exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
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 :validate_key!
136
-
137
- def derive_id(event, execution, source, logical_identity)
138
- return SecureRandom.uuid.freeze unless execution&.derives_identity?
139
-
140
- unless source
141
- raise InvalidMetadata, "source is required to publish #{event.class}"
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(
@@ -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, key: nil)
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, key: key)
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
 
@@ -1,3 +1,3 @@
1
1
  module EventRail
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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.3.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, retry-stable identity, logical
113
- context propagation, and Rails-native durable fanout without replacing Active Job
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: []