event_rail 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +77 -3
- data/lib/event_rail/errors.rb +6 -0
- data/lib/event_rail/event.rb +9 -0
- data/lib/event_rail/internal/registry.rb +259 -9
- data/lib/event_rail/railtie.rb +21 -0
- data/lib/event_rail/subscriptions.rb +14 -1
- data/lib/event_rail/test_helper.rb +74 -0
- data/lib/event_rail/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 191255f222c7ea1b8b082d118d7d6c4111c52f822cb320004fbcb0323a07d464
|
|
4
|
+
data.tar.gz: 4082d4d03ca75711cf9077d54d7fa1249e04738e29ce5e575fa4fb336d49389b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e0b5b49ad4be65fade9e5e9120aed1bcd1a87fd51ab49288b05a3f380d906af1b146d42568d82a83bee34581bfa268ef512b97c2602f533c5eb32168a64afa6
|
|
7
|
+
data.tar.gz: 20685b5f579f6d68a3742ca1388e4f52714a25165071d38899ba98bbd78b45c0be8173dee1a587c134902c5128dd9c9dc2ee75d4ce4b135d409994bcef71a3e5
|
data/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,50 @@ here unless a release changes how they behave.
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.2.0] - 2026-09-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `EventRail::TestHelper`, loaded by an explicit `require "event_rail/test_helper"` and not
|
|
21
|
+
by requiring the library. `EventRail::TestHelper.declare` opens a window in which event
|
|
22
|
+
contracts and subscriptions may be declared after application preparation has sealed the
|
|
23
|
+
registry, and `with_subscribers` activates a declared fixture subscriber for the duration
|
|
24
|
+
of one block, restoring the previous registry afterwards. Contracts declared in a window
|
|
25
|
+
go live when it closes; subscribers stay dormant until activated, so a fixture cannot fan
|
|
26
|
+
out in a later test that never mentions it.
|
|
27
|
+
- `config.event_rail.roots`, defaulting to `%w[app/events app/jobs]` and appendable, naming
|
|
28
|
+
the directories discovery scans. Each entry is matched as a path suffix against the main
|
|
29
|
+
autoloader's roots, so one entry covers the host application and every engine. A
|
|
30
|
+
configured root beyond the default that is not an autoload root fails preparation with the
|
|
31
|
+
new `EventRail::ConfigurationError`; a default root the application has not created is
|
|
32
|
+
skipped.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **A concrete `EventRail::Event` subclass that declares `event_type` or `version` after
|
|
37
|
+
preparation now raises `EventRail::DeclarationError` at the declaration**, the same rule
|
|
38
|
+
subscriptions have always followed. Previously such a class was silently absent from the
|
|
39
|
+
contract index: it published cleanly, serialized cleanly, and failed only when a worker
|
|
40
|
+
tried to reconstruct it, with `UnknownEventTypeError`. Move the class under `app/events`,
|
|
41
|
+
add its root to `config.event_rail.roots`, or -- for a test fixture -- declare it inside
|
|
42
|
+
`EventRail::TestHelper.declare`. Abstract bases and classes never assigned to a constant
|
|
43
|
+
are unaffected.
|
|
44
|
+
- A subscription declared on a class with no name now raises where it is declared. Active
|
|
45
|
+
Job cannot enqueue a job it cannot name, so this was never a working configuration; it was
|
|
46
|
+
previously dropped from the registry without comment.
|
|
47
|
+
- Both late-declaration messages now name the declaration's own file and line, state that
|
|
48
|
+
discovery runs before `eager_load!` so `config.eager_load_paths` alone is never enough,
|
|
49
|
+
and offer both remedies. The advice to load such code from an autoload-once path is gone
|
|
50
|
+
as general guidance: it never applied to reloadable application code.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- A declaration arriving between a reload's constant unload and the registry rebuild is no
|
|
55
|
+
longer rejected as late. Rails deletes constants before running prepare callbacks and
|
|
56
|
+
nothing cleared the snapshot in between, so that window was indistinguishable from a
|
|
57
|
+
sealed registry -- reachable by any prepare callback ordered ahead of EventRail's,
|
|
58
|
+
including one belonging to a gem that loads earlier in the Gemfile.
|
|
59
|
+
|
|
16
60
|
## [0.1.0] - 2026-09-15
|
|
17
61
|
|
|
18
62
|
### Added
|
|
@@ -43,5 +87,6 @@ here unless a release changes how they behave.
|
|
|
43
87
|
- A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
|
|
44
88
|
casting, context, serialization, and publication failures.
|
|
45
89
|
|
|
46
|
-
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.
|
|
90
|
+
[Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...HEAD
|
|
91
|
+
[0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
|
|
47
92
|
[0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ EventRail depends on Active Support, Active Model, Active Job, Railties, and Zei
|
|
|
20
20
|
gem "event_rail"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Then run `bundle install`. There is no initializer to generate and nothing to configure: EventRail initializes itself through a Railtie,
|
|
23
|
+
Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose -- [where it looks for events and subscribers](#where-discovery-looks) -- has a default that most applications never change.
|
|
24
24
|
|
|
25
25
|
## Job integration
|
|
26
26
|
|
|
@@ -150,9 +150,27 @@ publication.skipped_subscribers # subscribers whose own enqueue callback declin
|
|
|
150
150
|
|
|
151
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
152
|
|
|
153
|
+
### Where discovery looks
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# doc:illustrative
|
|
157
|
+
# config/application.rb
|
|
158
|
+
config.event_rail.roots << "app/subscribers" # default: %w[app/events app/jobs]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Each entry is matched as a path suffix against the autoload roots Rails already has, so one entry covers the host application, every engine, and a packwerk-style `packs/billing/app/jobs` without naming any of them. Appending is additive: the defaults stay in effect.
|
|
162
|
+
|
|
163
|
+
Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload -- moving the file is usually the better fix. And a root you name that is not an autoload root of the application or any engine fails preparation with `EventRail::ConfigurationError`, so a typo is a boot error rather than a directory that silently discovers nothing. A *default* root the application has not created is simply skipped, so a fresh application with no `app/events` directory boots normally.
|
|
164
|
+
|
|
153
165
|
`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
166
|
|
|
155
|
-
A subscriber
|
|
167
|
+
A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported -- name the class first. The check is on the name the class carries, not on whether a constant resolves to it, so a class with a name nothing resolves to is still dropped from the registry without comment; that is a deliberate limit, not an oversight.
|
|
168
|
+
|
|
169
|
+
One rule covers both halves of discovery: **an event contract or a subscription declared after preparation has sealed the registry raises**, naming the file and line and the two ways to fix it. EventRail refuses to run with a subscriber that would receive nothing, or with an event class a worker could not reconstruct from the queue.
|
|
170
|
+
|
|
171
|
+
Adding the directory to `config.eager_load_paths` is never enough on its own, and the reason is Rails' own ordering: prepare callbacks run *before* `eager_load!`, so a reloadable class outside a discovery root cannot be loaded in time in any environment. Either move the file under a discovery root, or [add its root](#where-discovery-looks).
|
|
172
|
+
|
|
173
|
+
Two exceptions. Code that is not reloadable at all -- a gem, or a file plainly required from an initializer -- ran its declaration before preparation, so it is already registered; such a file has to bring its own event class and job base, because initializers run before the main autoloader exists. And a test suite loads after preparation by definition, which is what [`EventRail::TestHelper`](#tests-that-need-their-own-fixtures) is for.
|
|
156
174
|
|
|
157
175
|
### At-least-once delivery, and what that means for subscribers
|
|
158
176
|
|
|
@@ -293,7 +311,63 @@ assert_enqueued_with(job: Docs::OnOrderPlacedJob, args: [ publication.event ])
|
|
|
293
311
|
perform_enqueued_jobs
|
|
294
312
|
```
|
|
295
313
|
|
|
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.
|
|
314
|
+
`assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper -- Active Job's helpers are the whole assertion surface.
|
|
315
|
+
|
|
316
|
+
### Tests that need their own fixtures
|
|
317
|
+
|
|
318
|
+
A test file loads after preparation, so declaring a throwaway event or subscriber in one would raise. `EventRail::TestHelper` is the door for it, and it is opt-in:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
# doc:illustrative
|
|
322
|
+
# test/test_helper.rb
|
|
323
|
+
require "event_rail/test_helper"
|
|
324
|
+
|
|
325
|
+
class ActiveSupport::TestCase
|
|
326
|
+
include EventRail::TestHelper
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Declare fixtures at file scope, in a window:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
EventRail::TestHelper.declare do
|
|
334
|
+
module OrderTests
|
|
335
|
+
class Placed < EventRail::Event
|
|
336
|
+
event_type "docs.order_tests_placed"
|
|
337
|
+
version 1
|
|
338
|
+
default_source "tests"
|
|
339
|
+
|
|
340
|
+
attribute :order_id, :string
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
class AuditJob < ApplicationJob
|
|
344
|
+
subscribes_to Placed
|
|
345
|
+
|
|
346
|
+
def perform(event) = Rails.logger.info(event.order_id)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**A subscriber declared in a window receives nothing until you activate it.** That is deliberate: a fixture that went live on declaration would fan out in every later test in the process, including tests that never mention it. Activate it for one block:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
# doc:illustrative
|
|
356
|
+
test "publication fans out to the audit job" do
|
|
357
|
+
with_subscribers(OrderTests::AuditJob) do
|
|
358
|
+
publication = EventRail.publish(OrderTests::Placed.new(order_id: "o-1"))
|
|
359
|
+
|
|
360
|
+
assert_enqueued_with(job: OrderTests::AuditJob, args: [ publication.event ])
|
|
361
|
+
perform_enqueued_jobs
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The previous registry is restored when the block exits, including when it raises, and nested activations are additive. Activation applies the same validation preparation does, so an abstract fixture fails there rather than at delivery.
|
|
367
|
+
|
|
368
|
+
Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing -- **give each fixture a unique `event_type`, and declare it once, at file scope.** Two live classes claiming one type and version fail every later rebuild for the rest of the process, and a declaration inside a test method reopens the same constant and runs the writer again on a sealed registry.
|
|
369
|
+
|
|
370
|
+
Activation replaces a process-wide registry, so it is not safe under `parallelize(with: :threads)`. Process-based parallelisation, the Rails default, is unaffected.
|
|
297
371
|
|
|
298
372
|
## Notifications
|
|
299
373
|
|
data/lib/event_rail/errors.rb
CHANGED
|
@@ -13,6 +13,12 @@ module EventRail
|
|
|
13
13
|
class DeclarationError < Error
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# An application's own configuration is wrong: a discovery root that is not an autoload
|
|
17
|
+
# root of the application or any engine. Distinct from a declaration fault, which is about
|
|
18
|
+
# a class body, and raised at preparation so it fails the boot that introduced it.
|
|
19
|
+
class ConfigurationError < Error
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
# A value cannot become the declared type without discarding information, or is not
|
|
17
23
|
# portable at all.
|
|
18
24
|
class CastingError < Error
|
data/lib/event_rail/event.rb
CHANGED
|
@@ -43,6 +43,12 @@ module EventRail
|
|
|
43
43
|
raise DeclarationError, "event_type exceeds #{Limits::MAX_EVENT_TYPE_BYTES} bytes"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Only a change is a declaration. Re-running a class body that sets the same value --
|
|
47
|
+
# a `load` rather than a `require`, a reopened class -- alters no contract, so the
|
|
48
|
+
# index is already correct and rejecting it would be a false positive.
|
|
49
|
+
unless @event_type == value
|
|
50
|
+
Internal::Registry.declare_contract(self, caller_locations(1, 1).first)
|
|
51
|
+
end
|
|
46
52
|
@event_type = value.dup.freeze
|
|
47
53
|
end
|
|
48
54
|
|
|
@@ -53,6 +59,9 @@ module EventRail
|
|
|
53
59
|
raise DeclarationError, "version must be a positive integer"
|
|
54
60
|
end
|
|
55
61
|
|
|
62
|
+
unless @event_version == value
|
|
63
|
+
Internal::Registry.declare_contract(self, caller_locations(1, 1).first)
|
|
64
|
+
end
|
|
56
65
|
@event_version = value
|
|
57
66
|
end
|
|
58
67
|
|
|
@@ -12,7 +12,14 @@ module EventRail
|
|
|
12
12
|
# to have no subscribers: the first is a boot-order bug and raises, the second is a
|
|
13
13
|
# legitimate zero-delivery publication.
|
|
14
14
|
class Registry
|
|
15
|
-
|
|
15
|
+
# The conventional discovery roots. Matched as a path suffix against the main
|
|
16
|
+
# autoloader's roots, which is what makes one entry cover the host application, every
|
|
17
|
+
# engine, and packwerk-style `packs/*/app/jobs` without naming any of them.
|
|
18
|
+
#
|
|
19
|
+
# An application may append through `config.event_rail.roots`. The default is copied
|
|
20
|
+
# rather than shared, so `<<` works and cannot mutate this constant.
|
|
21
|
+
DEFAULT_ROOTS = %w[app/events app/jobs].freeze
|
|
22
|
+
CONVENTIONAL_ROOTS = DEFAULT_ROOTS
|
|
16
23
|
|
|
17
24
|
Snapshot = Struct.new(:subscribers, :contracts, keyword_init: true) do
|
|
18
25
|
def subscribers_for(event_class)
|
|
@@ -34,23 +41,69 @@ module EventRail
|
|
|
34
41
|
@pending = []
|
|
35
42
|
@snapshot = nil
|
|
36
43
|
@building = false
|
|
44
|
+
# A test declaration window, opened by EventRail::TestHelper.declare. Distinct from
|
|
45
|
+
# @building: a window routes subscriptions to the dormant fixture set rather than to
|
|
46
|
+
# the live pending list, which is what keeps a fixture from fanning out to every
|
|
47
|
+
# later test in the process.
|
|
48
|
+
@window = false
|
|
49
|
+
@fixtures = []
|
|
50
|
+
# Set from `before_class_unload`, which fires first in a reload cycle, and cleared when
|
|
51
|
+
# preparation finishes. Rails deletes the constants and only then runs prepare
|
|
52
|
+
# callbacks, and nothing clears the snapshot in between -- so without this flag the
|
|
53
|
+
# window between the two is indistinguishable from a sealed registry, and a legal
|
|
54
|
+
# declaration arriving there would be rejected for lateness.
|
|
55
|
+
@reloading = false
|
|
56
|
+
# Subscribers activated for the duration of a block, and the snapshot and fixture set
|
|
57
|
+
# to restore when it exits. A stack, so nested activations are additive.
|
|
58
|
+
@active_fixtures = []
|
|
59
|
+
@activations = []
|
|
37
60
|
|
|
38
61
|
class << self
|
|
39
62
|
# Appending is idempotent per job class: the macro may be called more than once
|
|
40
63
|
# in one body, and the declarations themselves live on the job class.
|
|
41
|
-
def declare(job_class)
|
|
64
|
+
def declare(job_class, location = nil)
|
|
42
65
|
@monitor.synchronize do
|
|
43
|
-
if @
|
|
66
|
+
if @window
|
|
67
|
+
@fixtures << job_class unless @fixtures.include?(job_class)
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if @snapshot && !@building && !@reloading
|
|
44
72
|
raise DeclarationError,
|
|
45
|
-
"#{job_class} declared a subscription after EventRail finished
|
|
46
|
-
"
|
|
47
|
-
"
|
|
73
|
+
"#{named(job_class)}#{" (#{at(location)})" if location} declared a subscription after EventRail finished " \
|
|
74
|
+
"preparing, so it would receive no deliveries. #{discovery_advice}. A subscriber in a gem that is " \
|
|
75
|
+
"not loaded at boot can be required from an initializer. In a test, define it inside " \
|
|
76
|
+
"EventRail::TestHelper.declare."
|
|
48
77
|
end
|
|
49
78
|
|
|
50
79
|
@pending << job_class unless @pending.include?(job_class)
|
|
51
80
|
end
|
|
52
81
|
end
|
|
53
82
|
|
|
83
|
+
# The contract half of the sealing rule, called from the writer form of
|
|
84
|
+
# `EventRail::Event.event_type` and `.version`. A check only: contracts are collected
|
|
85
|
+
# by rescanning `EventRail::Event.descendants` when a snapshot is built, so there is
|
|
86
|
+
# no pending list for events and nothing here to keep reload-safe.
|
|
87
|
+
#
|
|
88
|
+
# Named classes only. An unnamed class can never enter the index -- `build_contracts`
|
|
89
|
+
# selects through `live?`, which resolves the constant the name denotes -- so checking
|
|
90
|
+
# one would reject every inline event definition in a test suite for no guarantee
|
|
91
|
+
# gained.
|
|
92
|
+
def declare_contract(event_class, location = nil)
|
|
93
|
+
return if event_class.name.nil?
|
|
94
|
+
|
|
95
|
+
@monitor.synchronize do
|
|
96
|
+
next unless @snapshot
|
|
97
|
+
next if @building || @reloading || @window
|
|
98
|
+
|
|
99
|
+
raise DeclarationError,
|
|
100
|
+
"#{named(event_class)}#{" (#{at(location)})" if location} declared an event contract after EventRail " \
|
|
101
|
+
"finished preparing, so a worker could not reconstruct it from the queue. #{discovery_advice}. An " \
|
|
102
|
+
"event class in a gem that is not loaded at boot can be required from an initializer. In a test, " \
|
|
103
|
+
"define it inside EventRail::TestHelper.declare."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
54
107
|
def snapshot
|
|
55
108
|
snapshot = @snapshot
|
|
56
109
|
return snapshot if snapshot
|
|
@@ -64,6 +117,17 @@ module EventRail
|
|
|
64
117
|
!@snapshot.nil?
|
|
65
118
|
end
|
|
66
119
|
|
|
120
|
+
# Marks the start of a reload cycle. Registered once from a Railtie initializer, never
|
|
121
|
+
# from preparation: a callback registered inside `prepare` would be added again on
|
|
122
|
+
# every reload and accumulate for the life of the process.
|
|
123
|
+
def reloading!
|
|
124
|
+
@monitor.synchronize { @reloading = true }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def reloading?
|
|
128
|
+
@reloading
|
|
129
|
+
end
|
|
130
|
+
|
|
67
131
|
def subscribers_for(event_class)
|
|
68
132
|
snapshot.subscribers_for(event_class)
|
|
69
133
|
end
|
|
@@ -80,6 +144,11 @@ module EventRail
|
|
|
80
144
|
@snapshot = build_snapshot
|
|
81
145
|
ensure
|
|
82
146
|
@building = previously_building
|
|
147
|
+
# Cleared here rather than from a reloader callback because the two reload paths
|
|
148
|
+
# differ: a console `reload!` runs prepare twice, the executor path that serves a
|
|
149
|
+
# request runs it once. The end of preparation is the only point correct for
|
|
150
|
+
# both. In `ensure`, so a failed prepare does not leave sealing disabled.
|
|
151
|
+
@reloading = false
|
|
83
152
|
end
|
|
84
153
|
end
|
|
85
154
|
@snapshot
|
|
@@ -100,11 +169,90 @@ module EventRail
|
|
|
100
169
|
end
|
|
101
170
|
end
|
|
102
171
|
|
|
172
|
+
# Opens a test declaration window. Subscriptions declared inside become dormant
|
|
173
|
+
# fixtures; event contracts declared inside are indexed when the window closes,
|
|
174
|
+
# because `build_contracts` rescans `EventRail::Event.descendants` and a named class
|
|
175
|
+
# stays there for the life of the process.
|
|
176
|
+
#
|
|
177
|
+
# The monitor is held across the yield, as `reopen` does, because a window is opened
|
|
178
|
+
# at file scope before any test runs. `activate` deliberately does not, since it
|
|
179
|
+
# yields to arbitrary test code.
|
|
180
|
+
def declare_fixtures
|
|
181
|
+
@monitor.synchronize do
|
|
182
|
+
unless @activations.empty?
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"a declaration window cannot be opened inside with_subscribers, because closing it rebuilds the " \
|
|
185
|
+
"registry and would discard the activation"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
previously = @window
|
|
189
|
+
@window = true
|
|
190
|
+
begin
|
|
191
|
+
yield
|
|
192
|
+
ensure
|
|
193
|
+
@window = previously
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Rebuilding here is what puts window-declared contracts in the index. Nothing
|
|
197
|
+
# is eager-loaded: the window body has already run.
|
|
198
|
+
@snapshot = build_snapshot(extra_subscribers: @active_fixtures) if @snapshot
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Activates dormant fixture subscribers for the duration of the block, then restores
|
|
203
|
+
# the previous snapshot. The block runs outside the monitor: a test may spawn a
|
|
204
|
+
# thread that calls `prepare`, and holding the lock across the yield would deadlock
|
|
205
|
+
# it.
|
|
206
|
+
def activate(job_classes)
|
|
207
|
+
restore = nil
|
|
208
|
+
|
|
209
|
+
@monitor.synchronize do
|
|
210
|
+
validate_activation!(job_classes)
|
|
211
|
+
|
|
212
|
+
# Built before anything is committed: `build_snapshot` applies the same
|
|
213
|
+
# validation preparation does, so an abstract or context-less fixture raises
|
|
214
|
+
# here. Assigning first would leave the failed fixture active for every later
|
|
215
|
+
# activation in the process.
|
|
216
|
+
# Deduplicated: activating a fixture that an enclosing block already activated
|
|
217
|
+
# must not register it twice, which would double its fanout -- the same defect
|
|
218
|
+
# `prune_stale_declarations` exists to prevent on the reload path.
|
|
219
|
+
fixtures = (@active_fixtures + job_classes).uniq
|
|
220
|
+
candidate = build_snapshot(extra_subscribers: fixtures)
|
|
221
|
+
|
|
222
|
+
restore = [ @snapshot, @active_fixtures ]
|
|
223
|
+
@snapshot = candidate
|
|
224
|
+
@active_fixtures = fixtures
|
|
225
|
+
@activations.push(restore)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
yield
|
|
230
|
+
ensure
|
|
231
|
+
@monitor.synchronize do
|
|
232
|
+
@snapshot, @active_fixtures = restore
|
|
233
|
+
@activations.pop
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def fixtures
|
|
239
|
+
@fixtures.dup.freeze
|
|
240
|
+
end
|
|
241
|
+
|
|
103
242
|
def reset!
|
|
104
243
|
@monitor.synchronize do
|
|
105
244
|
@pending = []
|
|
106
245
|
@snapshot = nil
|
|
107
246
|
@building = false
|
|
247
|
+
@window = false
|
|
248
|
+
@reloading = false
|
|
249
|
+
@active_fixtures = []
|
|
250
|
+
@activations = []
|
|
251
|
+
# @fixtures is deliberately not cleared, for the same reason the pending list is
|
|
252
|
+
# pruned rather than emptied: a fixture is declared once at file scope and its
|
|
253
|
+
# window never runs again, so clearing it would leave every later activation in
|
|
254
|
+
# the process unable to find it. Fixtures are dormant, so keeping them changes
|
|
255
|
+
# no snapshot.
|
|
108
256
|
end
|
|
109
257
|
end
|
|
110
258
|
|
|
@@ -118,8 +266,11 @@ module EventRail
|
|
|
118
266
|
loader = Rails.autoloaders.main
|
|
119
267
|
return unless loader.respond_to?(:eager_load_dir)
|
|
120
268
|
|
|
269
|
+
roots = configured_roots
|
|
270
|
+
validate_roots!(roots, loader)
|
|
271
|
+
|
|
121
272
|
loader.dirs.each do |dir|
|
|
122
|
-
next unless
|
|
273
|
+
next unless roots.any? { |root| dir.end_with?("/#{root}") }
|
|
123
274
|
next unless Dir.exist?(dir)
|
|
124
275
|
|
|
125
276
|
begin
|
|
@@ -132,6 +283,75 @@ module EventRail
|
|
|
132
283
|
end
|
|
133
284
|
end
|
|
134
285
|
|
|
286
|
+
# Both late-declaration messages share this. It interpolates the configured roots
|
|
287
|
+
# rather than hardcoding the defaults, and it names the ordering fact that makes the
|
|
288
|
+
# obvious fix wrong: Rails runs prepare callbacks before `eager_load!`, so adding a
|
|
289
|
+
# directory to `eager_load_paths` is always too late.
|
|
290
|
+
def discovery_advice
|
|
291
|
+
# Falls back to the default rather than raising: this builds the text of another
|
|
292
|
+
# error, and a misconfigured roots value has its own error from `validate_roots!`
|
|
293
|
+
# at preparation.
|
|
294
|
+
roots = configured_roots
|
|
295
|
+
roots = DEFAULT_ROOTS.dup unless well_formed_roots?(roots)
|
|
296
|
+
"EventRail discovers #{roots.join(" and ")} in the application and its engines, and discovery runs " \
|
|
297
|
+
"before eager_load!, so adding the directory to eager_load_paths is not enough: move the file under " \
|
|
298
|
+
"#{roots.first}, or add its root to config.event_rail.roots (which eager-loads that directory " \
|
|
299
|
+
"during preparation in every environment)"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# `to_s` on a class whose `name` was overridden still reports the anonymous form, and
|
|
303
|
+
# the name is what an adopter searches for.
|
|
304
|
+
def named(klass)
|
|
305
|
+
klass.name || klass.inspect
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Relative to the application root, so the message names the file the way an editor
|
|
309
|
+
# does rather than with an absolute path.
|
|
310
|
+
def at(location)
|
|
311
|
+
path = location.path
|
|
312
|
+
root = Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
313
|
+
path = path.delete_prefix("#{root}/") if root
|
|
314
|
+
|
|
315
|
+
"#{path}:#{location.lineno}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Copied on every read, so the registry never holds a reference the application can
|
|
319
|
+
# mutate afterwards and repeated reloads cannot accumulate entries.
|
|
320
|
+
def configured_roots
|
|
321
|
+
configured =
|
|
322
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
323
|
+
Rails.application.config.event_rail&.roots
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
(configured || DEFAULT_ROOTS).dup
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def well_formed_roots?(roots)
|
|
330
|
+
roots.is_a?(Array) && roots.all? { |root| root.is_a?(String) && !root.empty? }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Only entries beyond the default are checked. Rails registers an `app/*` directory
|
|
334
|
+
# with the main loader only when it exists, so a freshly generated application that
|
|
335
|
+
# has not created `app/events` yet has no such root -- validating the defaults would
|
|
336
|
+
# fail its boot before it had written a single event. A missing default is a
|
|
337
|
+
# convention not yet used; a missing entry the application typed is a mistake.
|
|
338
|
+
def validate_roots!(roots, loader)
|
|
339
|
+
unless well_formed_roots?(roots)
|
|
340
|
+
raise ConfigurationError,
|
|
341
|
+
"config.event_rail.roots must be an array of non-empty strings; got #{roots.inspect}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
dirs = loader.dirs.map(&:to_s)
|
|
345
|
+
|
|
346
|
+
(roots - DEFAULT_ROOTS).each do |root|
|
|
347
|
+
next if dirs.any? { |dir| dir.end_with?("/#{root}") }
|
|
348
|
+
|
|
349
|
+
raise ConfigurationError,
|
|
350
|
+
"config.event_rail.roots names #{root.inspect}, which is not an autoload root of the application " \
|
|
351
|
+
"or any engine"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
135
355
|
# A reload replaces class objects while leaving the previous ones reachable
|
|
136
356
|
# from this list. An entry survives only if the constant its own name denotes
|
|
137
357
|
# is still this exact object, which is what distinguishes a live class from a
|
|
@@ -156,11 +376,17 @@ module EventRail
|
|
|
156
376
|
resolved.equal?(klass)
|
|
157
377
|
end
|
|
158
378
|
|
|
159
|
-
|
|
379
|
+
# `extra_subscribers` carries fixtures activated for a block. They are not added to
|
|
380
|
+
# the pending list, so the next rebuild without them drops them again.
|
|
381
|
+
def build_snapshot(extra_subscribers: EMPTY_SUBSCRIBERS)
|
|
160
382
|
contracts = build_contracts
|
|
161
383
|
subscribers = {}
|
|
162
384
|
|
|
163
|
-
|
|
385
|
+
# The pending list is filtered here, not only pruned by preparation. A snapshot
|
|
386
|
+
# may be built without a preceding prune -- `activate` does exactly that -- and
|
|
387
|
+
# the list can hold an entry whose macro ran before a later argument raised, so
|
|
388
|
+
# a snapshot must never validate a class the constant no longer denotes.
|
|
389
|
+
(@pending.select { |job_class| live?(job_class) } + extra_subscribers).uniq.each do |job_class|
|
|
164
390
|
validate_subscriber!(job_class)
|
|
165
391
|
|
|
166
392
|
job_class.event_rail_subscriptions.each do |event_class|
|
|
@@ -186,6 +412,30 @@ module EventRail
|
|
|
186
412
|
ContractIndex.build(concrete)
|
|
187
413
|
end
|
|
188
414
|
|
|
415
|
+
# These are caller mistakes in a test, not failures of the library's declaration
|
|
416
|
+
# rules, so they raise ArgumentError: an adopter rescuing EventRail::Error should
|
|
417
|
+
# not catch them.
|
|
418
|
+
def validate_activation!(job_classes)
|
|
419
|
+
job_classes.each do |job_class|
|
|
420
|
+
if job_class.name.nil?
|
|
421
|
+
raise ArgumentError,
|
|
422
|
+
"#{job_class.inspect} has no name, and Active Job cannot enqueue a job it cannot name; " \
|
|
423
|
+
"assign the class to a constant"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
next if @fixtures.include?(job_class)
|
|
427
|
+
|
|
428
|
+
if @pending.include?(job_class)
|
|
429
|
+
raise ArgumentError,
|
|
430
|
+
"#{job_class} is already a live subscriber; with_subscribers is for fixtures declared in a test"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
raise ArgumentError,
|
|
434
|
+
"#{job_class} was not declared inside EventRail::TestHelper.declare, so with_subscribers cannot " \
|
|
435
|
+
"activate it"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
189
439
|
def validate_subscriber!(job_class)
|
|
190
440
|
unless job_class.instance_methods(false).include?(:perform) ||
|
|
191
441
|
job_class.private_instance_methods(false).include?(:perform)
|
data/lib/event_rail/railtie.rb
CHANGED
|
@@ -6,10 +6,31 @@ module EventRail
|
|
|
6
6
|
# eager_load!, so no reloadable constant outside a conventional root can already be
|
|
7
7
|
# loaded here -- which is exactly why one declared there raises later instead of
|
|
8
8
|
# silently receiving nothing.
|
|
9
|
+
# The directories discovery scans, matched as a path suffix against the main
|
|
10
|
+
# autoloader's roots. An application appends to it; the default is a mutable copy, so
|
|
11
|
+
# `config.event_rail.roots << "app/subscribers"` works.
|
|
12
|
+
config.event_rail = ActiveSupport::OrderedOptions.new
|
|
13
|
+
config.event_rail.roots = Internal::Registry::DEFAULT_ROOTS.dup
|
|
14
|
+
|
|
9
15
|
config.to_prepare do
|
|
10
16
|
# Unqualified, so lexical lookup reaches the private Internal namespace that a
|
|
11
17
|
# qualified EventRail::Internal reference would be refused.
|
|
12
18
|
Internal::Registry.prepare
|
|
13
19
|
end
|
|
20
|
+
|
|
21
|
+
# A reload deletes the constants and only then runs prepare callbacks, and nothing
|
|
22
|
+
# clears the snapshot in between -- so a declaration arriving in that window would be
|
|
23
|
+
# rejected for lateness even though the imminent prepare would have registered it. That
|
|
24
|
+
# window is reachable by any prepare callback ordered ahead of EventRail's, which is the
|
|
25
|
+
# case for one registered directly on the reloader from an initializer, or belonging to
|
|
26
|
+
# a gem that loads earlier in the Gemfile.
|
|
27
|
+
#
|
|
28
|
+
# An initializer rather than a prepare block: registering the callback from `prepare`
|
|
29
|
+
# would add it again on every reload.
|
|
30
|
+
initializer "event_rail.mark_reload_window" do |app|
|
|
31
|
+
if app.config.reloading_enabled?
|
|
32
|
+
app.reloader.before_class_unload { Internal::Registry.reloading! }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
14
35
|
end
|
|
15
36
|
end
|
|
@@ -39,7 +39,20 @@ module EventRail
|
|
|
39
39
|
own << event_class
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# Checked here rather than during preparation, and after the arguments above rather
|
|
43
|
+
# than before them. Active Job cannot enqueue a job it cannot name, so an anonymous
|
|
44
|
+
# subscriber is never a working configuration -- but preparation is the wrong place to
|
|
45
|
+
# say so: the macro has already recorded the class by then, and rejecting it there
|
|
46
|
+
# would make every later rebuild in the process fail, including in unrelated tests.
|
|
47
|
+
# After the argument checks, because a bad argument on an anonymous class deserves the
|
|
48
|
+
# specific error rather than this one.
|
|
49
|
+
if name.nil?
|
|
50
|
+
raise DeclarationError,
|
|
51
|
+
"#{inspect} declared a subscription but has no name, and Active Job cannot enqueue a job it cannot " \
|
|
52
|
+
"name; assign the class to a constant"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Internal::Registry.declare(self, caller_locations(1, 1).first)
|
|
43
56
|
include Internal::SubscriberExecution unless include?(Internal::SubscriberExecution)
|
|
44
57
|
|
|
45
58
|
event_rail_subscriptions
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "event_rail"
|
|
2
|
+
|
|
3
|
+
module EventRail
|
|
4
|
+
# Test support, loaded only by an explicit `require "event_rail/test_helper"`. Requiring
|
|
5
|
+
# the library does not load it, and requiring it does not by itself weaken the rule that
|
|
6
|
+
# a declaration arriving after preparation is an error.
|
|
7
|
+
#
|
|
8
|
+
# # test/test_helper.rb
|
|
9
|
+
# require "event_rail/test_helper"
|
|
10
|
+
#
|
|
11
|
+
# class ActiveSupport::TestCase
|
|
12
|
+
# include EventRail::TestHelper
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Including the module adds `with_subscribers` and nothing else. `declare` stays a module
|
|
16
|
+
# method rather than becoming an instance method on every test case, because the name is
|
|
17
|
+
# general enough to collide.
|
|
18
|
+
module TestHelper
|
|
19
|
+
# Opens a window in which event contracts and subscriptions may be declared even though
|
|
20
|
+
# preparation has already sealed the registry. Fixtures go at file scope, once per
|
|
21
|
+
# process:
|
|
22
|
+
#
|
|
23
|
+
# EventRail::TestHelper.declare do
|
|
24
|
+
# module OrderTests
|
|
25
|
+
# class Placed < EventRail::Event
|
|
26
|
+
# event_type "tests.order_placed"
|
|
27
|
+
# version 1
|
|
28
|
+
# default_source "tests"
|
|
29
|
+
#
|
|
30
|
+
# attribute :order_id, :string
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# class AuditJob < ApplicationJob
|
|
34
|
+
# subscribes_to Placed
|
|
35
|
+
#
|
|
36
|
+
# def perform(event) = (self.class.seen << event)
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# Contracts declared here are indexed when the window closes and stay indexed: a class
|
|
42
|
+
# cannot be unloaded, so two fixtures claiming one event type and version fail every
|
|
43
|
+
# later rebuild, not just the first. Give each fixture a unique type, and declare it at
|
|
44
|
+
# file scope -- a declaration inside a test method reopens the same constant and runs the
|
|
45
|
+
# writer again on a sealed registry.
|
|
46
|
+
#
|
|
47
|
+
# Subscriptions declared here are dormant. They receive nothing until `with_subscribers`
|
|
48
|
+
# activates them, so a fixture cannot fan out in a later test that never mentions it.
|
|
49
|
+
def self.declare(&block)
|
|
50
|
+
# Unqualified, so lexical lookup reaches the private Internal namespace that a
|
|
51
|
+
# qualified EventRail::Internal reference would be refused.
|
|
52
|
+
Internal::Registry.declare_fixtures(&block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Activates fixture subscribers for the duration of the block and restores the previous
|
|
56
|
+
# registry afterwards, including when the block raises. Nested calls are additive.
|
|
57
|
+
#
|
|
58
|
+
# test "publication fans out to the audit job" do
|
|
59
|
+
# with_subscribers(OrderTests::AuditJob) do
|
|
60
|
+
# publication = EventRail.publish(OrderTests::Placed.new(order_id: "o-1"))
|
|
61
|
+
#
|
|
62
|
+
# assert_enqueued_with job: OrderTests::AuditJob, args: [ publication.event ]
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# The registry snapshot this replaces is process-wide, so activation is not safe under
|
|
67
|
+
# thread-based test parallelisation (`parallelize(with: :threads)`). Process-based
|
|
68
|
+
# parallelisation, which is the Rails default, is unaffected because each worker has its
|
|
69
|
+
# own registry.
|
|
70
|
+
def with_subscribers(*job_classes, &block)
|
|
71
|
+
Internal::Registry.activate(job_classes.flatten, &block)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/event_rail/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: event_rail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Dmitriev
|
|
@@ -153,6 +153,7 @@ files:
|
|
|
153
153
|
- lib/event_rail/publish.rb
|
|
154
154
|
- lib/event_rail/railtie.rb
|
|
155
155
|
- lib/event_rail/subscriptions.rb
|
|
156
|
+
- lib/event_rail/test_helper.rb
|
|
156
157
|
- lib/event_rail/version.rb
|
|
157
158
|
homepage: https://github.com/alexdmtv/event-rail
|
|
158
159
|
licenses:
|