ruby_reactor 0.3.2 → 0.4.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-config.json +15 -0
  3. data/.release-please-manifest.json +3 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +13 -0
  6. data/README.md +80 -4
  7. data/lib/ruby_reactor/context_serializer.rb +10 -1
  8. data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
  9. data/lib/ruby_reactor/rate_limit.rb +2 -2
  10. data/lib/ruby_reactor/sidekiq_workers/worker.rb +58 -1
  11. data/lib/ruby_reactor/version.rb +1 -1
  12. metadata +7 -52
  13. data/documentation/DAG.md +0 -457
  14. data/documentation/README.md +0 -135
  15. data/documentation/async_reactors.md +0 -381
  16. data/documentation/composition.md +0 -199
  17. data/documentation/core_concepts.md +0 -676
  18. data/documentation/data_pipelines.md +0 -230
  19. data/documentation/examples/inventory_management.md +0 -748
  20. data/documentation/examples/order_processing.md +0 -380
  21. data/documentation/examples/payment_processing.md +0 -565
  22. data/documentation/getting_started.md +0 -242
  23. data/documentation/images/failed_order_processing.png +0 -0
  24. data/documentation/images/payment_workflow.png +0 -0
  25. data/documentation/interrupts.md +0 -163
  26. data/documentation/locks_and_semaphores.md +0 -459
  27. data/documentation/retry_configuration.md +0 -362
  28. data/documentation/testing.md +0 -994
  29. data/gui/.gitignore +0 -24
  30. data/gui/README.md +0 -73
  31. data/gui/eslint.config.js +0 -23
  32. data/gui/index.html +0 -13
  33. data/gui/package-lock.json +0 -5925
  34. data/gui/package.json +0 -46
  35. data/gui/postcss.config.js +0 -6
  36. data/gui/public/vite.svg +0 -1
  37. data/gui/src/App.css +0 -42
  38. data/gui/src/App.tsx +0 -51
  39. data/gui/src/assets/react.svg +0 -1
  40. data/gui/src/components/DagVisualizer.tsx +0 -424
  41. data/gui/src/components/Dashboard.tsx +0 -163
  42. data/gui/src/components/ErrorBoundary.tsx +0 -47
  43. data/gui/src/components/ReactorDetail.tsx +0 -135
  44. data/gui/src/components/StepInspector.tsx +0 -492
  45. data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
  46. data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
  47. data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
  48. data/gui/src/globals.d.ts +0 -7
  49. data/gui/src/index.css +0 -14
  50. data/gui/src/lib/utils.ts +0 -13
  51. data/gui/src/main.tsx +0 -14
  52. data/gui/src/test/setup.ts +0 -11
  53. data/gui/tailwind.config.js +0 -11
  54. data/gui/tsconfig.app.json +0 -28
  55. data/gui/tsconfig.json +0 -7
  56. data/gui/tsconfig.node.json +0 -26
  57. data/gui/vite.config.ts +0 -8
  58. data/gui/vitest.config.ts +0 -13
@@ -1,381 +0,0 @@
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
- async_result.execution_id # UUID for reloading state
45
- async_result.intermediate_results # Whatever was computed before handoff
46
-
47
- # Inspect status later by reloading from storage
48
- reactor = OrderProcessingReactor.find(async_result.execution_id)
49
- case reactor.context.status.to_s
50
- when "running" then puts "Execution is in progress"
51
- when "completed" then puts "Done: #{reactor.result.value}"
52
- when "failed" then puts "Failed: #{reactor.result.error}"
53
- when "paused" then puts "Waiting on interrupt"
54
- end
55
- ```
56
-
57
- > **Note:** `AsyncResult` itself does not poll. It only carries the job handle and any results computed before handoff (`job_id`, `execution_id`, `intermediate_results`). To check progress, reload via `Reactor.find(execution_id)` and inspect `context.status`, or use the web dashboard.
58
-
59
- ### Architecture
60
-
61
- ```mermaid
62
- graph LR
63
- A[Client] --> B[Reactor.run<br/>async: true]
64
- B --> C[Validate Inputs<br/>Synchronously]
65
- C --> D[Queue Sidekiq Job<br/>with Context]
66
- D --> E[Sidekiq Worker]
67
- E --> F[Deserialize Context]
68
- F --> G[Execute All Steps<br/>Sequentially]
69
- G --> H{Result?}
70
- H -->|Success| I[Return Success]
71
- H -->|Failure| J[Run Compensation<br/>in Worker]
72
- J --> K[Return Failure]
73
- ```
74
-
75
- ```
76
- Client → Reactor.run() → Queue Job → Sidekiq Worker → Execute All Steps
77
- ```
78
-
79
- ## Step-Level Async
80
-
81
- 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.
82
-
83
- ### Configuration
84
-
85
- ```ruby
86
- class OrderProcessingReactor < RubyReactor::Reactor
87
- step :validate_order do
88
- # Runs synchronously
89
- run { |args, _ctx| validate_order_logic(args) }
90
- end
91
-
92
- step :process_payment do
93
- async true # First async step — triggers handoff to worker
94
- run { |args, _ctx| process_payment_logic(args) }
95
- end
96
-
97
- step :update_inventory do
98
- # Runs in worker after handoff
99
- run { |args, _ctx| update_inventory_logic(args) }
100
- end
101
-
102
- step :send_confirmation do
103
- # Runs in same worker
104
- run { |args, _ctx| send_confirmation_logic(args) }
105
- end
106
- end
107
- ```
108
-
109
- > **DSL note:** `async` is declared inside the step block (`async true`), not as a keyword on `step :name, async: true`. The `step` method only accepts a step name and an optional implementation class as positional arguments.
110
-
111
- ### Execution Flow
112
-
113
- ```ruby
114
- # Runs validate_order synchronously, then hands off
115
- async_result = OrderProcessingReactor.run(order_id: 123)
116
-
117
- # All remaining steps execute in a single worker
118
- # Compensation and rollback work within the worker context
119
- ```
120
-
121
- ### Architecture
122
-
123
- ```mermaid
124
- graph LR
125
- A[Client] --> B[Reactor.run]
126
- B --> C[Execute Sync Steps<br/>Until First Async]
127
- C --> D{First Async<br/>Step Found?}
128
- D -->|No| E[Execute All Steps<br/>Synchronously]
129
- D -->|Yes| F[Queue Sidekiq Job<br/>with Context]
130
- F --> G[Sidekiq Worker]
131
- G --> H[Execute Remaining Steps<br/>Sequentially]
132
- H --> I{Result?}
133
- I -->|Success| J[Return Success]
134
- I -->|Failure| K[Run Compensation<br/>in Worker]
135
- K --> L[Return Failure]
136
- ```
137
-
138
- ```
139
- Client → Reactor.run() → Sync Steps → Queue Job → Worker → Remaining Steps
140
- ```
141
-
142
- ## Async Steps
143
-
144
- Individual steps can be configured with `async true`, which changes the execution behavior at the point where the first async step is encountered.
145
-
146
- ### Key Behavior
147
-
148
- When an async step is encountered during synchronous execution:
149
-
150
- 1. **All previous steps have already executed synchronously** in the main thread
151
- 2. **The Sidekiq job is queued** at the moment the async step would be executed
152
- 3. **The async step itself and all subsequent steps execute** in the Sidekiq worker
153
- 4. **The main thread returns immediately** with an async result handle
154
-
155
- ### Important Distinction
156
-
157
- - **Before async step**: All execution is synchronous
158
- - **At async step**: Job is queued instead of executing the step
159
- - **After async step**: All remaining execution happens in the worker
160
-
161
- ### Example
162
-
163
- ```ruby
164
- class OrderProcessingReactor < RubyReactor::Reactor
165
- step :validate_input do
166
- # Executes synchronously in main thread
167
- run { validate_order_input }
168
- end
169
-
170
- step :check_inventory do
171
- # Executes synchronously in main thread
172
- run { check_inventory_levels }
173
- end
174
-
175
- step :process_payment do
176
- async true
177
- # Job is queued here - this step executes in worker
178
- run { process_payment_logic }
179
- end
180
-
181
- step :update_inventory do
182
- # Executes in worker
183
- run { update_inventory_records }
184
- end
185
-
186
- step :send_notification do
187
- # Executes in worker
188
- run { send_order_confirmation }
189
- end
190
- end
191
- ```
192
-
193
- ### Execution Flow with Mermaid
194
-
195
- ```mermaid
196
- sequenceDiagram
197
- participant Client
198
- participant Reactor
199
- participant Sidekiq
200
- participant Worker
201
-
202
- Client->>Reactor: run(order_data)
203
- Reactor->>Reactor: Execute validate_input (sync)
204
- Reactor->>Reactor: Execute check_inventory (sync)
205
- Reactor->>Reactor: Reach process_payment (async: true)
206
- Reactor->>Sidekiq: Queue job with context
207
- Reactor->>Client: Return AsyncResult (pending)
208
- Sidekiq->>Worker: Process job
209
- Worker->>Worker: Execute process_payment
210
- Worker->>Worker: Execute update_inventory
211
- Worker->>Worker: Execute send_notification
212
- Worker->>Sidekiq: Mark job complete
213
- ```
214
-
215
- ```mermaid
216
- graph TD
217
- A[Client Calls Reactor.run] --> B[Execute Previous Steps Synchronously]
218
- B --> C[Encounter First Async Step]
219
- C --> D[Queue Sidekiq Job<br/>with Current Context]
220
- D --> E[Return AsyncResult to Client<br/>Status: :pending]
221
- D --> F[Sidekiq Worker Receives Job]
222
- F --> G[Deserialize Context]
223
- G --> H[Execute Async Step<br/>and All Subsequent Steps]
224
- H --> I{Execution<br/>Successful?}
225
- I -->|Yes| J[Mark AsyncResult<br/>Status: :success]
226
- I -->|No| K[Run Compensation<br/>in Worker Context]
227
- K --> L[Mark AsyncResult<br/>Status: :failed]
228
- ```
229
-
230
- ### Critical Points
231
-
232
- - **Synchronous Prefix Guarantee**: Steps before the first async step always complete synchronously
233
- - **Single Handoff Point**: Only one job is queued per reactor execution
234
- - **Worker Execution**: The async step and all following steps run in the same worker
235
- - **Context Preservation**: Execution state is serialized and passed to the worker
236
- - **Compensation Scope**: All compensation for failed async execution happens in the worker
237
-
238
- ## Retry Configuration
239
-
240
- Both async models support sophisticated retry mechanisms with non-blocking job requeuing.
241
-
242
- ### Step-Level Retry
243
-
244
- ```ruby
245
- class PaymentProcessingReactor < RubyReactor::Reactor
246
- async true
247
-
248
- step :validate_payment do
249
- retries max_attempts: 3, backoff: :exponential, base_delay: 1.second
250
- run { |args, _ctx| validate_payment_logic(args) }
251
- end
252
-
253
- step :charge_card do
254
- async true
255
- retries max_attempts: 5, backoff: :linear, base_delay: 5.seconds
256
- run { |args, _ctx| charge_card_logic(args) }
257
- end
258
-
259
- step :update_records do
260
- # No retry - critical step
261
- run { |args, _ctx| update_records_logic(args) }
262
- end
263
- end
264
- ```
265
-
266
- ### Reactor-Level Defaults
267
-
268
- ```ruby
269
- class PaymentProcessingReactor < RubyReactor::Reactor
270
- async true
271
-
272
- # Set defaults for all steps
273
- retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 2.seconds
274
-
275
- step :validate_payment do
276
- # Inherits reactor defaults (3 attempts, exponential backoff)
277
- run { validate_payment_logic }
278
- end
279
-
280
- step :charge_card do
281
- # Override defaults for this step
282
- retries max_attempts: 5, backoff: :linear, base_delay: 10.seconds
283
- run { charge_card_logic }
284
- end
285
- end
286
- ```
287
-
288
- ## Retry Strategies
289
-
290
- ### Backoff Algorithms
291
-
292
- - **`:exponential`**: Delay doubles with each attempt (1s, 2s, 4s, 8s...)
293
- - **`:linear`**: Delay increases linearly (5s, 10s, 15s, 20s...)
294
- - **`:fixed`**: Same delay for each attempt (5s, 5s, 5s, 5s...)
295
-
296
- ## Error Handling and Compensation
297
-
298
- Async reactors support full compensation and rollback in the worker context:
299
-
300
- ```ruby
301
- class OrderProcessingReactor < RubyReactor::Reactor
302
- async true
303
-
304
- step :process_payment do
305
- argument :order, result(:validate_order)
306
- run { |args, _ctx| process_payment_logic(args[:order]) }
307
-
308
- undo do |result, _args, _ctx|
309
- # Runs in worker when a LATER step fails
310
- PaymentService.refund(result[:payment_id])
311
- Success()
312
- end
313
-
314
- compensate do |error, args, _ctx|
315
- # Runs in worker if THIS step fails
316
- AuditService.log_payment_failure(args[:order].id, error.message)
317
- Success()
318
- end
319
- end
320
-
321
- step :update_inventory do
322
- argument :order, result(:validate_order)
323
- run { |args, _ctx| update_inventory_logic(args[:order]) }
324
-
325
- compensate do |_error, args, _ctx|
326
- # Runs in worker on failure
327
- InventoryService.restore(args[:order])
328
- Success()
329
- end
330
- end
331
- end
332
- ```
333
-
334
- ## Monitoring and Observability
335
-
336
- ### Job Visibility
337
-
338
- Retries are visible in Sidekiq web UI with:
339
- - Step name and attempt number
340
- - Retry delay and timing
341
- - Success/failure status
342
- - Execution context
343
-
344
- ## Configuration
345
-
346
- ### Sidekiq Worker Setup
347
-
348
- ```ruby
349
- # config/initializers/ruby_reactor.rb (Rails) or load before booting Sidekiq
350
- RubyReactor.configure do |config|
351
- config.storage.adapter = :redis
352
- config.storage.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
353
-
354
- config.sidekiq_queue = :default
355
- config.sidekiq_retry_count = 3
356
- config.logger = Logger.new('log/ruby_reactor.log')
357
- end
358
- ```
359
-
360
- Sidekiq workers are loaded automatically via Zeitwerk when `ruby_reactor` is required — no extra `require` is needed.
361
-
362
- ## Performance Considerations
363
-
364
- ### Worker Pool Sizing
365
-
366
- - **Full Reactor Async**: Size pool based on total reactor throughput
367
- - **Step-Level Async**: Size pool based on async step frequency
368
-
369
- ### Context Size Limits
370
-
371
- - Redis has job size limits (~512MB)
372
- - TODO: Large contexts are automatically compressed
373
- - Consider external storage for very large execution states
374
-
375
- ### Monitoring Metrics
376
-
377
- Track these key metrics:
378
- - Retry attempt counts per step
379
- - Average retry delays
380
- - Success rates after retries
381
- - Worker utilization during peak loads
@@ -1,199 +0,0 @@
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.