ruby_reactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Async Reactors
|
|
2
|
+
|
|
3
|
+
RubyReactor supports two asynchronous execution models: **Full Reactor Async** and **Step-Level Async**. Both models use Sidekiq for background processing with non-blocking retry mechanisms.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Async execution provides several benefits:
|
|
8
|
+
|
|
9
|
+
- **Non-blocking**: Workers are freed during retry delays
|
|
10
|
+
- **Scalable**: Better resource utilization with large worker pools
|
|
11
|
+
- **Reliable**: Automatic retry with configurable backoff strategies
|
|
12
|
+
- **Observable**: Full visibility into execution state and retry attempts
|
|
13
|
+
|
|
14
|
+
## Full Reactor Async
|
|
15
|
+
|
|
16
|
+
When a reactor is marked as `async true`, the entire execution happens in a Sidekiq worker, including input validation.
|
|
17
|
+
|
|
18
|
+
### Configuration
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
22
|
+
async true # Enable full reactor async
|
|
23
|
+
|
|
24
|
+
step :validate_order do
|
|
25
|
+
run { validate_order_logic }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
step :process_payment do
|
|
29
|
+
run { process_payment_logic }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
step :send_confirmation do
|
|
33
|
+
run { send_confirmation_logic }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Execution Flow
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# Synchronous call returns immediately
|
|
42
|
+
async_result = OrderProcessingReactor.run(order_id: 123)
|
|
43
|
+
|
|
44
|
+
# Check status later
|
|
45
|
+
case async_result.status
|
|
46
|
+
when :pending
|
|
47
|
+
puts "Execution is queued"
|
|
48
|
+
when :running
|
|
49
|
+
puts "Execution is in progress"
|
|
50
|
+
when :success
|
|
51
|
+
puts "Execution completed successfully"
|
|
52
|
+
result = async_result.result
|
|
53
|
+
when :failed
|
|
54
|
+
puts "Execution failed: #{async_result.error}"
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Architecture
|
|
59
|
+
|
|
60
|
+
```mermaid
|
|
61
|
+
graph LR
|
|
62
|
+
A[Client] --> B[Reactor.run<br/>async: true]
|
|
63
|
+
B --> C[Validate Inputs<br/>Synchronously]
|
|
64
|
+
C --> D[Queue Sidekiq Job<br/>with Context]
|
|
65
|
+
D --> E[Sidekiq Worker]
|
|
66
|
+
E --> F[Deserialize Context]
|
|
67
|
+
F --> G[Execute All Steps<br/>Sequentially]
|
|
68
|
+
G --> H{Result?}
|
|
69
|
+
H -->|Success| I[Return Success]
|
|
70
|
+
H -->|Failure| J[Run Compensation<br/>in Worker]
|
|
71
|
+
J --> K[Return Failure]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Client → Reactor.run() → Queue Job → Sidekiq Worker → Execute All Steps
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Step-Level Async
|
|
79
|
+
|
|
80
|
+
Individual steps can be marked as `async: true`. Execution runs synchronously until the first async step, then hands off to a Sidekiq worker for all remaining execution.
|
|
81
|
+
|
|
82
|
+
### Configuration
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
86
|
+
step :validate_order do
|
|
87
|
+
# Runs synchronously
|
|
88
|
+
run { validate_order_logic }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
step :process_payment, async: true do
|
|
92
|
+
# First async step - triggers handoff to worker
|
|
93
|
+
run { process_payment_logic }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
step :update_inventory do
|
|
97
|
+
# Runs in worker after handoff
|
|
98
|
+
run { update_inventory_logic }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
step :send_confirmation do
|
|
102
|
+
# Runs in same worker
|
|
103
|
+
run { send_confirmation_logic }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Execution Flow
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Runs validate_order synchronously, then hands off
|
|
112
|
+
async_result = OrderProcessingReactor.run(order_id: 123)
|
|
113
|
+
|
|
114
|
+
# All remaining steps execute in a single worker
|
|
115
|
+
# Compensation and rollback work within the worker context
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Architecture
|
|
119
|
+
|
|
120
|
+
```mermaid
|
|
121
|
+
graph LR
|
|
122
|
+
A[Client] --> B[Reactor.run]
|
|
123
|
+
B --> C[Execute Sync Steps<br/>Until First Async]
|
|
124
|
+
C --> D{First Async<br/>Step Found?}
|
|
125
|
+
D -->|No| E[Execute All Steps<br/>Synchronously]
|
|
126
|
+
D -->|Yes| F[Queue Sidekiq Job<br/>with Context]
|
|
127
|
+
F --> G[Sidekiq Worker]
|
|
128
|
+
G --> H[Execute Remaining Steps<br/>Sequentially]
|
|
129
|
+
H --> I{Result?}
|
|
130
|
+
I -->|Success| J[Return Success]
|
|
131
|
+
I -->|Failure| K[Run Compensation<br/>in Worker]
|
|
132
|
+
K --> L[Return Failure]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
Client → Reactor.run() → Sync Steps → Queue Job → Worker → Remaining Steps
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Async Steps
|
|
140
|
+
|
|
141
|
+
Individual steps can be configured with `async true`, which changes the execution behavior at the point where the first async step is encountered.
|
|
142
|
+
|
|
143
|
+
### Key Behavior
|
|
144
|
+
|
|
145
|
+
When an async step is encountered during synchronous execution:
|
|
146
|
+
|
|
147
|
+
1. **All previous steps have already executed synchronously** in the main thread
|
|
148
|
+
2. **The Sidekiq job is queued** at the moment the async step would be executed
|
|
149
|
+
3. **The async step itself and all subsequent steps execute** in the Sidekiq worker
|
|
150
|
+
4. **The main thread returns immediately** with an async result handle
|
|
151
|
+
|
|
152
|
+
### Important Distinction
|
|
153
|
+
|
|
154
|
+
- **Before async step**: All execution is synchronous
|
|
155
|
+
- **At async step**: Job is queued instead of executing the step
|
|
156
|
+
- **After async step**: All remaining execution happens in the worker
|
|
157
|
+
|
|
158
|
+
### Example
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
162
|
+
step :validate_input do
|
|
163
|
+
# Executes synchronously in main thread
|
|
164
|
+
run { validate_order_input }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
step :check_inventory do
|
|
168
|
+
# Executes synchronously in main thread
|
|
169
|
+
run { check_inventory_levels }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
step :process_payment do
|
|
173
|
+
async true
|
|
174
|
+
# Job is queued here - this step executes in worker
|
|
175
|
+
run { process_payment_logic }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
step :update_inventory do
|
|
179
|
+
# Executes in worker
|
|
180
|
+
run { update_inventory_records }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
step :send_notification do
|
|
184
|
+
# Executes in worker
|
|
185
|
+
run { send_order_confirmation }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Execution Flow with Mermaid
|
|
191
|
+
|
|
192
|
+
```mermaid
|
|
193
|
+
sequenceDiagram
|
|
194
|
+
participant Client
|
|
195
|
+
participant Reactor
|
|
196
|
+
participant Sidekiq
|
|
197
|
+
participant Worker
|
|
198
|
+
|
|
199
|
+
Client->>Reactor: run(order_data)
|
|
200
|
+
Reactor->>Reactor: Execute validate_input (sync)
|
|
201
|
+
Reactor->>Reactor: Execute check_inventory (sync)
|
|
202
|
+
Reactor->>Reactor: Reach process_payment (async: true)
|
|
203
|
+
Reactor->>Sidekiq: Queue job with context
|
|
204
|
+
Reactor->>Client: Return AsyncResult (pending)
|
|
205
|
+
Sidekiq->>Worker: Process job
|
|
206
|
+
Worker->>Worker: Execute process_payment
|
|
207
|
+
Worker->>Worker: Execute update_inventory
|
|
208
|
+
Worker->>Worker: Execute send_notification
|
|
209
|
+
Worker->>Sidekiq: Mark job complete
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```mermaid
|
|
213
|
+
graph TD
|
|
214
|
+
A[Client Calls Reactor.run] --> B[Execute Previous Steps Synchronously]
|
|
215
|
+
B --> C[Encounter First Async Step]
|
|
216
|
+
C --> D[Queue Sidekiq Job<br/>with Current Context]
|
|
217
|
+
D --> E[Return AsyncResult to Client<br/>Status: :pending]
|
|
218
|
+
D --> F[Sidekiq Worker Receives Job]
|
|
219
|
+
F --> G[Deserialize Context]
|
|
220
|
+
G --> H[Execute Async Step<br/>and All Subsequent Steps]
|
|
221
|
+
H --> I{Execution<br/>Successful?}
|
|
222
|
+
I -->|Yes| J[Mark AsyncResult<br/>Status: :success]
|
|
223
|
+
I -->|No| K[Run Compensation<br/>in Worker Context]
|
|
224
|
+
K --> L[Mark AsyncResult<br/>Status: :failed]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Critical Points
|
|
228
|
+
|
|
229
|
+
- **Synchronous Prefix Guarantee**: Steps before the first async step always complete synchronously
|
|
230
|
+
- **Single Handoff Point**: Only one job is queued per reactor execution
|
|
231
|
+
- **Worker Execution**: The async step and all following steps run in the same worker
|
|
232
|
+
- **Context Preservation**: Execution state is serialized and passed to the worker
|
|
233
|
+
- **Compensation Scope**: All compensation for failed async execution happens in the worker
|
|
234
|
+
|
|
235
|
+
## Retry Configuration
|
|
236
|
+
|
|
237
|
+
Both async models support sophisticated retry mechanisms with non-blocking job requeuing.
|
|
238
|
+
|
|
239
|
+
### Step-Level Retry
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
class PaymentProcessingReactor < RubyReactor::Reactor
|
|
243
|
+
async true
|
|
244
|
+
|
|
245
|
+
step :validate_payment do
|
|
246
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 1.second
|
|
247
|
+
run { validate_payment_logic }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
step :charge_card, async: true do
|
|
251
|
+
retries max_attempts: 5, backoff: :linear, base_delay: 5.seconds
|
|
252
|
+
run { charge_card_logic }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
step :update_records do
|
|
256
|
+
# No retry - critical step
|
|
257
|
+
run { update_records_logic }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Reactor-Level Defaults
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
class PaymentProcessingReactor < RubyReactor::Reactor
|
|
266
|
+
async true
|
|
267
|
+
|
|
268
|
+
# Set defaults for all steps
|
|
269
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 2.seconds
|
|
270
|
+
|
|
271
|
+
step :validate_payment do
|
|
272
|
+
# Inherits reactor defaults (3 attempts, exponential backoff)
|
|
273
|
+
run { validate_payment_logic }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
step :charge_card do
|
|
277
|
+
# Override defaults for this step
|
|
278
|
+
retries max_attempts: 5, backoff: :linear, base_delay: 10.seconds
|
|
279
|
+
run { charge_card_logic }
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Retry Strategies
|
|
285
|
+
|
|
286
|
+
### Backoff Algorithms
|
|
287
|
+
|
|
288
|
+
- **`:exponential`**: Delay doubles with each attempt (1s, 2s, 4s, 8s...)
|
|
289
|
+
- **`:linear`**: Delay increases linearly (5s, 10s, 15s, 20s...)
|
|
290
|
+
- **`:fixed`**: Same delay for each attempt (5s, 5s, 5s, 5s...)
|
|
291
|
+
|
|
292
|
+
## Error Handling and Compensation
|
|
293
|
+
|
|
294
|
+
Async reactors support full compensation and rollback in the worker context:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
298
|
+
async true
|
|
299
|
+
|
|
300
|
+
step :process_payment do
|
|
301
|
+
run { process_payment_logic }
|
|
302
|
+
|
|
303
|
+
undo do |payment_id:, **|
|
|
304
|
+
# Runs in worker if execution fails later
|
|
305
|
+
PaymentService.refund(payment_id)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
compensate do |payment_id:, **|
|
|
309
|
+
# Runs in worker if execution fails later
|
|
310
|
+
PaymentService.refund(payment_id)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
step :update_inventory do
|
|
315
|
+
run { update_inventory_logic }
|
|
316
|
+
|
|
317
|
+
compensate do |order:, **|
|
|
318
|
+
# Runs in worker on failure
|
|
319
|
+
InventoryService.restore(order)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Monitoring and Observability
|
|
326
|
+
|
|
327
|
+
### Job Visibility
|
|
328
|
+
|
|
329
|
+
Retries are visible in Sidekiq web UI with:
|
|
330
|
+
- Step name and attempt number
|
|
331
|
+
- Retry delay and timing
|
|
332
|
+
- Success/failure status
|
|
333
|
+
- Execution context
|
|
334
|
+
|
|
335
|
+
## Configuration
|
|
336
|
+
|
|
337
|
+
### Sidekiq Worker Setup
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
# config/sidekiq.rb
|
|
341
|
+
require 'ruby_reactor/worker'
|
|
342
|
+
|
|
343
|
+
RubyReactor.configure do |config|
|
|
344
|
+
config.sidekiq_queue = :default
|
|
345
|
+
config.sidekiq_retry_count = 3
|
|
346
|
+
config.logger = Logger.new('log/ruby_reactor.log')
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Performance Considerations
|
|
351
|
+
|
|
352
|
+
### Worker Pool Sizing
|
|
353
|
+
|
|
354
|
+
- **Full Reactor Async**: Size pool based on total reactor throughput
|
|
355
|
+
- **Step-Level Async**: Size pool based on async step frequency
|
|
356
|
+
|
|
357
|
+
### Context Size Limits
|
|
358
|
+
|
|
359
|
+
- Redis has job size limits (~512MB)
|
|
360
|
+
- TODO: Large contexts are automatically compressed
|
|
361
|
+
- Consider external storage for very large execution states
|
|
362
|
+
|
|
363
|
+
### Monitoring Metrics
|
|
364
|
+
|
|
365
|
+
Track these key metrics:
|
|
366
|
+
- Retry attempt counts per step
|
|
367
|
+
- Average retry delays
|
|
368
|
+
- Success rates after retries
|
|
369
|
+
- Worker utilization during peak loads
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Composition
|
|
2
|
+
|
|
3
|
+
RubyReactor allows you to compose reactors within other reactors using the `compose` DSL. This enables you to build complex workflows by reusing existing reactors or defining sub-workflows inline.
|
|
4
|
+
|
|
5
|
+
## Inline Composition
|
|
6
|
+
|
|
7
|
+
You can define a composed reactor inline using a block. This is useful for grouping related steps or defining a sub-workflow that doesn't need to be reused elsewhere.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class UpdateUserReactor < RubyReactor::Reactor
|
|
11
|
+
input :user_id
|
|
12
|
+
input :profile_data
|
|
13
|
+
|
|
14
|
+
step :validate_user do
|
|
15
|
+
argument :user_id, input(:user_id)
|
|
16
|
+
run { |args| ... }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Define a sub-workflow inline
|
|
20
|
+
compose :update_profile do
|
|
21
|
+
# You can define inputs for the inline reactor
|
|
22
|
+
argument :user_id, input(:user_id)
|
|
23
|
+
argument :data, input(:profile_data)
|
|
24
|
+
|
|
25
|
+
# Configure async execution for the sub-workflow
|
|
26
|
+
async true
|
|
27
|
+
|
|
28
|
+
# Configure retries for steps within the sub-workflow
|
|
29
|
+
retries max_attempts: 3
|
|
30
|
+
|
|
31
|
+
step :update_bio do
|
|
32
|
+
argument :user_id, input(:user_id)
|
|
33
|
+
argument :bio, input(:data, :bio)
|
|
34
|
+
run { |args| ... }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
step :update_avatar do
|
|
38
|
+
argument :user_id, input(:user_id)
|
|
39
|
+
argument :avatar, input(:data, :avatar)
|
|
40
|
+
run { |args| ... }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
step :notify_user do
|
|
45
|
+
wait_for :update_profile
|
|
46
|
+
run { |args| ... }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Class-based Composition
|
|
52
|
+
|
|
53
|
+
You can also compose an existing reactor class. This is ideal for reusable workflows.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class ProfileUpdateReactor < RubyReactor::Reactor
|
|
57
|
+
input :user_id
|
|
58
|
+
input :data
|
|
59
|
+
|
|
60
|
+
step :update_bio do ... end
|
|
61
|
+
step :update_avatar do ... end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class MainReactor < RubyReactor::Reactor
|
|
65
|
+
input :user_id
|
|
66
|
+
input :profile_data
|
|
67
|
+
|
|
68
|
+
# Compose the existing reactor
|
|
69
|
+
compose :update_profile, ProfileUpdateReactor do
|
|
70
|
+
argument :user_id, input(:user_id)
|
|
71
|
+
argument :data, input(:profile_data)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Multiple Compose Declarations
|
|
77
|
+
|
|
78
|
+
A single reactor can include multiple `compose` declarations, allowing you to orchestrate several sub-workflows. You can mix both class-based and inline compositions, and combine them with regular steps.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
82
|
+
input :order_id
|
|
83
|
+
input :customer_data
|
|
84
|
+
input :payment_info
|
|
85
|
+
|
|
86
|
+
step :validate_order do
|
|
87
|
+
argument :order_id, input(:order_id)
|
|
88
|
+
run { |args| ... }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# First compose: Class-based reactor
|
|
92
|
+
compose :update_customer_profile, CustomerProfileReactor do
|
|
93
|
+
argument :customer_data, input(:customer_data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Second compose: Inline reactor
|
|
97
|
+
compose :process_payment do
|
|
98
|
+
argument :order_id, input(:order_id)
|
|
99
|
+
argument :payment_info, input(:payment_info)
|
|
100
|
+
|
|
101
|
+
async true # This sub-workflow can run async
|
|
102
|
+
|
|
103
|
+
step :authorize_payment do
|
|
104
|
+
run { |args| ... }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
step :capture_payment do
|
|
108
|
+
run { |args| ... }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Third compose: Another inline reactor
|
|
113
|
+
compose :allocate_inventory do
|
|
114
|
+
argument :order_id, input(:order_id)
|
|
115
|
+
argument :order, input(:validate_order)
|
|
116
|
+
|
|
117
|
+
step :check_availability do
|
|
118
|
+
run { |args| ... }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
step :reserve_items do
|
|
122
|
+
run { |args| ... }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
step :send_confirmation do
|
|
127
|
+
# Wait for all compose steps to complete
|
|
128
|
+
wait_for :update_customer_profile, :process_payment, :allocate_inventory
|
|
129
|
+
|
|
130
|
+
argument :customer_email, input(:customer_data)
|
|
131
|
+
argument :order_id, input(:order_id)
|
|
132
|
+
run { |args| ... }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Execution Flow
|
|
138
|
+
|
|
139
|
+
When you have multiple `compose` declarations:
|
|
140
|
+
|
|
141
|
+
1. **Execution Order**: Composed reactors execute in topological order based on their dependencies. Dependencies are determined automatically when you reference results from other steps (using `result(:step_name)`), or explicitly using `wait_for`. If no dependencies exist.
|
|
142
|
+
|
|
143
|
+
2. **Access Compose Results**: A compose step returns the final result of the composed reactor. By default, this is a hash containing all of its step results, unless the composed reactor uses the `returns` DSL to specify a custom return value:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
step :final_step do
|
|
147
|
+
# Get the complete result hash from the composed reactor
|
|
148
|
+
argument :payment_result, result(:process_payment)
|
|
149
|
+
|
|
150
|
+
run { |args|
|
|
151
|
+
# args[:payment_result] contains the full hash:
|
|
152
|
+
# { authorize_payment: ..., capture_payment: ... }
|
|
153
|
+
#
|
|
154
|
+
# args[:payment_status] contains just the capture_payment result
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
3. **Async Execution**: If a composed reactor is marked with `async true`, execution will pause at that compose step, serialize the entire reactor context, and queue a background job. The worker will resume execution from that compose step and continue sequentially through remaining steps. Only one worker executes the main reactor at a time.
|
|
160
|
+
|
|
161
|
+
4. **Shared Context**: All composed reactors share access to the parent reactor's inputs and results of previous steps and can be configured with different retry strategies.
|
|
162
|
+
|
|
163
|
+
### Async Compose Execution Flow
|
|
164
|
+
|
|
165
|
+
When you mark a compose as async:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
compose :process_payment do
|
|
169
|
+
async true
|
|
170
|
+
# ... steps
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The execution flow is:
|
|
175
|
+
|
|
176
|
+
1. Parent reactor executes steps up to `process_payment`
|
|
177
|
+
2. Serializes entire context and queues a background job
|
|
178
|
+
3. Returns `AsyncResult` to caller
|
|
179
|
+
4. Worker picks up job and resumes from `process_payment`
|
|
180
|
+
5. After `process_payment` completes, continues to next step sequentially
|
|
181
|
+
6. If another async step is encountered, the process repeats
|
|
182
|
+
|
|
183
|
+
This ensures proper ordering and state consistency across async boundaries.
|
|
184
|
+
|
|
185
|
+
## Nested Async Retries
|
|
186
|
+
|
|
187
|
+
One of the powerful features of composition in RubyReactor is the handling of asynchronous retries within nested reactors.
|
|
188
|
+
|
|
189
|
+
When a step inside a composed reactor fails and is configured to retry asynchronously (e.g., via Sidekiq), RubyReactor ensures that the entire execution context is preserved.
|
|
190
|
+
|
|
191
|
+
1. **Context Serialization**: The entire reactor tree, including the state of the parent reactor and the composed reactor, is serialized.
|
|
192
|
+
2. **Resume on Retry**: When the retry job executes, it resumes execution exactly from the failed step within the composed reactor.
|
|
193
|
+
3. **State Preservation**: All intermediate results and inputs from the parent reactor are available, ensuring that the composed reactor has everything it needs to complete.
|
|
194
|
+
|
|
195
|
+
This behavior works seamlessly whether you are using inline composition or class-based composition.
|
|
196
|
+
|
|
197
|
+
## Inspection
|
|
198
|
+
|
|
199
|
+
The execution state of composed reactors is stored in the parent context under `composed_contexts`. This allows for inspection of the full execution tree, which is useful for debugging and building monitoring tools.
|