ruby_reactor 0.3.1 → 0.3.2
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/README.md +114 -5
- data/documentation/README.md +20 -8
- data/documentation/async_reactors.md +46 -34
- data/documentation/core_concepts.md +75 -61
- data/documentation/examples/inventory_management.md +2 -3
- data/documentation/examples/order_processing.md +92 -77
- data/documentation/examples/payment_processing.md +28 -117
- data/documentation/getting_started.md +112 -94
- data/documentation/interrupts.md +9 -7
- data/documentation/locks_and_semaphores.md +459 -0
- data/documentation/retry_configuration.md +19 -14
- data/documentation/testing.md +182 -0
- data/lib/ruby_reactor/configuration.rb +18 -1
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/executor/result_handler.rb +19 -0
- data/lib/ruby_reactor/executor/step_executor.rb +5 -0
- data/lib/ruby_reactor/executor.rb +145 -2
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/matchers.rb +171 -4
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +70 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +49 -0
- metadata +9 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Getting Started with RubyReactor
|
|
2
2
|
|
|
3
|
-
This guide
|
|
3
|
+
This guide walks you through installation, configuration, and your first reactor.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -16,130 +16,158 @@ Or install directly:
|
|
|
16
16
|
gem install ruby_reactor
|
|
17
17
|
```
|
|
18
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
|
+
|
|
19
39
|
## Your First Reactor
|
|
20
40
|
|
|
21
|
-
|
|
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:
|
|
22
42
|
|
|
23
43
|
```ruby
|
|
24
44
|
require 'ruby_reactor'
|
|
25
45
|
|
|
26
46
|
class OrderProcessingReactor < RubyReactor::Reactor
|
|
47
|
+
input :order_id do
|
|
48
|
+
required(:order_id).filled(:integer, gt?: 0)
|
|
49
|
+
end
|
|
50
|
+
|
|
27
51
|
step :validate_order do
|
|
28
|
-
|
|
29
|
-
required(:order_id).filled(:string)
|
|
30
|
-
end
|
|
52
|
+
argument :order_id, input(:order_id)
|
|
31
53
|
|
|
32
|
-
run do |
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
raise "Order already processed" if order.processed?
|
|
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?
|
|
37
58
|
|
|
38
59
|
Success({ order: order })
|
|
39
60
|
end
|
|
40
61
|
end
|
|
41
62
|
|
|
42
63
|
step :process_payment do
|
|
43
|
-
|
|
44
|
-
# Process payment for the order
|
|
45
|
-
payment_result = PaymentService.charge(order.total, order.customer.card_token)
|
|
46
|
-
raise "Payment failed" unless payment_result.success?
|
|
64
|
+
argument :order, result(:validate_order, :order)
|
|
47
65
|
|
|
48
|
-
|
|
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")
|
|
49
69
|
end
|
|
50
70
|
end
|
|
51
71
|
|
|
52
72
|
step :update_inventory do
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
argument :order, result(:validate_order, :order)
|
|
74
|
+
|
|
75
|
+
run do |args, _context|
|
|
76
|
+
args[:order].items.each do |item|
|
|
56
77
|
InventoryService.decrement(item.product_id, item.quantity)
|
|
57
78
|
end
|
|
58
|
-
|
|
59
79
|
Success({ inventory_updated: true })
|
|
60
80
|
end
|
|
61
81
|
end
|
|
62
82
|
|
|
63
83
|
step :send_confirmation do
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
Success({ confirmation_sent: email_result.success? })
|
|
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 })
|
|
73
90
|
end
|
|
74
91
|
end
|
|
92
|
+
|
|
93
|
+
returns :send_confirmation
|
|
75
94
|
end
|
|
76
95
|
```
|
|
77
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
|
+
|
|
78
101
|
## Executing a Reactor
|
|
79
102
|
|
|
80
103
|
### Synchronous Execution
|
|
81
104
|
|
|
82
105
|
```ruby
|
|
83
|
-
# Run the reactor synchronously
|
|
84
106
|
result = OrderProcessingReactor.run(order_id: 123)
|
|
85
107
|
|
|
86
108
|
if result.success?
|
|
87
|
-
puts "Order processed
|
|
88
|
-
puts "Results: #{result.step_results}"
|
|
109
|
+
puts "Order processed: #{result.value}"
|
|
89
110
|
else
|
|
90
111
|
puts "Order processing failed: #{result.error}"
|
|
91
112
|
end
|
|
92
113
|
```
|
|
93
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
|
+
|
|
94
122
|
### Asynchronous Execution
|
|
95
123
|
|
|
96
|
-
For async execution,
|
|
124
|
+
For async execution, configure Sidekiq and either mark the reactor `async true` or mark individual steps async:
|
|
97
125
|
|
|
98
126
|
```ruby
|
|
99
127
|
class OrderProcessingReactor < RubyReactor::Reactor
|
|
100
|
-
async true #
|
|
128
|
+
async true # Entire reactor runs in a Sidekiq worker
|
|
101
129
|
|
|
102
130
|
# ... steps defined above
|
|
103
131
|
end
|
|
104
132
|
|
|
105
|
-
# Run asynchronously
|
|
106
133
|
async_result = OrderProcessingReactor.run(order_id: 123)
|
|
107
|
-
#
|
|
108
|
-
# Check status later with async_result.status
|
|
134
|
+
async_result.execution_id # => UUID for looking up state later
|
|
109
135
|
```
|
|
110
136
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
RubyReactor returns detailed execution results:
|
|
137
|
+
To inspect a running execution, reload it from storage:
|
|
114
138
|
|
|
115
139
|
```ruby
|
|
116
|
-
|
|
140
|
+
reactor = OrderProcessingReactor.find(async_result.execution_id)
|
|
141
|
+
reactor.context.status # => "running" | "completed" | "failed" | "paused"
|
|
142
|
+
reactor.result # Success / Failure / InterruptResult
|
|
143
|
+
```
|
|
117
144
|
|
|
118
|
-
|
|
119
|
-
result.success? # => true/false
|
|
145
|
+
See [Async Reactors](async_reactors.md) for the full async model.
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
result.step_results[:validate_order] # => { order: #<Order> }
|
|
123
|
-
result.step_results[:process_payment] # => { payment_id: "pay_123" }
|
|
147
|
+
## Inspecting the Context
|
|
124
148
|
|
|
125
|
-
|
|
126
|
-
result.intermediate_results # => Hash of all step outputs
|
|
149
|
+
Step outputs are stored on the context, not the result object. After a sync execution you can reach them via the reactor instance:
|
|
127
150
|
|
|
128
|
-
|
|
129
|
-
|
|
151
|
+
```ruby
|
|
152
|
+
reactor = OrderProcessingReactor.new
|
|
153
|
+
reactor.run(order_id: 123)
|
|
130
154
|
|
|
131
|
-
#
|
|
132
|
-
|
|
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, ... }, ...]
|
|
133
159
|
```
|
|
134
160
|
|
|
161
|
+
For black-box assertions in tests, use the `test_reactor` helper described in [Testing with RSpec](testing.md).
|
|
162
|
+
|
|
135
163
|
## Step Dependencies
|
|
136
164
|
|
|
137
|
-
Steps
|
|
165
|
+
Steps depend on each other through `argument :name, result(:other_step)`. The dependency graph topologically sorts steps; circular dependencies raise `RubyReactor::Error::DependencyError`:
|
|
138
166
|
|
|
139
167
|
```ruby
|
|
140
168
|
class ComplexReactor < RubyReactor::Reactor
|
|
141
169
|
step :validate_order do
|
|
142
|
-
run { validate_order_logic }
|
|
170
|
+
run { |args, _ctx| validate_order_logic(args) }
|
|
143
171
|
end
|
|
144
172
|
|
|
145
173
|
step :check_inventory do
|
|
@@ -160,65 +188,55 @@ class ComplexReactor < RubyReactor::Reactor
|
|
|
160
188
|
end
|
|
161
189
|
```
|
|
162
190
|
|
|
191
|
+
You can also declare order without data flow using `wait_for :step_name`.
|
|
192
|
+
|
|
163
193
|
## Error Handling and Compensation
|
|
164
194
|
|
|
165
|
-
|
|
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`.
|
|
166
200
|
|
|
167
201
|
```ruby
|
|
168
202
|
class OrderProcessingReactor < RubyReactor::Reactor
|
|
169
|
-
step :validate_order do
|
|
170
|
-
run { validate_order_logic }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
203
|
step :process_payment do
|
|
174
|
-
|
|
204
|
+
argument :order, result(:validate_order)
|
|
175
205
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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()
|
|
179
212
|
end
|
|
180
213
|
end
|
|
181
214
|
|
|
182
215
|
step :update_inventory do
|
|
183
|
-
|
|
216
|
+
argument :order, result(:validate_order)
|
|
184
217
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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()
|
|
190
224
|
end
|
|
191
225
|
end
|
|
192
226
|
end
|
|
193
227
|
```
|
|
194
228
|
|
|
195
|
-
If `update_inventory` fails,
|
|
196
|
-
1. Run the `update_inventory` compensate block
|
|
197
|
-
2. Run the `process_payment` undo block
|
|
198
|
-
3. Return a failure result
|
|
199
|
-
|
|
200
|
-
## Configuration
|
|
201
|
-
|
|
202
|
-
### Sidekiq Setup (for Async)
|
|
203
|
-
|
|
204
|
-
Add to your Sidekiq configuration:
|
|
205
|
-
|
|
206
|
-
```ruby
|
|
207
|
-
# config/sidekiq.rb
|
|
208
|
-
require 'ruby_reactor/worker'
|
|
209
|
-
|
|
210
|
-
# Configure RubyReactor
|
|
211
|
-
RubyReactor.configure do |config|
|
|
212
|
-
config.sidekiq_queue = :default
|
|
213
|
-
config.sidekiq_retry_count = 3
|
|
214
|
-
config.logger = Logger.new('log/ruby_reactor.log')
|
|
215
|
-
end
|
|
216
|
-
```
|
|
229
|
+
If `update_inventory` fails: its `compensate` runs, then `process_payment`'s `undo` runs.
|
|
217
230
|
|
|
231
|
+
See [Core Concepts](core_concepts.md#compensation) for the full compensation/undo model.
|
|
218
232
|
|
|
219
233
|
## Next Steps
|
|
220
234
|
|
|
221
|
-
-
|
|
222
|
-
-
|
|
223
|
-
-
|
|
224
|
-
-
|
|
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
|
data/documentation/interrupts.md
CHANGED
|
@@ -9,7 +9,7 @@ Use the `interrupt` keyword to define a pause point in your reactor.
|
|
|
9
9
|
```ruby
|
|
10
10
|
class ReportReactor < RubyReactor::Reactor
|
|
11
11
|
step :request_report do
|
|
12
|
-
run do
|
|
12
|
+
run do |_args, _ctx|
|
|
13
13
|
response = HTTP.post("https://api.example.com/reports")
|
|
14
14
|
Success(response.fetch(:id))
|
|
15
15
|
end
|
|
@@ -46,7 +46,7 @@ class ReportReactor < RubyReactor::Reactor
|
|
|
46
46
|
# The result of the interrupt step is the payload provided when resuming
|
|
47
47
|
argument :webhook_payload, result(:wait_for_report)
|
|
48
48
|
|
|
49
|
-
run do |args|
|
|
49
|
+
run do |args, _ctx|
|
|
50
50
|
Success(ReportProcessor.call(args[:webhook_payload]))
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -73,8 +73,9 @@ When a reactor encounters an `interrupt`:
|
|
|
73
73
|
execution = ReportReactor.run(company_id: 1)
|
|
74
74
|
|
|
75
75
|
if execution.paused?
|
|
76
|
-
execution.
|
|
77
|
-
execution.
|
|
76
|
+
execution.execution_id # => "uuid-123"
|
|
77
|
+
execution.correlation_id # => "report-..." (if defined)
|
|
78
|
+
execution.status # => :paused
|
|
78
79
|
end
|
|
79
80
|
```
|
|
80
81
|
|
|
@@ -122,12 +123,13 @@ There are two ways to invoke continuation:
|
|
|
122
123
|
You can cancel a paused reactor if the operation is no longer needed.
|
|
123
124
|
|
|
124
125
|
```ruby
|
|
125
|
-
# Undo: Runs defined
|
|
126
|
+
# Undo: Runs defined undo/compensate blocks for completed steps in reverse order,
|
|
127
|
+
# then marks the execution as cancelled.
|
|
126
128
|
ReportReactor.undo("uuid-123")
|
|
127
129
|
|
|
128
130
|
# Cancel: Stops execution immediately and marks the reactor as cancelled with the provided reason.
|
|
129
131
|
# The context is preserved for inspection, but resumption is disabled.
|
|
130
|
-
ReportReactor.cancel("uuid-123", reason: "User cancelled")
|
|
132
|
+
ReportReactor.cancel(id: "uuid-123", reason: "User cancelled")
|
|
131
133
|
```
|
|
132
134
|
|
|
133
135
|
## Common Use Cases
|
|
@@ -142,7 +144,7 @@ end
|
|
|
142
144
|
|
|
143
145
|
step :process_decision do
|
|
144
146
|
argument :decision, result(:wait_for_approval)
|
|
145
|
-
run do |args|
|
|
147
|
+
run do |args, _ctx|
|
|
146
148
|
if args[:decision][:approved]
|
|
147
149
|
Success("Approved")
|
|
148
150
|
else
|