dexkit 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 +34 -0
- data/README.md +55 -1
- data/guides/llm/EVENT.md +300 -0
- data/lib/dex/concern.rb +10 -0
- data/lib/dex/event/bus.rb +98 -0
- data/lib/dex/event/execution_state.rb +17 -0
- data/lib/dex/event/handler.rb +77 -0
- data/lib/dex/event/metadata.rb +54 -0
- data/lib/dex/event/processor.rb +61 -0
- data/lib/dex/event/suppression.rb +49 -0
- data/lib/dex/event/trace.rb +56 -0
- data/lib/dex/event.rb +87 -0
- data/lib/dex/event_test_helpers/assertions.rb +70 -0
- data/lib/dex/event_test_helpers.rb +88 -0
- data/lib/dex/operation/async_proxy.rb +30 -36
- data/lib/dex/operation/async_wrapper.rb +3 -19
- data/lib/dex/operation/callback_wrapper.rb +11 -15
- data/lib/dex/operation/jobs.rb +8 -14
- data/lib/dex/operation/lock_wrapper.rb +2 -11
- data/lib/dex/operation/pipeline.rb +5 -5
- data/lib/dex/operation/record_wrapper.rb +10 -38
- data/lib/dex/operation/rescue_wrapper.rb +1 -3
- data/lib/dex/operation/result_wrapper.rb +7 -14
- data/lib/dex/operation/settings.rb +10 -3
- data/lib/dex/operation/transaction_wrapper.rb +7 -20
- data/lib/dex/operation.rb +54 -105
- data/lib/dex/{operation/props_setup.rb → props_setup.rb} +12 -15
- data/lib/dex/test_helpers.rb +3 -1
- data/lib/dex/type_coercion.rb +96 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +14 -1
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3adbbfa21a62c17b74b7ac6807ed7a9abdca2b02c85e06bb2d542bdbf7d39677
|
|
4
|
+
data.tar.gz: cead77e688d4c30a7256c05f73d509fefd44c7c6b6bfc52d9b0bdbfd8675954e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b58aec14261efd1c679bb662b163df64f96d86239e5efe8897d7b8d007aef4cd19864a8fabc03ee4acf139379c1185e56f8a5f6e488a1f5dec00442a24223dee
|
|
7
|
+
data.tar.gz: 6009bd9567cf1fd4adacf21cfb5c6627cfce8d1d399498923ae9ff8a11c3f2b31df884549a8b3bdb0c45bede9951b672c6d50dbf2199a691e45a79c37b5c1497
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-03-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Event system** — typed, immutable event value objects with publish/subscribe
|
|
8
|
+
- Typed properties via `prop`/`prop?` DSL (same as Operation)
|
|
9
|
+
- Sync and async publishing (`event.publish`, `Event.publish(sync: true)`)
|
|
10
|
+
- Handler DSL: `on` for subscription, `retries` with exponential/fixed/custom backoff
|
|
11
|
+
- Async dispatch via ActiveJob (`Dex::Event::Processor`, lazy-loaded)
|
|
12
|
+
- Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
|
|
13
|
+
- Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
|
|
14
|
+
- Optional persistence via `event_store` configuration
|
|
15
|
+
- Context capture and restoration across async boundaries (`event_context`, `restore_event_context`)
|
|
16
|
+
- **Event test helpers** — `Dex::Event::TestHelpers` module
|
|
17
|
+
- `capture_events` block for inspecting published events without dispatching
|
|
18
|
+
- `assert_event_published`, `refute_event_published`, `assert_event_count`
|
|
19
|
+
- `assert_event_trace`, `assert_same_trace` for causality assertions
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Extracted shared `validate_options!` helper for DSL option validation
|
|
24
|
+
- Added `Dex.warn` for unified warning logging
|
|
25
|
+
- Added `Dex::Concern` to reduce module inclusion boilerplate
|
|
26
|
+
- Simplified internal method names in standalone classes (AsyncProxy, Pipeline, Jobs, Processor)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `_Array(_Ref(...))` props now serialize and deserialize correctly in both Operation and Event (array of model references was crashing on `value.id` called on Array)
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Extracted `Dex::TypeCoercion` — shared module for prop serialization and coercion, used by both Operation and Event. Eliminates duplication of `_serialized_coercions`, ref type detection, and value coercion logic.
|
|
35
|
+
- Extracted `Dex::PropsSetup` — shared `prop`/`prop?`/`_Ref` DSL wrapping Literal's with reserved name validation, public reader default, and automatic RefType coercion. Eliminates duplication between Operation and Event.
|
|
36
|
+
|
|
3
37
|
## [0.1.0] - 2026-03-01
|
|
4
38
|
|
|
5
39
|
- Initial public release
|
data/README.md
CHANGED
|
@@ -84,6 +84,60 @@ class CreateUserTest < Minitest::Test
|
|
|
84
84
|
end
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
## Events
|
|
88
|
+
|
|
89
|
+
Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class OrderPlaced < Dex::Event
|
|
93
|
+
prop :order_id, Integer
|
|
94
|
+
prop :total, BigDecimal
|
|
95
|
+
prop? :coupon_code, String
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class NotifyWarehouse < Dex::Event::Handler
|
|
99
|
+
on OrderPlaced
|
|
100
|
+
retries 3
|
|
101
|
+
|
|
102
|
+
def perform
|
|
103
|
+
WarehouseApi.notify(event.order_id)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
OrderPlaced.publish(order_id: 1, total: 99.99)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### What you get out of the box
|
|
111
|
+
|
|
112
|
+
**Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
|
|
113
|
+
|
|
114
|
+
**Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline.
|
|
115
|
+
|
|
116
|
+
**Causality tracing** — link events in chains with shared `trace_id`:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
order_placed.trace do
|
|
120
|
+
InventoryReserved.publish(order_id: 1)
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
|
|
125
|
+
|
|
126
|
+
### Testing
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class CreateOrderTest < Minitest::Test
|
|
130
|
+
include Dex::Event::TestHelpers
|
|
131
|
+
|
|
132
|
+
def test_publishes_order_placed
|
|
133
|
+
capture_events do
|
|
134
|
+
CreateOrder.call(item_id: 1)
|
|
135
|
+
assert_event_published(OrderPlaced, order_id: 1)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
87
141
|
## Installation
|
|
88
142
|
|
|
89
143
|
```ruby
|
|
@@ -100,7 +154,7 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
|
|
|
100
154
|
|
|
101
155
|
```bash
|
|
102
156
|
cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
|
|
103
|
-
cp $(bundle show dexkit)/guides/llm/
|
|
157
|
+
cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
|
|
104
158
|
```
|
|
105
159
|
|
|
106
160
|
## License
|
data/guides/llm/EVENT.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Dex::Event — LLM Reference
|
|
2
|
+
|
|
3
|
+
Copy this to your app's event handlers directory (e.g., `app/event_handlers/AGENTS.md`) so coding agents know the full API when implementing and testing events.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Reference Event
|
|
8
|
+
|
|
9
|
+
All examples below build on this event unless noted otherwise:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class OrderPlaced < Dex::Event
|
|
13
|
+
prop :order_id, Integer
|
|
14
|
+
prop :total, BigDecimal
|
|
15
|
+
prop? :coupon_code, String
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Defining Events
|
|
22
|
+
|
|
23
|
+
Typed, immutable value objects. Props defined with `prop` (required) and `prop?` (optional). Same type system as `Dex::Operation`.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class UserCreated < Dex::Event
|
|
27
|
+
prop :user_id, Integer
|
|
28
|
+
prop :email, String
|
|
29
|
+
prop? :referrer, String
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
|
|
34
|
+
|
|
35
|
+
Events are frozen after creation. Each gets auto-generated `id` (UUID), `timestamp` (UTC), `trace_id`, and optional `caused_by_id`.
|
|
36
|
+
|
|
37
|
+
### Literal Types Cheatsheet
|
|
38
|
+
|
|
39
|
+
Same types as Operation — `String`, `Integer`, `_Integer(1..)`, `_Array(String)`, `_Union("a", "b")`, `_Nilable(String)`, `_Ref(Model)`, etc.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Publishing
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# Class-level (most common)
|
|
47
|
+
OrderPlaced.publish(order_id: 1, total: 99.99) # async (default)
|
|
48
|
+
OrderPlaced.publish(order_id: 1, total: 99.99, sync: true) # sync
|
|
49
|
+
|
|
50
|
+
# Instance-level
|
|
51
|
+
event = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
52
|
+
event.publish # async
|
|
53
|
+
event.publish(sync: true) # sync
|
|
54
|
+
|
|
55
|
+
# With causality
|
|
56
|
+
OrderPlaced.publish(order_id: 1, total: 99.99, caused_by: parent_event)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Async** (default): handlers dispatched via ActiveJob. **Sync**: handlers called inline.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Handling
|
|
64
|
+
|
|
65
|
+
Handlers are classes that subscribe to events. Subscription is automatic via `on`:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class NotifyWarehouse < Dex::Event::Handler
|
|
69
|
+
on OrderPlaced
|
|
70
|
+
on OrderUpdated # subscribe to multiple events
|
|
71
|
+
|
|
72
|
+
def perform
|
|
73
|
+
event # accessor — the event instance
|
|
74
|
+
event.order_id # typed props
|
|
75
|
+
event.id # UUID
|
|
76
|
+
event.timestamp # Time (UTC)
|
|
77
|
+
event.caused_by_id # parent event ID (if traced)
|
|
78
|
+
event.trace_id # shared trace ID across causal chain
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Multi-event handlers**: A single handler can subscribe to multiple event types.
|
|
84
|
+
|
|
85
|
+
### Loading Handlers (Rails)
|
|
86
|
+
|
|
87
|
+
Handlers must be loaded for `on` to register. Standard pattern:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# config/initializers/events.rb
|
|
91
|
+
Rails.application.config.to_prepare do
|
|
92
|
+
Dex::Event::Bus.clear!
|
|
93
|
+
Dir.glob(Rails.root.join("app/event_handlers/**/*.rb")).each { |e| require(e) }
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Retries
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class ProcessPayment < Dex::Event::Handler
|
|
101
|
+
on PaymentReceived
|
|
102
|
+
retries 3 # exponential backoff (1s, 2s, 4s)
|
|
103
|
+
retries 3, wait: 10 # fixed 10s between retries
|
|
104
|
+
retries 3, wait: ->(attempt) { attempt * 5 } # custom delay
|
|
105
|
+
|
|
106
|
+
def perform
|
|
107
|
+
# ...
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When retries exhausted, exception propagates normally.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Tracing (Causality)
|
|
117
|
+
|
|
118
|
+
Link events in a causal chain. All events in a chain share the same `trace_id`.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
122
|
+
|
|
123
|
+
# Option 1: trace block
|
|
124
|
+
order_placed.trace do
|
|
125
|
+
InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
|
|
126
|
+
ShippingRequested.publish(order_id: 1) # same trace_id
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Option 2: caused_by keyword
|
|
130
|
+
InventoryReserved.publish(order_id: 1, caused_by: order_placed)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Nesting works — each child gets the nearest parent's `id` as `caused_by_id`, and the root's `trace_id`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Suppression
|
|
138
|
+
|
|
139
|
+
Prevent events from being published (useful in tests, migrations, bulk ops):
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Dex::Event.suppress { ... } # suppress all events
|
|
143
|
+
Dex::Event.suppress(OrderPlaced) { ... } # suppress specific class
|
|
144
|
+
Dex::Event.suppress(OrderPlaced, UserCreated) { ... } # suppress multiple
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Suppression is block-scoped and nestable. Child classes are suppressed when parent is.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Persistence (Optional)
|
|
152
|
+
|
|
153
|
+
Store events to database when configured:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Dex.configure do |c|
|
|
157
|
+
c.event_store = EventRecord # any model with create!(event_type:, payload:, metadata:)
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
create_table :event_records do |t|
|
|
163
|
+
t.string :event_type
|
|
164
|
+
t.jsonb :payload
|
|
165
|
+
t.jsonb :metadata
|
|
166
|
+
t.timestamps
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Persistence failures are silently rescued — they never halt event publishing.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Context (Optional)
|
|
175
|
+
|
|
176
|
+
Capture ambient context (current user, tenant, etc.) at publish time:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
Dex.configure do |c|
|
|
180
|
+
c.event_context = -> { { user_id: Current.user&.id, tenant: Current.tenant } }
|
|
181
|
+
c.restore_event_context = ->(ctx) {
|
|
182
|
+
Current.user = User.find(ctx["user_id"]) if ctx["user_id"]
|
|
183
|
+
Current.tenant = ctx["tenant"]
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Context is stored in event metadata and restored before async handler execution.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Configuration
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# config/initializers/dexkit.rb
|
|
196
|
+
Dex.configure do |config|
|
|
197
|
+
config.event_store = nil # model for persistence (default: nil)
|
|
198
|
+
config.event_context = nil # -> { Hash } lambda (default: nil)
|
|
199
|
+
config.restore_event_context = nil # ->(ctx) { ... } lambda (default: nil)
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Everything works without configuration. All three settings are optional.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Testing
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# test/test_helper.rb
|
|
211
|
+
require "dex/event_test_helpers"
|
|
212
|
+
|
|
213
|
+
class Minitest::Test
|
|
214
|
+
include Dex::Event::TestHelpers
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Not autoloaded — stays out of production.
|
|
219
|
+
|
|
220
|
+
### Capturing Events
|
|
221
|
+
|
|
222
|
+
Captures events instead of dispatching handlers:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
def test_publishes_order_placed
|
|
226
|
+
capture_events do
|
|
227
|
+
OrderPlaced.publish(order_id: 1, total: 99.99)
|
|
228
|
+
|
|
229
|
+
assert_event_published(OrderPlaced)
|
|
230
|
+
assert_event_published(OrderPlaced, order_id: 1)
|
|
231
|
+
assert_event_count(OrderPlaced, 1)
|
|
232
|
+
refute_event_published(OrderCancelled)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Outside `capture_events`, events are dispatched synchronously (test safety).
|
|
238
|
+
|
|
239
|
+
### Assertions
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
# Published
|
|
243
|
+
assert_event_published(EventClass) # any instance
|
|
244
|
+
assert_event_published(EventClass, prop: value) # with prop match
|
|
245
|
+
refute_event_published # nothing published
|
|
246
|
+
refute_event_published(EventClass) # specific class not published
|
|
247
|
+
|
|
248
|
+
# Count
|
|
249
|
+
assert_event_count(EventClass, 3)
|
|
250
|
+
|
|
251
|
+
# Tracing
|
|
252
|
+
assert_event_trace(parent_event, child_event) # caused_by_id match
|
|
253
|
+
assert_same_trace(event_a, event_b, event_c) # shared trace_id
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Suppression in Tests
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
def test_no_side_effects
|
|
260
|
+
Dex::Event.suppress do
|
|
261
|
+
CreateOrder.call(item_id: 1) # events suppressed
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Complete Test Example
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class CreateOrderTest < Minitest::Test
|
|
270
|
+
include Dex::Event::TestHelpers
|
|
271
|
+
|
|
272
|
+
def test_publishes_order_placed
|
|
273
|
+
capture_events do
|
|
274
|
+
order = CreateOrder.call(item_id: 1, quantity: 2)
|
|
275
|
+
|
|
276
|
+
assert_event_published(OrderPlaced, order_id: order.id)
|
|
277
|
+
assert_event_count(OrderPlaced, 1)
|
|
278
|
+
refute_event_published(OrderCancelled)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def test_trace_chain
|
|
283
|
+
capture_events do
|
|
284
|
+
parent = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
285
|
+
|
|
286
|
+
parent.trace do
|
|
287
|
+
InventoryReserved.publish(order_id: 1)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
child = _dex_published_events.last
|
|
291
|
+
assert_event_trace(parent, child)
|
|
292
|
+
assert_same_trace(parent, child)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
**End of reference.**
|
data/lib/dex/concern.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
class Bus
|
|
6
|
+
@_subscribers = {}
|
|
7
|
+
@_mutex = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def subscribe(event_class, handler_class)
|
|
11
|
+
Event.validate_event_class!(event_class)
|
|
12
|
+
unless handler_class.is_a?(Class) && handler_class < Dex::Event::Handler
|
|
13
|
+
raise ArgumentError, "#{handler_class.inspect} is not a Dex::Event::Handler subclass"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@_mutex.synchronize do
|
|
17
|
+
@_subscribers[event_class] ||= []
|
|
18
|
+
@_subscribers[event_class] |= [handler_class]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unsubscribe(event_class, handler_class)
|
|
23
|
+
@_mutex.synchronize do
|
|
24
|
+
list = @_subscribers[event_class]
|
|
25
|
+
return unless list
|
|
26
|
+
|
|
27
|
+
list.delete(handler_class)
|
|
28
|
+
@_subscribers.delete(event_class) if list.empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subscribers_for(event_class)
|
|
33
|
+
@_mutex.synchronize do
|
|
34
|
+
@_subscribers.each_with_object([]) do |(subscribed_class, handlers), result|
|
|
35
|
+
result.concat(handlers) if event_class <= subscribed_class
|
|
36
|
+
end.uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def subscribers
|
|
41
|
+
@_mutex.synchronize { @_subscribers.transform_values(&:dup) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def publish(event, sync:)
|
|
45
|
+
return if Suppression.suppressed?(event.class)
|
|
46
|
+
|
|
47
|
+
persist(event)
|
|
48
|
+
handlers = subscribers_for(event.class)
|
|
49
|
+
return if handlers.empty?
|
|
50
|
+
|
|
51
|
+
event_frame = event.trace_frame
|
|
52
|
+
|
|
53
|
+
handlers.each do |handler_class|
|
|
54
|
+
if sync
|
|
55
|
+
Trace.restore(event_frame) do
|
|
56
|
+
handler_class._event_handle(event)
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
enqueue(handler_class, event, event_frame)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear!
|
|
65
|
+
@_mutex.synchronize { @_subscribers.clear }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def persist(event)
|
|
71
|
+
store = Dex.configuration.event_store
|
|
72
|
+
return unless store
|
|
73
|
+
|
|
74
|
+
store.create!(
|
|
75
|
+
event_type: event.class.name,
|
|
76
|
+
payload: event._props_as_json,
|
|
77
|
+
metadata: event.metadata.as_json
|
|
78
|
+
)
|
|
79
|
+
rescue => e
|
|
80
|
+
Event._warn("Failed to persist event: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def enqueue(handler_class, event, trace_data)
|
|
84
|
+
ctx = event.context
|
|
85
|
+
|
|
86
|
+
Dex::Event::Processor.perform_later(
|
|
87
|
+
handler_class: handler_class.name,
|
|
88
|
+
event_class: event.class.name,
|
|
89
|
+
payload: event._props_as_json,
|
|
90
|
+
metadata: event.metadata.as_json,
|
|
91
|
+
trace: trace_data,
|
|
92
|
+
context: ctx
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module ExecutionState
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def _execution_state
|
|
9
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
|
10
|
+
ActiveSupport::IsolatedExecutionState
|
|
11
|
+
else
|
|
12
|
+
Thread.current
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
class Handler
|
|
6
|
+
attr_reader :event
|
|
7
|
+
|
|
8
|
+
def self.on(*event_classes)
|
|
9
|
+
event_classes.each do |ec|
|
|
10
|
+
Event.validate_event_class!(ec)
|
|
11
|
+
Bus.subscribe(ec, self)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.retries(count, **opts)
|
|
16
|
+
raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
|
|
17
|
+
|
|
18
|
+
if opts.key?(:wait)
|
|
19
|
+
wait = opts[:wait]
|
|
20
|
+
unless wait.is_a?(Numeric) || wait.is_a?(Proc)
|
|
21
|
+
raise ArgumentError, "wait: must be Numeric or Proc"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@_event_handler_retries = { count: count, **opts }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self._event_handler_retry_config
|
|
29
|
+
if defined?(@_event_handler_retries)
|
|
30
|
+
@_event_handler_retries
|
|
31
|
+
elsif superclass.respond_to?(:_event_handler_retry_config)
|
|
32
|
+
superclass._event_handler_retry_config
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self._event_handle(event)
|
|
37
|
+
instance = new
|
|
38
|
+
instance.instance_variable_set(:@event, event)
|
|
39
|
+
instance.perform
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
|
|
43
|
+
event_class = Object.const_get(event_class_name)
|
|
44
|
+
event = _event_reconstruct(event_class, payload, metadata_hash)
|
|
45
|
+
_event_handle(event)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def _event_reconstruct(event_class, payload, metadata_hash)
|
|
52
|
+
coerced = event_class.send(:_coerce_serialized_hash, payload)
|
|
53
|
+
instance = event_class.allocate
|
|
54
|
+
|
|
55
|
+
event_class.literal_properties.each do |prop|
|
|
56
|
+
instance.instance_variable_set(:"@#{prop.name}", coerced[prop.name])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
metadata = Event::Metadata.new(
|
|
60
|
+
id: metadata_hash["id"],
|
|
61
|
+
timestamp: Time.parse(metadata_hash["timestamp"]),
|
|
62
|
+
trace_id: metadata_hash["trace_id"],
|
|
63
|
+
caused_by_id: metadata_hash["caused_by_id"],
|
|
64
|
+
context: metadata_hash["context"]
|
|
65
|
+
)
|
|
66
|
+
instance.instance_variable_set(:@metadata, metadata)
|
|
67
|
+
instance.freeze
|
|
68
|
+
instance
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def perform
|
|
73
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dex
|
|
6
|
+
class Event
|
|
7
|
+
class Metadata
|
|
8
|
+
attr_reader :id, :timestamp, :trace_id, :caused_by_id, :context
|
|
9
|
+
|
|
10
|
+
def initialize(id:, timestamp:, trace_id:, caused_by_id:, context:)
|
|
11
|
+
@id = id
|
|
12
|
+
@timestamp = timestamp
|
|
13
|
+
@trace_id = trace_id
|
|
14
|
+
@caused_by_id = caused_by_id
|
|
15
|
+
@context = context
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.build(caused_by_id: nil)
|
|
20
|
+
id = SecureRandom.uuid
|
|
21
|
+
trace_id = Trace.current_trace_id || id
|
|
22
|
+
caused = caused_by_id || Trace.current_event_id
|
|
23
|
+
|
|
24
|
+
ctx = if Dex.configuration.event_context
|
|
25
|
+
begin
|
|
26
|
+
Dex.configuration.event_context.call
|
|
27
|
+
rescue => e
|
|
28
|
+
Event._warn("event_context failed: #{e.message}")
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(
|
|
34
|
+
id: id,
|
|
35
|
+
timestamp: Time.now.utc,
|
|
36
|
+
trace_id: trace_id,
|
|
37
|
+
caused_by_id: caused,
|
|
38
|
+
context: ctx
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def as_json
|
|
43
|
+
h = {
|
|
44
|
+
"id" => @id,
|
|
45
|
+
"timestamp" => @timestamp.iso8601(6),
|
|
46
|
+
"trace_id" => @trace_id
|
|
47
|
+
}
|
|
48
|
+
h["caused_by_id"] = @caused_by_id if @caused_by_id
|
|
49
|
+
h["context"] = @context if @context
|
|
50
|
+
h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|