event_rail 0.1.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/CONTRIBUTING.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +364 -0
- data/SECURITY.md +3 -0
- data/lib/event_rail/contract.rb +44 -0
- data/lib/event_rail/current.rb +95 -0
- data/lib/event_rail/data.rb +4 -0
- data/lib/event_rail/envelope.rb +123 -0
- data/lib/event_rail/errors.rb +196 -0
- data/lib/event_rail/event.rb +258 -0
- data/lib/event_rail/internal/attribute_record.rb +272 -0
- data/lib/event_rail/internal/context.rb +32 -0
- data/lib/event_rail/internal/contract_index.rb +28 -0
- data/lib/event_rail/internal/event_serializer.rb +148 -0
- data/lib/event_rail/internal/execution.rb +86 -0
- data/lib/event_rail/internal/extensions.rb +69 -0
- data/lib/event_rail/internal/identity.rb +90 -0
- data/lib/event_rail/internal/notifications.rb +42 -0
- data/lib/event_rail/internal/portable_value.rb +108 -0
- data/lib/event_rail/internal/registry.rb +224 -0
- data/lib/event_rail/internal/stamping.rb +185 -0
- data/lib/event_rail/internal/subscriber_execution.rb +61 -0
- data/lib/event_rail/internal/timestamp.rb +85 -0
- data/lib/event_rail/internal/transaction.rb +33 -0
- data/lib/event_rail/internal/types.rb +441 -0
- data/lib/event_rail/job_context.rb +124 -0
- data/lib/event_rail/limits.rb +31 -0
- data/lib/event_rail/metadata.rb +94 -0
- data/lib/event_rail/portable_type.rb +35 -0
- data/lib/event_rail/publication.rb +33 -0
- data/lib/event_rail/publish.rb +88 -0
- data/lib/event_rail/railtie.rb +15 -0
- data/lib/event_rail/subscriptions.rb +59 -0
- data/lib/event_rail/version.rb +3 -0
- data/lib/event_rail.rb +52 -0
- metadata +184 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e59345edd1e6e7570345e9b68df5be4b565e5705051d35f854076a64e8fc08b8
|
|
4
|
+
data.tar.gz: d1391a4e11a36c9a81df720b6cd1376e4ddc79a126fab0fa8988db435a4823b4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a1bc401719f38d3f8fa654c1aba1df05451aaed81f813c7af74bb6c865ce90f35ccf48d579ec982627d973cd9f705e076b546303fd4b9759d5107dd5d99b9025
|
|
7
|
+
data.tar.gz: 6200d549898556da58c5d09ebe252373a10d73e4876494850cba5ae6f5603c2d2175b2744801eb02ab8ab57cd8ac9a7c65b7e2c9c34e63fc1906e872636bb8fa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to EventRail are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
6
|
+
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
While the version is below 1.0 the public API may change in a minor release.
|
|
8
|
+
|
|
9
|
+
This file tracks the **gem** version only. An event's own `version` is a separate
|
|
10
|
+
integer contract declared per event class, and the queue representation carries its own
|
|
11
|
+
private format version; neither is derived from the gem version and neither appears
|
|
12
|
+
here unless a release changes how they behave.
|
|
13
|
+
|
|
14
|
+
## [Unreleased]
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2026-09-15
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `EventRail::Event` and `EventRail::Data`: immutable, recursively frozen Active Model
|
|
21
|
+
value objects with strict casting that refuses to discard information, an `attributes`
|
|
22
|
+
view of cast values, and a `data` projection that is the single portable written form.
|
|
23
|
+
- `EventRail.publish`, returning a frozen `EventRail::Publication` carrying the stamped
|
|
24
|
+
event and the accepted and skipped subscribers.
|
|
25
|
+
- `subscribes_to`, discovering subscribers from the conventional `app/events` and
|
|
26
|
+
`app/jobs` roots of the host application and every engine during Rails preparation,
|
|
27
|
+
and validating each one at boot.
|
|
28
|
+
- Retry-stable event identity derived as a UUIDv5 over source, executing job class,
|
|
29
|
+
execution scope, event type, version, and logical publication identity, with an
|
|
30
|
+
explicit `key:` and a declarative `identity_by` for selecting that identity.
|
|
31
|
+
- `EventRail::JobContext`, an opt-in Active Job concern propagating logical context
|
|
32
|
+
through one reserved key in the job's serialized data.
|
|
33
|
+
- `EventRail::Current` and `EventRail.with_context` for establishing and reading
|
|
34
|
+
message, correlation, and causation identifiers, origin time, and string-keyed
|
|
35
|
+
extensions.
|
|
36
|
+
- `EventRail::Envelope` and `EventRail::Contract` for crossing a network boundary, with
|
|
37
|
+
the codec and the inbound allowlist owned by the application.
|
|
38
|
+
- `EventRail::PortableType`, an opt-in contract for custom attribute types whose
|
|
39
|
+
`portable_examples` are round-tripped through JSON when the attribute is declared.
|
|
40
|
+
- Four `ActiveSupport::Notifications` events -- `publish.event_rail`,
|
|
41
|
+
`enqueue_subscriber.event_rail`, `deserialize.event_rail`, and
|
|
42
|
+
`perform_subscriber.event_rail` -- carrying contract, identity, and lineage only.
|
|
43
|
+
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
44
|
+
casting, context, serialization, and publication failures.
|
|
45
|
+
|
|
46
|
+
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...HEAD
|
|
47
|
+
[0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
EventRail is under initial development. Please open an issue before proposing a substantial API change.
|
|
4
|
+
|
|
5
|
+
Run `bin/test` and `bin/rubocop` before submitting a change.
|
|
6
|
+
|
|
7
|
+
To run the suite against a specific Rails version, generate the per-Rails gemfiles first
|
|
8
|
+
-- they are derived from the root `Gemfile` and are not committed:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
bundle exec appraisal generate
|
|
12
|
+
bundle exec appraisal install
|
|
13
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
|
|
14
|
+
```
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Alex Dmitriev
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# EventRail
|
|
2
|
+
|
|
3
|
+
[](https://github.com/alexdmtv/event-rail/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
EventRail is an early-stage Rails library for immutable domain events and durable fanout through ordinary Active Job subscribers. It builds on Rails conventions instead of introducing a transport, command bus, dependency-injection container, or replacement job runtime.
|
|
6
|
+
|
|
7
|
+
EventRail is not ready for production use yet. The public API is still being implemented and validated.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby 3.3 or newer
|
|
12
|
+
- Rails 7.2, 8.0, or 8.1
|
|
13
|
+
|
|
14
|
+
EventRail depends on Active Support, Active Model, Active Job, Railties, and Zeitwerk. It does not require Active Record.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# doc:illustrative
|
|
20
|
+
gem "event_rail"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then run `bundle install`. There is no initializer to generate and nothing to configure: EventRail initializes itself through a Railtie, and every safety limit is a fixed documented constant.
|
|
24
|
+
|
|
25
|
+
## Job integration
|
|
26
|
+
|
|
27
|
+
One explicit inclusion, on each job base class that should carry logical context:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class ApplicationJob < ActiveJob::Base
|
|
31
|
+
include EventRail::JobContext
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That is the entire integration. EventRail does not prepend `ActiveJob::Base` globally, because changing serialization for jobs whose owners never asked for it is surprising, and it does not edit `ApplicationJob` from a generator, because applications may have several job bases or a customized one. Ordinary jobs that do not opt in are untouched.
|
|
36
|
+
|
|
37
|
+
## Events and nested data
|
|
38
|
+
|
|
39
|
+
Events use Active Model types and validations. Nested objects can be modeled as immutable `EventRail::Data` values, including arrays of typed objects:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
module Docs
|
|
43
|
+
class LineItem < EventRail::Data
|
|
44
|
+
attribute :product_id, :string
|
|
45
|
+
attribute :quantity, :integer
|
|
46
|
+
|
|
47
|
+
validates :product_id, presence: true
|
|
48
|
+
validates :quantity, numericality: { greater_than: 0 }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class OrderPlaced < EventRail::Event
|
|
52
|
+
event_type "docs.order_placed"
|
|
53
|
+
version 1
|
|
54
|
+
default_source "acme.orders"
|
|
55
|
+
identity_by :order_id
|
|
56
|
+
|
|
57
|
+
attribute :order_id, :string
|
|
58
|
+
attribute :total, :decimal
|
|
59
|
+
attribute :placed_at, :datetime
|
|
60
|
+
attribute :line_items, LineItem, array: true
|
|
61
|
+
attribute :properties
|
|
62
|
+
|
|
63
|
+
validates :order_id, presence: true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
event = Docs::OrderPlaced.new(
|
|
68
|
+
order_id: "A-1001",
|
|
69
|
+
total: "49.90",
|
|
70
|
+
placed_at: "2026-09-01T10:30:00+02:00",
|
|
71
|
+
line_items: [ { product_id: "P-1", quantity: 2 } ],
|
|
72
|
+
properties: { "channel" => "web" },
|
|
73
|
+
extensions: { "tenant" => "north" }
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
event.order_id # => "A-1001"
|
|
77
|
+
event.total # => BigDecimal("49.9")
|
|
78
|
+
event.line_items.first.quantity # => 2
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Two views of the same payload, for two different jobs:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
event.attributes # cast values of the declared types: BigDecimal, Time, LineItem
|
|
85
|
+
event.data # the portable projection: JSON primitives, arrays, string-keyed hashes
|
|
86
|
+
event.metadata # immutable metadata; not yet stamped
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`attributes` is Active Model's own meaning and is what application code reads. `data` is the single written form that both the queue representation and the public envelope use, so a `Date`, a `BigDecimal`, or a nested Ruby object never reaches a queue adapter or a codec.
|
|
90
|
+
|
|
91
|
+
Casting refuses to discard information rather than substituting a plausible value. `"abc"` is not `0`, `true` is not `"t"`, a timestamp needs an explicit offset, and a date attribute will not silently drop a time of day. An untyped attribute accepts only recursively JSON-like values — strings, integers, finite floats, booleans, `nil`, arrays, string-keyed hashes — bounded in nesting depth and forbidden from using the `_aj_` prefix Active Job reserves in its own argument encoding. Records, GlobalID values, arbitrary Ruby objects, non-finite numbers, undeclared attributes, and callable defaults are rejected.
|
|
92
|
+
|
|
93
|
+
Constructed events, nested data, metadata, and every contained value are recursively immutable. Two events of one class with equal payload and equal metadata are equal values, which is what lets `assert_enqueued_with(args: [event])` match a published event.
|
|
94
|
+
|
|
95
|
+
### Custom attribute types
|
|
96
|
+
|
|
97
|
+
A custom Active Model type may be used as an attribute type. Rails' `serialize` is documented as producing a value "usable by the database", and a database driver accepts a `Date` or a `BigDecimal` object -- a queue does not, and Active Job does not recurse into a serializer's output, so such a value would reach the adapter raw. Including `EventRail::PortableType` narrows the promise to a JSON primitive, array, or string-keyed hash, and `portable_examples` makes the promise checkable:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
module Docs
|
|
101
|
+
Weight = Struct.new(:grams)
|
|
102
|
+
|
|
103
|
+
class WeightType < ActiveModel::Type::Value
|
|
104
|
+
include EventRail::PortableType
|
|
105
|
+
|
|
106
|
+
def cast(value) = value.is_a?(Weight) || value.nil? ? value : Weight.new(Integer(value))
|
|
107
|
+
def serialize(value) = value&.grams
|
|
108
|
+
def deserialize(value) = value && Weight.new(value)
|
|
109
|
+
def portable_examples = [ Weight.new(0), Weight.new(2500) ]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class ParcelShipped < EventRail::Event
|
|
113
|
+
event_type "docs.parcel_shipped"
|
|
114
|
+
version 1
|
|
115
|
+
default_source "acme.shipping"
|
|
116
|
+
identity_by :parcel_id
|
|
117
|
+
|
|
118
|
+
attribute :parcel_id, :string
|
|
119
|
+
attribute :weight, WeightType.new
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Docs::ParcelShipped.new(parcel_id: "P-1", weight: 2500).data
|
|
124
|
+
# => { "parcel_id" => "P-1", "weight" => 2500 }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Every example is round-tripped through JSON when the attribute is declared, so a type that cannot hold up fails at class definition rather than at the first enqueue. A type whose cast value is already a portable scalar -- a plain `:string` subclass, say -- needs none of this.
|
|
128
|
+
|
|
129
|
+
## Publishing
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
module Docs
|
|
133
|
+
class OnOrderPlacedJob < ApplicationJob
|
|
134
|
+
subscribes_to OrderPlaced
|
|
135
|
+
|
|
136
|
+
def perform(event)
|
|
137
|
+
Rails.logger.info("charging #{event.order_id} idempotently on #{event.id}")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
publication = EventRail.publish(
|
|
143
|
+
Docs::OrderPlaced.new(order_id: "A-1002", total: "10.00", placed_at: Time.now.utc.iso8601)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
publication.event # the stamped, immutable fact
|
|
147
|
+
publication.accepted_subscribers # subscribers whose enqueue was accepted
|
|
148
|
+
publication.skipped_subscribers # subscribers whose own enqueue callback declined
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Subscribers are discovered from the conventional `app/events` and `app/jobs` roots of the host application and every engine, during Rails preparation. There is no registration API, no initializer, and no registry to query.
|
|
152
|
+
|
|
153
|
+
`subscribes_to` is exact and not inherited: a subclass of a subscriber is a different job and receives nothing. A subscriber must define its own `perform` taking exactly one required positional event parameter, must not have subclasses, and must include `EventRail::JobContext`. Each of those is checked during preparation, so a mistake fails the boot that introduced it rather than the first publication.
|
|
154
|
+
|
|
155
|
+
A subscriber declared outside those roots must be loaded before preparation finishes, or declaring it raises: EventRail refuses to run with a subscriber it cannot see at boot. A subscriber required from an initializer works, but initializers run before the main autoloader exists, so such a file has to bring its own event class and job base rather than referencing autoloaded constants. Declaring a subscriber after preparation -- from a test file, or from a lazily autoloaded path outside `app/events` and `app/jobs` -- raises for the same reason, so a test that needs a throwaway subscriber should define it in a file under a conventional root of the test application instead.
|
|
156
|
+
|
|
157
|
+
### At-least-once delivery, and what that means for subscribers
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
No ordering is promised, between subscribers or between events.
|
|
162
|
+
|
|
163
|
+
Retries, backoff, discarding, and dead-letter handling stay where they already are: each subscriber's own Active Job configuration and the configured queue adapter. EventRail adds no retry policy of its own and no failure queue.
|
|
164
|
+
|
|
165
|
+
### Publishing inside a database transaction
|
|
166
|
+
|
|
167
|
+
Don't. Publication raises `EventRail::TransactionalPublicationError` when a transaction is open, in every environment, and the fix is to publish after the transaction commits.
|
|
168
|
+
|
|
169
|
+
Both queue deferral settings are wrong inside a transaction, in opposite directions. With `enqueue_after_transaction_commit` on, the enqueue is deferred past the point where its failure can be reported, so a publication that silently enqueued nothing looks successful. With it off, the enqueue announces a fact that a rollback then contradicts. The check is on the open transaction itself, so it does not depend on the setting — or on Active Record being present at all.
|
|
170
|
+
|
|
171
|
+
### Replay-safe publishers
|
|
172
|
+
|
|
173
|
+
A publisher that applies a state transition and then publishes should make the transition idempotent and attempt publication unconditionally:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
module Docs
|
|
177
|
+
class PlaceOrderJob < ApplicationJob
|
|
178
|
+
def perform(order_id)
|
|
179
|
+
order = { id: order_id, placed: true } # stands in for an idempotent state transition
|
|
180
|
+
EventRail.publish(Docs::OrderPlaced.new(order_id: order[:id], total: "1.00", placed_at: Time.now.utc.iso8601))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Guarding publication behind "did I already transition?" is the failure mode to avoid: a crash between the transition and the enqueue then leaves an event that is never published. Attempting publication every time is safe, because a second publication of the same logical fact in the same execution is rejected as a duplicate, and a retry after a failed fanout reuses the identity already stamped.
|
|
187
|
+
|
|
188
|
+
## Identity, occurrence time, and source
|
|
189
|
+
|
|
190
|
+
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.
|
|
191
|
+
|
|
192
|
+
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:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
EventRail.publish(
|
|
196
|
+
Docs::OrderPlaced.new(order_id: "A-1003", total: "5.00", placed_at: Time.now.utc.iso8601),
|
|
197
|
+
key: "adjustment-7"
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
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.
|
|
202
|
+
|
|
203
|
+
`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.
|
|
204
|
+
|
|
205
|
+
`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.
|
|
206
|
+
|
|
207
|
+
## Logical context
|
|
208
|
+
|
|
209
|
+
Application-owned ingress code establishes context; EventRail ships no HTTP middleware and no controller concern, and harvests nothing from headers, Rails current state, or tracing baggage.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
EventRail.with_context(message_id: "req-abc", extensions: { "tenant" => "north" }) do
|
|
213
|
+
EventRail.publish(Docs::OrderPlaced.new(order_id: "A-1004", total: "7.00", placed_at: Time.now.utc.iso8601))
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A nested scope inherits lineage and may add extensions or repeat identical values; replacing an inherited identifier, origin time, or extension value fails rather than rewriting the lineage of a flow already in progress.
|
|
218
|
+
|
|
219
|
+
Context extensions are durable baggage: opted-in child jobs carry them, and published events merge them with event-local extensions, rejecting conflicting values. They are not the place for domain data — that belongs in declared attributes.
|
|
220
|
+
|
|
221
|
+
### Reading context
|
|
222
|
+
|
|
223
|
+
A job that includes `EventRail::JobContext` runs with context already installed, and reads it from `EventRail::Current`:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
class ReconcileOrderJob < ApplicationJob
|
|
227
|
+
def perform
|
|
228
|
+
EventRail::Current.message_id # this job's logical message
|
|
229
|
+
EventRail::Current.correlation_id # constant across the whole causal tree
|
|
230
|
+
EventRail::Current.causation_id # what caused this job
|
|
231
|
+
EventRail::Current.originated_at # when the flow started, a UTC Time
|
|
232
|
+
EventRail::Current.extensions # frozen string-keyed baggage
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Those five readers are the whole surface. Outside a job they return `nil`, except `extensions`, which is always a frozen hash.
|
|
238
|
+
|
|
239
|
+
A subscriber is the case where they are usually unnecessary. Its logical message is the event it is handling rather than the job delivering it, so `Current.message_id` **is** `event.id`, and correlation, causation, and extensions all come from the event that is already the method argument:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
class SendReceiptJob < ApplicationJob
|
|
243
|
+
subscribes_to Docs::OrderPlaced
|
|
244
|
+
|
|
245
|
+
def perform(event)
|
|
246
|
+
event.id # == EventRail::Current.message_id
|
|
247
|
+
event.correlation_id # == EventRail::Current.correlation_id
|
|
248
|
+
event.extensions # == EventRail::Current.extensions
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Reach for `Current` in a subscriber only to hand lineage to something that does not take the event -- a log line, an outbound request header, an APM tag.
|
|
254
|
+
|
|
255
|
+
Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers -- a fiber-per-request server, or a worker that runs jobs on fibers -- must set `config.active_support.isolation_level = :fiber`**, or lineage will be shared between concurrent fibers. That setting is Rails-wide rather than EventRail's, and it governs both `EventRail::Current` and EventRail's own publication state. EventRail documents the requirement rather than claiming an isolation it cannot provide.
|
|
256
|
+
|
|
257
|
+
Leaving it wrong fails loudly rather than silently: under thread isolation two concurrent fibers each establishing context raise `InvalidContext`, because the second inherits the first's identifiers and replacing them is refused. The rule that stops lineage being rewritten mid-flow doubles as a misconfiguration detector.
|
|
258
|
+
|
|
259
|
+
Under fiber isolation a fiber spawned by application code starts with no context at all, which is the isolation working as asked. Code that fans out over its own fibers and publishes from them should re-establish context inside each fiber rather than rely on inheriting it. A job running on a fiber-based worker needs none of that: its context arrives in the job's own serialized data, not from an ambient parent.
|
|
260
|
+
|
|
261
|
+
OpenTelemetry is not required. When standard Active Job instrumentation is installed, its own carrier and ambient span continue to work; EventRail never copies trace context or baggage into durable event metadata.
|
|
262
|
+
|
|
263
|
+
## Versioning events
|
|
264
|
+
|
|
265
|
+
`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.
|
|
266
|
+
|
|
267
|
+
EventRail provides no upcasting and no schema registry.
|
|
268
|
+
|
|
269
|
+
## Fixed safety limits
|
|
270
|
+
|
|
271
|
+
| Value | Limit |
|
|
272
|
+
| --- | ---: |
|
|
273
|
+
| Event, correlation, causation, or boundary identifier | 512 bytes |
|
|
274
|
+
| Source | 255 bytes |
|
|
275
|
+
| Event type | 255 bytes |
|
|
276
|
+
| Extension entries | 32 |
|
|
277
|
+
| Extension key | 64 bytes |
|
|
278
|
+
| Extension value | 1,024 bytes |
|
|
279
|
+
| Total extension key and value bytes | 8,192 bytes |
|
|
280
|
+
| Raw structure nesting depth | 32 |
|
|
281
|
+
|
|
282
|
+
Extension keys and values must be strings. Framework metadata names, tracing names, and the `eventrail.` prefix are reserved. There is no universal payload byte limit, because adapters and transports impose different ones.
|
|
283
|
+
|
|
284
|
+
## Testing
|
|
285
|
+
|
|
286
|
+
Application tests use Active Job's own helpers and nothing from EventRail:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# doc:illustrative
|
|
290
|
+
publication = EventRail.publish(Docs::OrderPlaced.new(order_id: "A-1005", total: "1.00", placed_at: Time.now.utc.iso8601))
|
|
291
|
+
|
|
292
|
+
assert_enqueued_with(job: Docs::OnOrderPlacedJob, args: [ publication.event ])
|
|
293
|
+
perform_enqueued_jobs
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper.
|
|
297
|
+
|
|
298
|
+
## Notifications
|
|
299
|
+
|
|
300
|
+
Four `ActiveSupport::Notifications` events, all in block form so Rails' own exception keys report failures:
|
|
301
|
+
|
|
302
|
+
| Name | Additional payload |
|
|
303
|
+
| --- | --- |
|
|
304
|
+
| `publish.event_rail` | `subscriber_count`, `accepted`, `skipped` |
|
|
305
|
+
| `enqueue_subscriber.event_rail` | `job_class`, `outcome` (`accepted`, `skipped`, `failed`) |
|
|
306
|
+
| `deserialize.event_rail` | `format_version` |
|
|
307
|
+
| `perform_subscriber.event_rail` | `job_class` |
|
|
308
|
+
|
|
309
|
+
Every payload carries `event_type`, `event_version`, `event_id`, `source`, `correlation_id`, and `causation_id`. None carries domain data or extensions. Handlers run synchronously under normal Rails semantics, so they should neither raise nor do slow work.
|
|
310
|
+
|
|
311
|
+
## Crossing a network boundary
|
|
312
|
+
|
|
313
|
+
`EventRail::Envelope` is the format-neutral boundary. It exposes contract, metadata, and the portable data projection through readers, and produces no bytes, no canonical hash, and no content type: those decisions belong to whoever owns the transport.
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
# doc:illustrative
|
|
317
|
+
envelope = EventRail::Envelope.of(publication.event)
|
|
318
|
+
payload = MyCloudEventsCodec.encode(envelope) # the application owns every byte
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Inbound, the application maps a contract to a class with its own allowlist and then reconstructs explicitly. A type name arriving over a network never names a Ruby constant, and the internal queue registry does not authorize external input:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
# doc:illustrative
|
|
325
|
+
ACCEPTED = { [ "partner.invoice_issued", 1 ] => Billing::InvoiceIssued }.freeze
|
|
326
|
+
|
|
327
|
+
event_class = ACCEPTED.fetch([ envelope.event_type, envelope.version ])
|
|
328
|
+
EventRail.publish(envelope.to_event(event_class))
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
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.
|
|
332
|
+
|
|
333
|
+
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.
|
|
334
|
+
|
|
335
|
+
Acknowledge a consumed message only after publication succeeds, and leave retry and dead-lettering to the transport.
|
|
336
|
+
|
|
337
|
+
## Upgrading and rolling back
|
|
338
|
+
|
|
339
|
+
The private Active Job representation and the job context entry each carry their own version, independent of any event's schema version. Both evolve as staged, read-before-write deployments:
|
|
340
|
+
|
|
341
|
+
1. Deploy a release that reads the old and the new form everywhere, while still writing the old one.
|
|
342
|
+
2. Only then deploy a release that writes the new form.
|
|
343
|
+
3. Remove the old reader only after every queued message in the old form is drained or expired.
|
|
344
|
+
|
|
345
|
+
Rolling back reverses that: a rollback is safe only to a release that can still read what the newer release wrote. A change to how an individual value is written is a different matter — that encoding is shared with the public envelope, so it is a breaking public change rather than a private format bump.
|
|
346
|
+
|
|
347
|
+
## Development
|
|
348
|
+
|
|
349
|
+
Run the core tests and linter with:
|
|
350
|
+
|
|
351
|
+
```sh
|
|
352
|
+
bin/test
|
|
353
|
+
bin/rubocop
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The Appraisal gemfiles cover Rails 7.2, 8.0, and 8.1. The CI matrix covers Ruby 3.3, 3.4, and 4.0.
|
|
357
|
+
|
|
358
|
+
## Contributing and security
|
|
359
|
+
|
|
360
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the local workflow and [SECURITY.md](SECURITY.md) for reporting security issues.
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
EventRail is available under the [MIT License](MIT-LICENSE).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# The durable identity of an event schema: a stable type and an integer compatibility
|
|
3
|
+
# version, independent of any Ruby class name. Constants may move; this does not.
|
|
4
|
+
class Contract
|
|
5
|
+
attr_reader :event_type, :version
|
|
6
|
+
|
|
7
|
+
def self.of(event_class)
|
|
8
|
+
new(event_type: event_class.event_type, version: event_class.version)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(event_type:, version:)
|
|
12
|
+
unless event_type.is_a?(String) && !event_type.empty? && event_type.valid_encoding?
|
|
13
|
+
raise InvalidContract, "event_type must be a non-empty valid string; got #{event_type.inspect}"
|
|
14
|
+
end
|
|
15
|
+
if event_type.bytesize > Limits::MAX_EVENT_TYPE_BYTES
|
|
16
|
+
raise InvalidContract, "event_type exceeds #{Limits::MAX_EVENT_TYPE_BYTES} bytes"
|
|
17
|
+
end
|
|
18
|
+
unless version.is_a?(Integer) && version.positive?
|
|
19
|
+
raise InvalidContract, "version must be a positive integer; got #{version.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@event_type = event_type.dup.freeze
|
|
23
|
+
@version = version
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.instance_of?(self.class) && other.event_type == event_type && other.version == version
|
|
29
|
+
end
|
|
30
|
+
alias_method :eql?, :==
|
|
31
|
+
|
|
32
|
+
def hash
|
|
33
|
+
[ self.class, event_type, version ].hash
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
"#{event_type}/#{version}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inspect
|
|
41
|
+
"#<EventRail::Contract #{self}>"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "active_support/current_attributes"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module EventRail
|
|
5
|
+
# Execution-scoped lineage, and nothing else.
|
|
6
|
+
#
|
|
7
|
+
# Publication state is deliberately not here: the Rails executor resets current
|
|
8
|
+
# attributes around a job's execution but not around a job performed inside
|
|
9
|
+
# another, so a shared map would behave differently in test than in production.
|
|
10
|
+
# `Internal::Execution` owns that instead.
|
|
11
|
+
#
|
|
12
|
+
# `ActiveSupport::IsolatedExecutionState` defaults to thread isolation, so a host
|
|
13
|
+
# running fiber-per-request must set `config.active_support.isolation_level = :fiber`
|
|
14
|
+
# for lineage to be isolated per request. EventRail documents that requirement
|
|
15
|
+
# rather than claiming an isolation it cannot provide.
|
|
16
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
17
|
+
attribute :message_id, :correlation_id, :causation_id, :originated_at, :extensions
|
|
18
|
+
|
|
19
|
+
def extensions
|
|
20
|
+
super || Internal::Extensions::EMPTY
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Establishes logical context for an application-owned ingress boundary: a
|
|
26
|
+
# middleware, a controller hook, a consumer draining a queue, a CLI entry point,
|
|
27
|
+
# a scheduler. EventRail ships no HTTP middleware and harvests nothing from
|
|
28
|
+
# headers, Rails current state, or tracing baggage -- what propagates is what the
|
|
29
|
+
# application installed here.
|
|
30
|
+
#
|
|
31
|
+
# A nested scope inherits lineage and may add extensions. It may not replace an
|
|
32
|
+
# identifier, the origin time, or an extension value with a different one: that
|
|
33
|
+
# would rewrite the lineage of a flow already in progress rather than describe it.
|
|
34
|
+
def with_context(message_id: nil, correlation_id: nil, originated_at: nil, extensions: {})
|
|
35
|
+
inherited = {
|
|
36
|
+
message_id: Current.message_id,
|
|
37
|
+
correlation_id: Current.correlation_id,
|
|
38
|
+
causation_id: Current.causation_id,
|
|
39
|
+
originated_at: Current.originated_at,
|
|
40
|
+
extensions: Current.extensions
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resolved_message_id = resolve_context_identifier(:message_id, message_id, inherited[:message_id]) ||
|
|
44
|
+
SecureRandom.uuid.freeze
|
|
45
|
+
resolved_correlation_id = resolve_context_identifier(:correlation_id, correlation_id, inherited[:correlation_id]) ||
|
|
46
|
+
resolved_message_id
|
|
47
|
+
resolved_originated_at = resolve_context_time(originated_at, inherited[:originated_at])
|
|
48
|
+
resolved_extensions = Internal::Extensions.merge!(
|
|
49
|
+
inherited[:extensions], extensions, error: InvalidContext
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
execution = Internal::Execution.new(scope: resolved_message_id, started_at: resolved_originated_at)
|
|
53
|
+
|
|
54
|
+
# A nested scope keeps its parent's causation: the message that caused this flow
|
|
55
|
+
# does not change because the application opened an inner block.
|
|
56
|
+
Internal::Context.establish(
|
|
57
|
+
message_id: resolved_message_id,
|
|
58
|
+
correlation_id: resolved_correlation_id,
|
|
59
|
+
causation_id: inherited[:causation_id],
|
|
60
|
+
originated_at: resolved_originated_at,
|
|
61
|
+
extensions: resolved_extensions
|
|
62
|
+
) do
|
|
63
|
+
Internal::Execution.wrap(execution) { yield }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
def resolve_context_identifier(name, supplied, inherited)
|
|
69
|
+
return inherited if supplied.nil?
|
|
70
|
+
|
|
71
|
+
Metadata.validate_identifier!(supplied, field: name.to_s)
|
|
72
|
+
frozen = supplied.dup.freeze
|
|
73
|
+
if inherited && inherited != frozen
|
|
74
|
+
raise InvalidContext,
|
|
75
|
+
"#{name} is already #{inherited.inspect} in this context and cannot be replaced with #{frozen.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
frozen
|
|
79
|
+
rescue InvalidMetadata => error
|
|
80
|
+
raise InvalidContext, error.message
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def resolve_context_time(supplied, inherited)
|
|
84
|
+
return inherited || Internal::Timestamp.normalize(Time.now.utc, field: "originated_at", error: InvalidContext) if supplied.nil?
|
|
85
|
+
|
|
86
|
+
normalized = Internal::Timestamp.normalize(supplied, field: "originated_at", error: InvalidContext)
|
|
87
|
+
if inherited && inherited != normalized
|
|
88
|
+
raise InvalidContext,
|
|
89
|
+
"originated_at is already #{inherited.iso8601(6)} in this context and cannot be replaced"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
normalized
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|