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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CONTRIBUTING.md +14 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +364 -0
  6. data/SECURITY.md +3 -0
  7. data/lib/event_rail/contract.rb +44 -0
  8. data/lib/event_rail/current.rb +95 -0
  9. data/lib/event_rail/data.rb +4 -0
  10. data/lib/event_rail/envelope.rb +123 -0
  11. data/lib/event_rail/errors.rb +196 -0
  12. data/lib/event_rail/event.rb +258 -0
  13. data/lib/event_rail/internal/attribute_record.rb +272 -0
  14. data/lib/event_rail/internal/context.rb +32 -0
  15. data/lib/event_rail/internal/contract_index.rb +28 -0
  16. data/lib/event_rail/internal/event_serializer.rb +148 -0
  17. data/lib/event_rail/internal/execution.rb +86 -0
  18. data/lib/event_rail/internal/extensions.rb +69 -0
  19. data/lib/event_rail/internal/identity.rb +90 -0
  20. data/lib/event_rail/internal/notifications.rb +42 -0
  21. data/lib/event_rail/internal/portable_value.rb +108 -0
  22. data/lib/event_rail/internal/registry.rb +224 -0
  23. data/lib/event_rail/internal/stamping.rb +185 -0
  24. data/lib/event_rail/internal/subscriber_execution.rb +61 -0
  25. data/lib/event_rail/internal/timestamp.rb +85 -0
  26. data/lib/event_rail/internal/transaction.rb +33 -0
  27. data/lib/event_rail/internal/types.rb +441 -0
  28. data/lib/event_rail/job_context.rb +124 -0
  29. data/lib/event_rail/limits.rb +31 -0
  30. data/lib/event_rail/metadata.rb +94 -0
  31. data/lib/event_rail/portable_type.rb +35 -0
  32. data/lib/event_rail/publication.rb +33 -0
  33. data/lib/event_rail/publish.rb +88 -0
  34. data/lib/event_rail/railtie.rb +15 -0
  35. data/lib/event_rail/subscriptions.rb +59 -0
  36. data/lib/event_rail/version.rb +3 -0
  37. data/lib/event_rail.rb +52 -0
  38. 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
+ [![CI](https://github.com/alexdmtv/event-rail/actions/workflows/ci.yml/badge.svg)](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,3 @@
1
+ # Security Policy
2
+
3
+ EventRail has not published a stable release yet. Please report suspected vulnerabilities privately through GitHub's security advisory interface rather than a public issue.
@@ -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
@@ -0,0 +1,4 @@
1
+ module EventRail
2
+ class Data < Internal::AttributeRecord
3
+ end
4
+ end