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.
- checksums.yaml +4 -4
- data/.release-please-config.json +15 -0
- data/.release-please-manifest.json +3 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +13 -0
- data/README.md +80 -4
- data/lib/ruby_reactor/context_serializer.rb +10 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
- data/lib/ruby_reactor/rate_limit.rb +2 -2
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +58 -1
- data/lib/ruby_reactor/version.rb +1 -1
- metadata +7 -52
- data/documentation/DAG.md +0 -457
- data/documentation/README.md +0 -135
- data/documentation/async_reactors.md +0 -381
- data/documentation/composition.md +0 -199
- data/documentation/core_concepts.md +0 -676
- data/documentation/data_pipelines.md +0 -230
- data/documentation/examples/inventory_management.md +0 -748
- data/documentation/examples/order_processing.md +0 -380
- data/documentation/examples/payment_processing.md +0 -565
- data/documentation/getting_started.md +0 -242
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +0 -163
- data/documentation/locks_and_semaphores.md +0 -459
- data/documentation/retry_configuration.md +0 -362
- data/documentation/testing.md +0 -994
- data/gui/.gitignore +0 -24
- data/gui/README.md +0 -73
- data/gui/eslint.config.js +0 -23
- data/gui/index.html +0 -13
- data/gui/package-lock.json +0 -5925
- data/gui/package.json +0 -46
- data/gui/postcss.config.js +0 -6
- data/gui/public/vite.svg +0 -1
- data/gui/src/App.css +0 -42
- data/gui/src/App.tsx +0 -51
- data/gui/src/assets/react.svg +0 -1
- data/gui/src/components/DagVisualizer.tsx +0 -424
- data/gui/src/components/Dashboard.tsx +0 -163
- data/gui/src/components/ErrorBoundary.tsx +0 -47
- data/gui/src/components/ReactorDetail.tsx +0 -135
- data/gui/src/components/StepInspector.tsx +0 -492
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
- data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
- data/gui/src/globals.d.ts +0 -7
- data/gui/src/index.css +0 -14
- data/gui/src/lib/utils.ts +0 -13
- data/gui/src/main.tsx +0 -14
- data/gui/src/test/setup.ts +0 -11
- data/gui/tailwind.config.js +0 -11
- data/gui/tsconfig.app.json +0 -28
- data/gui/tsconfig.json +0 -7
- data/gui/tsconfig.node.json +0 -26
- data/gui/vite.config.ts +0 -8
- data/gui/vitest.config.ts +0 -13
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
# Getting Started with RubyReactor
|
|
2
|
-
|
|
3
|
-
This guide walks you through installation, configuration, and your first reactor.
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
Add RubyReactor to your Gemfile:
|
|
8
|
-
|
|
9
|
-
```ruby
|
|
10
|
-
gem 'ruby_reactor'
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Or install directly:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
gem install ruby_reactor
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Configuration
|
|
20
|
-
|
|
21
|
-
RubyReactor uses Redis for state persistence and Sidekiq for async execution. Configure both before running any reactors:
|
|
22
|
-
|
|
23
|
-
```ruby
|
|
24
|
-
RubyReactor.configure do |config|
|
|
25
|
-
# Redis configuration for state persistence
|
|
26
|
-
config.storage.adapter = :redis
|
|
27
|
-
config.storage.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
|
|
28
|
-
config.storage.redis_options = { timeout: 1 }
|
|
29
|
-
|
|
30
|
-
# Sidekiq configuration for async execution
|
|
31
|
-
config.sidekiq_queue = :default
|
|
32
|
-
config.sidekiq_retry_count = 3
|
|
33
|
-
|
|
34
|
-
# Logger
|
|
35
|
-
config.logger = Logger.new($stdout)
|
|
36
|
-
end
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## Your First Reactor
|
|
40
|
-
|
|
41
|
-
Reactors are subclasses of `RubyReactor::Reactor`. Declare `input`s, define `step`s with their `argument`s and `run` block, and optionally a `returns` step:
|
|
42
|
-
|
|
43
|
-
```ruby
|
|
44
|
-
require 'ruby_reactor'
|
|
45
|
-
|
|
46
|
-
class OrderProcessingReactor < RubyReactor::Reactor
|
|
47
|
-
input :order_id do
|
|
48
|
-
required(:order_id).filled(:integer, gt?: 0)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
step :validate_order do
|
|
52
|
-
argument :order_id, input(:order_id)
|
|
53
|
-
|
|
54
|
-
run do |args, _context|
|
|
55
|
-
order = Order.find_by(id: args[:order_id])
|
|
56
|
-
return Failure("Order not found") unless order
|
|
57
|
-
return Failure("Order already processed") if order.processed?
|
|
58
|
-
|
|
59
|
-
Success({ order: order })
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
step :process_payment do
|
|
64
|
-
argument :order, result(:validate_order, :order)
|
|
65
|
-
|
|
66
|
-
run do |args, _context|
|
|
67
|
-
payment = PaymentService.charge(args[:order].total, args[:order].customer.card_token)
|
|
68
|
-
payment.success? ? Success({ payment_id: payment.id }) : Failure("Payment failed")
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
step :update_inventory do
|
|
73
|
-
argument :order, result(:validate_order, :order)
|
|
74
|
-
|
|
75
|
-
run do |args, _context|
|
|
76
|
-
args[:order].items.each do |item|
|
|
77
|
-
InventoryService.decrement(item.product_id, item.quantity)
|
|
78
|
-
end
|
|
79
|
-
Success({ inventory_updated: true })
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
step :send_confirmation do
|
|
84
|
-
argument :order, result(:validate_order, :order)
|
|
85
|
-
argument :payment_id, result(:process_payment, :payment_id)
|
|
86
|
-
|
|
87
|
-
run do |args, _context|
|
|
88
|
-
EmailService.send_confirmation(args[:order].customer.email, order: args[:order])
|
|
89
|
-
Success({ confirmation_sent: true })
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
returns :send_confirmation
|
|
94
|
-
end
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Run blocks always receive `(arguments, context)`
|
|
98
|
-
|
|
99
|
-
Every step's `run` block receives two positional arguments: the resolved arguments hash and the execution context. Use `argument :name, source` to declare which value goes into `args[:name]`.
|
|
100
|
-
|
|
101
|
-
## Executing a Reactor
|
|
102
|
-
|
|
103
|
-
### Synchronous Execution
|
|
104
|
-
|
|
105
|
-
```ruby
|
|
106
|
-
result = OrderProcessingReactor.run(order_id: 123)
|
|
107
|
-
|
|
108
|
-
if result.success?
|
|
109
|
-
puts "Order processed: #{result.value}"
|
|
110
|
-
else
|
|
111
|
-
puts "Order processing failed: #{result.error}"
|
|
112
|
-
end
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
`Reactor.run` returns one of:
|
|
116
|
-
|
|
117
|
-
- `RubyReactor::Success` — `result.success?` is `true`, `result.value` holds the step output for `returns` (or the full `intermediate_results` hash if no `returns` is set).
|
|
118
|
-
- `RubyReactor::Failure` — `result.failure?` is `true`. Useful readers: `result.error`, `result.step_name`, `result.exception_class`, `result.backtrace`, `result.step_arguments`.
|
|
119
|
-
- `RubyReactor::AsyncResult` — returned when the reactor (or a step) is async. Holds `job_id`, `execution_id`, and any `intermediate_results` available at handoff.
|
|
120
|
-
- `RubyReactor::InterruptResult` — returned when an `interrupt` step pauses execution. Use `result.execution_id` and `result.correlation_id` to resume later.
|
|
121
|
-
|
|
122
|
-
### Asynchronous Execution
|
|
123
|
-
|
|
124
|
-
For async execution, configure Sidekiq and either mark the reactor `async true` or mark individual steps async:
|
|
125
|
-
|
|
126
|
-
```ruby
|
|
127
|
-
class OrderProcessingReactor < RubyReactor::Reactor
|
|
128
|
-
async true # Entire reactor runs in a Sidekiq worker
|
|
129
|
-
|
|
130
|
-
# ... steps defined above
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
async_result = OrderProcessingReactor.run(order_id: 123)
|
|
134
|
-
async_result.execution_id # => UUID for looking up state later
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
To inspect a running execution, reload it from storage:
|
|
138
|
-
|
|
139
|
-
```ruby
|
|
140
|
-
reactor = OrderProcessingReactor.find(async_result.execution_id)
|
|
141
|
-
reactor.context.status # => "running" | "completed" | "failed" | "paused"
|
|
142
|
-
reactor.result # Success / Failure / InterruptResult
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
See [Async Reactors](async_reactors.md) for the full async model.
|
|
146
|
-
|
|
147
|
-
## Inspecting the Context
|
|
148
|
-
|
|
149
|
-
Step outputs are stored on the context, not the result object. After a sync execution you can reach them via the reactor instance:
|
|
150
|
-
|
|
151
|
-
```ruby
|
|
152
|
-
reactor = OrderProcessingReactor.new
|
|
153
|
-
reactor.run(order_id: 123)
|
|
154
|
-
|
|
155
|
-
reactor.context.intermediate_results[:validate_order] # => { order: <Order> }
|
|
156
|
-
reactor.context.intermediate_results[:process_payment] # => { payment_id: "pay_123" }
|
|
157
|
-
reactor.context.status # => "completed"
|
|
158
|
-
reactor.execution_trace # => [{ type: :run, step: :validate_order, ... }, ...]
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
For black-box assertions in tests, use the `test_reactor` helper described in [Testing with RSpec](testing.md).
|
|
162
|
-
|
|
163
|
-
## Step Dependencies
|
|
164
|
-
|
|
165
|
-
Steps depend on each other through `argument :name, result(:other_step)`. The dependency graph topologically sorts steps; circular dependencies raise `RubyReactor::Error::DependencyError`:
|
|
166
|
-
|
|
167
|
-
```ruby
|
|
168
|
-
class ComplexReactor < RubyReactor::Reactor
|
|
169
|
-
step :validate_order do
|
|
170
|
-
run { |args, _ctx| validate_order_logic(args) }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
step :check_inventory do
|
|
174
|
-
argument :order, result(:validate_order)
|
|
175
|
-
|
|
176
|
-
run do |args, _context|
|
|
177
|
-
check_inventory_for_order(args[:order])
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
step :process_payment do
|
|
182
|
-
argument :order, result(:check_inventory)
|
|
183
|
-
|
|
184
|
-
run do |args, _context|
|
|
185
|
-
process_payment_for_order(args[:order])
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
You can also declare order without data flow using `wait_for :step_name`.
|
|
192
|
-
|
|
193
|
-
## Error Handling and Compensation
|
|
194
|
-
|
|
195
|
-
When a step fails (returns `Failure(...)` or raises), the reactor:
|
|
196
|
-
|
|
197
|
-
1. Runs the **`compensate`** block of the failing step (signature: `|error, arguments, context|`).
|
|
198
|
-
2. Walks back through previously successful steps and runs each one's **`undo`** block in reverse order (signature: `|result, arguments, context|`).
|
|
199
|
-
3. Returns a `Failure`.
|
|
200
|
-
|
|
201
|
-
```ruby
|
|
202
|
-
class OrderProcessingReactor < RubyReactor::Reactor
|
|
203
|
-
step :process_payment do
|
|
204
|
-
argument :order, result(:validate_order)
|
|
205
|
-
|
|
206
|
-
run { |args, _ctx| PaymentService.charge(args[:order]) }
|
|
207
|
-
|
|
208
|
-
undo do |result, _args, _ctx|
|
|
209
|
-
# Runs if a later step fails
|
|
210
|
-
PaymentService.refund(result[:payment_id])
|
|
211
|
-
Success()
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
step :update_inventory do
|
|
216
|
-
argument :order, result(:validate_order)
|
|
217
|
-
|
|
218
|
-
run { |args, _ctx| InventoryService.decrement_all(args[:order].items) }
|
|
219
|
-
|
|
220
|
-
compensate do |_error, args, _ctx|
|
|
221
|
-
# Runs only if THIS step fails
|
|
222
|
-
args[:order].items.each { |i| InventoryService.increment(i.product_id, i.quantity) }
|
|
223
|
-
Success()
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
If `update_inventory` fails: its `compensate` runs, then `process_payment`'s `undo` runs.
|
|
230
|
-
|
|
231
|
-
See [Core Concepts](core_concepts.md#compensation) for the full compensation/undo model.
|
|
232
|
-
|
|
233
|
-
## Next Steps
|
|
234
|
-
|
|
235
|
-
- [Core Concepts](core_concepts.md) — Reactors, Steps, Context, Results
|
|
236
|
-
- [Async Reactors](async_reactors.md) — Full and step-level async execution
|
|
237
|
-
- [Retry Configuration](retry_configuration.md) — Backoff strategies and retry policies
|
|
238
|
-
- [Interrupts](interrupts.md) — Pause/resume workflows
|
|
239
|
-
- [Composition](composition.md) — Build complex flows from smaller reactors
|
|
240
|
-
- [Data Pipelines](data_pipelines.md) — Map over collections in parallel
|
|
241
|
-
- [Testing with RSpec](testing.md) — `test_reactor`, mocks, matchers
|
|
242
|
-
- [Examples](examples/) — End-to-end workflows
|
|
Binary file
|
|
Binary file
|
data/documentation/interrupts.md
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
# Interrupts (Pause & Resume)
|
|
2
|
-
|
|
3
|
-
RubyReactor introduces the `interrupt` mechanism to support long-running processes that require external input, such as user approvals, webhooks, or asynchronous job completions. Unlike standard steps that execute immediately, an `interrupt` pauses the reactor execution and persists its state, waiting for a signal to resume.
|
|
4
|
-
|
|
5
|
-
## DSL Usage
|
|
6
|
-
|
|
7
|
-
Use the `interrupt` keyword to define a pause point in your reactor.
|
|
8
|
-
|
|
9
|
-
```ruby
|
|
10
|
-
class ReportReactor < RubyReactor::Reactor
|
|
11
|
-
step :request_report do
|
|
12
|
-
run do |_args, _ctx|
|
|
13
|
-
response = HTTP.post("https://api.example.com/reports")
|
|
14
|
-
Success(response.fetch(:id))
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
interrupt :wait_for_report do
|
|
19
|
-
# Declare dependency: execution must trigger this interrupt only after :request_report succeeds
|
|
20
|
-
wait_for :request_report
|
|
21
|
-
|
|
22
|
-
# Optional: deterministic correlation ID for looking up this execution later
|
|
23
|
-
correlation_id do |context|
|
|
24
|
-
"report-#{context.result(:request_report)}"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Optional: timeout in seconds
|
|
28
|
-
# Strategies:
|
|
29
|
-
# - :lazy (default) - checked only when resume is attempted
|
|
30
|
-
# - :active - schedules a background job to wake up the reactor and fail it
|
|
31
|
-
timeout 1800, strategy: :active
|
|
32
|
-
|
|
33
|
-
# Optional: validate incoming payload immediately using dry-validation
|
|
34
|
-
validate do
|
|
35
|
-
required(:status).filled(:string)
|
|
36
|
-
required(:url).filled(:string)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Optional: limit validation attempts (default: 1)
|
|
40
|
-
# If exhausted, the reactor is cancelled and compensated.
|
|
41
|
-
# Use :infinity for unlimited attempts.
|
|
42
|
-
max_attempts 3
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
step :process_report do
|
|
46
|
-
# The result of the interrupt step is the payload provided when resuming
|
|
47
|
-
argument :webhook_payload, result(:wait_for_report)
|
|
48
|
-
|
|
49
|
-
run do |args, _ctx|
|
|
50
|
-
Success(ReportProcessor.call(args[:webhook_payload]))
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### Options
|
|
57
|
-
|
|
58
|
-
* **`wait_for`**: declare dependencies similar to `step`.
|
|
59
|
-
* **`correlation_id`**: A block that returns a unique string to identify this execution. This allows you to resume the reactor using a business key (e.g., order ID) instead of the internal execution UUID.
|
|
60
|
-
* **`timeout`**: Set a time limit for the interrupt.
|
|
61
|
-
* **`validate`**: A `dry-validation` schema block to validate the payload provided when resuming.
|
|
62
|
-
* **`max_attempts`**: Limit the number of times `continue` can be called with an invalid payload before the reactor is automatically cancelled and compensated. Defaults to 1. Set to `:infinity` for unlimited retries.
|
|
63
|
-
|
|
64
|
-
## Runtime Behavior
|
|
65
|
-
|
|
66
|
-
When a reactor encounters an `interrupt`:
|
|
67
|
-
|
|
68
|
-
1. It executes any dependencies.
|
|
69
|
-
2. It persists the full `Context` (results of previous steps) to the configured storage (e.g., Redis).
|
|
70
|
-
3. It returns an `InterruptResult` and halts execution.
|
|
71
|
-
|
|
72
|
-
```ruby
|
|
73
|
-
execution = ReportReactor.run(company_id: 1)
|
|
74
|
-
|
|
75
|
-
if execution.paused?
|
|
76
|
-
execution.execution_id # => "uuid-123"
|
|
77
|
-
execution.correlation_id # => "report-..." (if defined)
|
|
78
|
-
execution.status # => :paused
|
|
79
|
-
end
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
## Resuming Execution
|
|
83
|
-
|
|
84
|
-
You can resume a paused reactor using its UUID or the defined `correlation_id`.
|
|
85
|
-
|
|
86
|
-
### By UUID
|
|
87
|
-
|
|
88
|
-
```ruby
|
|
89
|
-
ReportReactor.continue(
|
|
90
|
-
id: "uuid-123",
|
|
91
|
-
payload: { status: "completed", url: "..." },
|
|
92
|
-
step_name: :wait_for_report
|
|
93
|
-
)
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### By Correlation ID
|
|
97
|
-
|
|
98
|
-
```ruby
|
|
99
|
-
ReportReactor.continue_by_correlation_id(
|
|
100
|
-
correlation_id: "report-999",
|
|
101
|
-
payload: { status: "completed", url: "..." },
|
|
102
|
-
step_name: :wait_for_report
|
|
103
|
-
)
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### Resuming Method Styles
|
|
107
|
-
|
|
108
|
-
There are two ways to invoke continuation:
|
|
109
|
-
|
|
110
|
-
1. **Strict / Fire-and-Forget (Class Method)**:
|
|
111
|
-
* `Reactor.continue(...)`
|
|
112
|
-
* If payload is invalid, it **automatically compensates (undo)** and cancels the reactor.
|
|
113
|
-
* Best for webhooks where you can't ask the sender to fix the payload.
|
|
114
|
-
|
|
115
|
-
2. **Flexible (Instance Method)**:
|
|
116
|
-
* First find the reactor: `reactor = ReportReactor.find("uuid-123")`
|
|
117
|
-
* Then call: `result = reactor.continue(payload: ..., step_name: :wait_for_report)`
|
|
118
|
-
* If payload is invalid, it returns a failure result but **does not** cancel execution.
|
|
119
|
-
* Allows you to handle the error (e.g., show a form error to a user) and try again.
|
|
120
|
-
|
|
121
|
-
## Cancellation & Undo
|
|
122
|
-
|
|
123
|
-
You can cancel a paused reactor if the operation is no longer needed.
|
|
124
|
-
|
|
125
|
-
```ruby
|
|
126
|
-
# Undo: Runs defined undo/compensate blocks for completed steps in reverse order,
|
|
127
|
-
# then marks the execution as cancelled.
|
|
128
|
-
ReportReactor.undo("uuid-123")
|
|
129
|
-
|
|
130
|
-
# Cancel: Stops execution immediately and marks the reactor as cancelled with the provided reason.
|
|
131
|
-
# The context is preserved for inspection, but resumption is disabled.
|
|
132
|
-
ReportReactor.cancel(id: "uuid-123", reason: "User cancelled")
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
## Common Use Cases
|
|
136
|
-
|
|
137
|
-
### Human Approvals
|
|
138
|
-
|
|
139
|
-
```ruby
|
|
140
|
-
interrupt :wait_for_approval do
|
|
141
|
-
wait_for :submit_request
|
|
142
|
-
correlation_id { |ctx| "approval-#{ctx.input(:request_id)}" }
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
step :process_decision do
|
|
146
|
-
argument :decision, result(:wait_for_approval)
|
|
147
|
-
run do |args, _ctx|
|
|
148
|
-
if args[:decision][:approved]
|
|
149
|
-
Success("Approved")
|
|
150
|
-
else
|
|
151
|
-
Failure("Rejected")
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Webhooks
|
|
158
|
-
|
|
159
|
-
Use `correlation_id` to map an external resource ID (like a Payment Intent ID) back to the reactor waiting for confirmation.
|
|
160
|
-
|
|
161
|
-
### Scheduled Follow-ups
|
|
162
|
-
|
|
163
|
-
Using `timeout` with `strategy: :active` to wake up a reactor after a delay if no external event occurs (e.g., expiring a reservation).
|