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.
@@ -1,6 +1,6 @@
1
1
  # Getting Started with RubyReactor
2
2
 
3
- This guide will help you get started with RubyReactor, from installation to your first reactor.
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
- Let's create a simple order processing reactor:
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
- validate_args do
29
- required(:order_id).filled(:string)
30
- end
52
+ argument :order_id, input(:order_id)
31
53
 
32
- run do |order_id|
33
- # Validate the order exists and is in correct state
34
- order = Order.find(order_id)
35
- raise "Order not found" unless order
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
- run do |order:, **|
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
- Success({ payment_id: payment_result.id })
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
- run do |order:, **|
54
- # Update inventory for each item
55
- order.items.each do |item|
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
- run do |order:, payment_id:, **|
65
- # Send confirmation email
66
- email_result = EmailService.send_confirmation(
67
- order.customer.email,
68
- order_id: order.id,
69
- payment_id: payment_id
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 successfully!"
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, you need Sidekiq configured. Mark the reactor as async:
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 # Enable full reactor async
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
- # Returns immediately with AsyncResult
108
- # Check status later with async_result.status
134
+ async_result.execution_id # => UUID for looking up state later
109
135
  ```
110
136
 
111
- ## Understanding Results
112
-
113
- RubyReactor returns detailed execution results:
137
+ To inspect a running execution, reload it from storage:
114
138
 
115
139
  ```ruby
116
- result = OrderProcessingReactor.run(order_id: 123)
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
- # Check overall success
119
- result.success? # => true/false
145
+ See [Async Reactors](async_reactors.md) for the full async model.
120
146
 
121
- # Access step results
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
- # Access intermediate results
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
- # Check completed steps
129
- result.completed_steps # => Set of completed step names
151
+ ```ruby
152
+ reactor = OrderProcessingReactor.new
153
+ reactor.run(order_id: 123)
130
154
 
131
- # Error information (if failed)
132
- result.error # => Exception that caused failure
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 can depend on each other using the `argument` method with `result()`:
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
- RubyReactor automatically handles errors and provides compensation:
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
- run { process_payment_logic }
204
+ argument :order, result(:validate_order)
175
205
 
176
- undo do |payment_id:, **|
177
- # Undo the payment if something fails later
178
- PaymentService.refund(payment_id)
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
- run { update_inventory_logic }
216
+ argument :order, result(:validate_order)
184
217
 
185
- compensate do |order:, **|
186
- # Restore inventory if something fails later
187
- order.items.each do |item|
188
- InventoryService.increment(item.product_id, item.quantity)
189
- end
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, RubyReactor will:
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
- - Learn about [async reactors](async_reactors.md)
222
- - Configure [retry policies](retry_configuration.md)
223
- - See [examples](examples/) for more patterns
224
- - Check the [API reference](api_reference.md) for detailed documentation
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
@@ -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.id # => "uuid-123"
77
- execution.status # => :paused
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 compensations for completed steps in reverse order, then deletes execution.
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