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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3ddfd9b6e65d03e5c00d7980563da39b4132fc1d5f0dbe14e5b782d039c7f68
4
- data.tar.gz: 785cc8ac8d426433a00f378eafb8f312b343950ecf496e2af0a6fe9e48115c29
3
+ metadata.gz: de5c91a5450cb522097965aa4348760438b60babd05da33f9f7ec1a07fe9bd07
4
+ data.tar.gz: d565e81c6ba6e057b56912311bf16dee95ce0c441eb76f2b893658b751568b2c
5
5
  SHA512:
6
- metadata.gz: 549a49deff5d63e129c0b4c043cbc3987dbbb09b054907a5acb8941af85fedb5f9706bad8d56948bbc7ea889eba19174254962ca987e7835b4cd0a7bf3f9e165
7
- data.tar.gz: 319399c2854cccb505fc7e8f6c964eeeb527bad375ed513b1a2c2ea89b77445283f287bbff5a0892902a56a5a56b5a9aff105a0164006abf407473ad3780a58d
6
+ metadata.gz: dde49e045303aeb4bcf414ea30b7d85d8fb8a782f873dd9ebb4a9d7d0524e74de37ba4e53ddc11ac6dea360bf1226f946d0c50e74e685da2ac70394065f436d6
7
+ data.tar.gz: 7b9b0b5bf75866876bdc1811dfc1118272963337431148f480d6570e19bfc2e9d93a6fdfd91a3629734501a590e23be579b98ff2e2f4f125831410351e0e6865
data/README.md CHANGED
@@ -24,6 +24,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
24
24
  - **Compensation**: Automatic rollback of completed steps when a failure occurs.
25
25
  - **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
26
26
  - **Input Validation**: Integrated with `dry-validation` for robust input checking.
27
+ - **Distributed Locks, Semaphores, Rate Limits & Periods**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), and `with_period` to dedup reactors to once per calendar bucket (once per day/month/year/etc). Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
27
28
 
28
29
  ## Comparison
29
30
 
@@ -32,6 +33,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
32
33
  | DAG/Parallel execution | Yes | No | Limited | Manual |
33
34
  | Auto compensation/undo | Yes | No | Manual | Manual |
34
35
  | Interrupts (pause/resume)| Yes | No | No | Manual |
36
+ | Locks / sem / rate / per | Yes | No | No | Manual |
35
37
  | Built-in web dashboard | Yes | No | No | No |
36
38
  | Async with Sidekiq | Yes | No | Limited | Yes |
37
39
 
@@ -56,6 +58,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
56
58
  - [Full Reactor Async](#full-reactor-async)
57
59
  - [Step-Level Async](#step-level-async)
58
60
  - [Interrupts (Pause & Resume)](#interrupts-pause--resume)
61
+ - [Locks & Semaphores](#locks--semaphores)
59
62
  - [Map & Parallel Execution](#map--parallel-execution)
60
63
  - [Map with Dynamic Source (ActiveRecord)](#map-with-dynamic-source-activerecord)
61
64
  - [Input Validation](#input-validation)
@@ -99,7 +102,14 @@ RubyReactor.configure do |config|
99
102
  # Sidekiq configuration for async execution
100
103
  config.sidekiq_queue = :default
101
104
  config.sidekiq_retry_count = 3
102
-
105
+
106
+ # Lock contention snooze behavior for async reactors. When a Sidekiq worker
107
+ # cannot acquire a lock or semaphore, it re-enqueues itself with this delay
108
+ # (plus jitter) up to `lock_snooze_max_attempts` times before giving up.
109
+ config.lock_snooze_base_delay = 5
110
+ config.lock_snooze_jitter = 5
111
+ config.lock_snooze_max_attempts = 20
112
+
103
113
  # Logger configuration
104
114
  config.logger = Logger.new($stdout)
105
115
  end
@@ -159,7 +169,7 @@ class UserRegistrationReactor < RubyReactor::Reactor
159
169
 
160
170
  run do |args, context|
161
171
  if args[:email] && args[:email].include?('@')
162
- Success(args[:email].trim)
172
+ Success(args[:email].strip)
163
173
  else
164
174
  Failure("Email must contain @")
165
175
  end
@@ -191,8 +201,9 @@ class UserRegistrationReactor < RubyReactor::Reactor
191
201
  Success(user)
192
202
  end
193
203
 
194
- conpensate do |error, args, context|
204
+ compensate do |error, args, context|
195
205
  Notify.to(args[:email])
206
+ Success()
196
207
  end
197
208
  end
198
209
 
@@ -200,8 +211,8 @@ class UserRegistrationReactor < RubyReactor::Reactor
200
211
  argument :email, result(:validate_email)
201
212
  wait_for :create_user
202
213
 
203
- run do |args, _context|
204
- Email.sent!(args[:email], "verify your email")
214
+ run do |args, _context|
215
+ Email.send!(args[:email], "verify your email")
205
216
  Success()
206
217
  end
207
218
 
@@ -326,6 +337,99 @@ ApprovalReactor.continue_by_correlation_id(
326
337
  )
327
338
  ```
328
339
 
340
+ ### Locks & Semaphores
341
+
342
+ Coordinate across processes with Redis-backed primitives:
343
+
344
+ - **`with_lock`** — at-most-one runner per key at a time (concurrency control).
345
+ - **`with_semaphore`** — cap total concurrent runners per key (capacity control).
346
+ - **`with_rate_limit`** — fixed-window rate limit, single or multi-window ("3/sec AND 100/min").
347
+ - **`with_period`** — run at most once per calendar bucket (dedup / once-per-day, once-per-month, etc).
348
+
349
+ ```ruby
350
+ class RefundOrderReactor < RubyReactor::Reactor
351
+ input :order_id
352
+
353
+ # Only one refund per order at a time. Auto-extend keeps the TTL fresh while
354
+ # the reactor runs, so long steps cannot let the lock expire mid-flight.
355
+ with_lock(ttl: 60) { |inputs| "order:#{inputs[:order_id]}" }
356
+
357
+ step :refund do
358
+ argument :order_id, input(:order_id)
359
+ run { |args| PaymentGateway.refund(args[:order_id]) }
360
+ end
361
+ end
362
+
363
+ class GeocodeReactor < RubyReactor::Reactor
364
+ input :address
365
+
366
+ # At most 5 geocode calls in flight across the fleet.
367
+ with_semaphore(limit: 5) { |inputs| "geocode_api" }
368
+
369
+ step :geocode do
370
+ argument :address, input(:address)
371
+ run { |args| Geocoder.lookup(args[:address]) }
372
+ end
373
+ end
374
+
375
+ class MonthlyBillingReactor < RubyReactor::Reactor
376
+ input :org_id
377
+
378
+ # Run at most once per UTC month per org. Subsequent calls in the same month
379
+ # return RubyReactor::Skipped without executing any step. Pair with
380
+ # with_lock for strict at-most-one even under concurrent racers.
381
+ with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
382
+
383
+ step :build do
384
+ argument :org_id, input(:org_id)
385
+ run { |args| Billing.generate(args[:org_id]) }
386
+ end
387
+ end
388
+
389
+ class ChargeReactor < RubyReactor::Reactor
390
+ input :account_id
391
+
392
+ # Respect upstream Stripe rate limits: 3/sec and 100/min.
393
+ # Async workers snooze for exactly retry_after seconds instead of
394
+ # consuming Sidekiq retry budget.
395
+ with_rate_limit(
396
+ limits: { second: 3, minute: 100 }
397
+ ) { |inputs| "stripe:#{inputs[:account_id]}" }
398
+
399
+ step :charge do
400
+ argument :account_id, input(:account_id)
401
+ run { |args| Stripe.charge(args[:account_id]) }
402
+ end
403
+ end
404
+ ```
405
+
406
+ On contention:
407
+
408
+ - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError`.
409
+ - **Async** (Sidekiq) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay is the error's `retry_after_seconds` (precise wakeup); for locks/semaphores it's `lock_snooze_base_delay + jitter`. Snoozes do not count against the Sidekiq retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed.
410
+
411
+ On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
412
+
413
+ ```ruby
414
+ result = MonthlyBillingReactor.run(org_id: 42)
415
+ result.success? # true (Skipped is a Success subclass)
416
+ result.skipped? # true on dedup hit, false otherwise
417
+ ```
418
+
419
+ A step's `run` block can also return `RubyReactor.Skipped(reason: "...")` to halt the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back").
420
+
421
+ ```ruby
422
+ step :ensure_active do
423
+ argument :user, result(:fetch_user)
424
+ run do |args|
425
+ next RubyReactor.Skipped(reason: "user_opted_out") if args[:user].opted_out?
426
+ RubyReactor.Success(args[:user])
427
+ end
428
+ end
429
+ ```
430
+
431
+ See [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, and operational notes.
432
+
329
433
  ### Map & Parallel Execution
330
434
 
331
435
  Process collections in parallel using the `map` step:
@@ -733,6 +837,10 @@ Learn how to pause and resume reactors to handle long-running processes, manual
733
837
  ### [Testing with RSpec](documentation/testing.md)
734
838
  Comprehensive guide to testing reactors with RubyReactor's testing utilities. Learn about the `TestSubject` class for reactor execution and introspection, step mocking for isolating dependencies, testing nested and composed reactors, and custom RSpec matchers like `be_success`, `have_run_step`, and `have_retried_step`.
735
839
 
840
+ ### [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md)
841
+
842
+ Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), and calendar-bucketed dedup (`with_period`, returning `Skipped` results). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, and once-per-day/month/year scheduling patterns.
843
+
736
844
  ### Examples
737
845
  - [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
738
846
  - [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
@@ -755,6 +863,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
755
863
  - [X] Sidekiq
756
864
  - [ ] ActiveJob
757
865
  - [ ] OpenTelemetry support
866
+ - [X] locks
758
867
 
759
868
  ## Development
760
869
 
@@ -8,13 +8,15 @@ RubyReactor is a powerful Ruby framework for building reliable, sequential busin
8
8
  - [Core Concepts](core_concepts.md)
9
9
  - [DAG Execution and Saga Patterns](DAG.md)
10
10
  - [Async Reactors](async_reactors.md)
11
+ - [Composition](composition.md)
12
+ - [Data Pipelines](data_pipelines.md)
11
13
  - [Retry Configuration](retry_configuration.md)
12
- - [Migration Guide](migration_guide.md)
14
+ - [Interrupts](interrupts.md)
15
+ - [Testing with RSpec](testing.md)
13
16
  - [Examples](examples/)
14
17
  - [Order Processing](examples/order_processing.md)
15
18
  - [Payment Processing](examples/payment_processing.md)
16
19
  - [Inventory Management](examples/inventory_management.md)
17
- - [API Reference](api_reference.md)
18
20
 
19
21
  ## Quick Start
20
22
 
@@ -22,21 +24,30 @@ RubyReactor is a powerful Ruby framework for building reliable, sequential busin
22
24
  require 'ruby_reactor'
23
25
 
24
26
  class OrderProcessingReactor < RubyReactor::Reactor
27
+ input :order_id
28
+
25
29
  step :validate_order do
26
- run { validate_order_logic }
30
+ argument :order_id, input(:order_id)
31
+ run { |args, _ctx| Success(Order.find(args[:order_id])) }
27
32
  end
28
33
 
29
34
  step :process_payment do
30
- run { process_payment_logic }
35
+ argument :order, result(:validate_order)
36
+ run { |args, _ctx| Success(PaymentService.charge(args[:order])) }
31
37
  end
32
38
 
33
39
  step :send_confirmation do
34
- run { send_email_confirmation }
40
+ argument :order, result(:validate_order)
41
+ run { |args, _ctx| Success(EmailService.send(args[:order])) }
35
42
  end
43
+
44
+ returns :process_payment
36
45
  end
37
46
 
38
47
  # Execute synchronously
39
48
  result = OrderProcessingReactor.run(order_id: 123)
49
+ result.success? # => true
50
+ result.value # => payment result
40
51
  ```
41
52
 
42
53
  ## Key Features
@@ -106,9 +117,10 @@ RubyReactor provides comprehensive error handling:
106
117
 
107
118
  ## Requirements
108
119
 
109
- - Ruby 2.7+
110
- - Redis (for async execution)
120
+ - Ruby 3.0+
121
+ - Redis (for async execution and state persistence)
111
122
  - Sidekiq (for background processing)
123
+ - dry-validation (optional, for input/payload validation)
112
124
 
113
125
  ## Installation
114
126
 
@@ -120,4 +132,4 @@ gem 'ruby_reactor'
120
132
 
121
133
  ## Contributing
122
134
 
123
- See [CONTRIBUTING.md](../CONTRIBUTING.md) for development setup and contribution guidelines.
135
+ Bug reports and pull requests are welcome at <https://github.com/arturictus/ruby_reactor>. See the root [README](../README.md#contributing) for development setup.
@@ -41,20 +41,21 @@ end
41
41
  # Synchronous call returns immediately
42
42
  async_result = OrderProcessingReactor.run(order_id: 123)
43
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}"
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"
55
54
  end
56
55
  ```
57
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
+
58
59
  ### Architecture
59
60
 
60
61
  ```mermaid
@@ -85,26 +86,28 @@ Individual steps can be marked as `async: true`. Execution runs synchronously un
85
86
  class OrderProcessingReactor < RubyReactor::Reactor
86
87
  step :validate_order do
87
88
  # Runs synchronously
88
- run { validate_order_logic }
89
+ run { |args, _ctx| validate_order_logic(args) }
89
90
  end
90
91
 
91
- step :process_payment, async: true do
92
- # First async step - triggers handoff to worker
93
- run { process_payment_logic }
92
+ step :process_payment do
93
+ async true # First async step triggers handoff to worker
94
+ run { |args, _ctx| process_payment_logic(args) }
94
95
  end
95
96
 
96
97
  step :update_inventory do
97
98
  # Runs in worker after handoff
98
- run { update_inventory_logic }
99
+ run { |args, _ctx| update_inventory_logic(args) }
99
100
  end
100
101
 
101
102
  step :send_confirmation do
102
103
  # Runs in same worker
103
- run { send_confirmation_logic }
104
+ run { |args, _ctx| send_confirmation_logic(args) }
104
105
  end
105
106
  end
106
107
  ```
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
+
108
111
  ### Execution Flow
109
112
 
110
113
  ```ruby
@@ -244,17 +247,18 @@ class PaymentProcessingReactor < RubyReactor::Reactor
244
247
 
245
248
  step :validate_payment do
246
249
  retries max_attempts: 3, backoff: :exponential, base_delay: 1.second
247
- run { validate_payment_logic }
250
+ run { |args, _ctx| validate_payment_logic(args) }
248
251
  end
249
252
 
250
- step :charge_card, async: true do
253
+ step :charge_card do
254
+ async true
251
255
  retries max_attempts: 5, backoff: :linear, base_delay: 5.seconds
252
- run { charge_card_logic }
256
+ run { |args, _ctx| charge_card_logic(args) }
253
257
  end
254
258
 
255
259
  step :update_records do
256
260
  # No retry - critical step
257
- run { update_records_logic }
261
+ run { |args, _ctx| update_records_logic(args) }
258
262
  end
259
263
  end
260
264
  ```
@@ -298,25 +302,30 @@ class OrderProcessingReactor < RubyReactor::Reactor
298
302
  async true
299
303
 
300
304
  step :process_payment do
301
- run { process_payment_logic }
305
+ argument :order, result(:validate_order)
306
+ run { |args, _ctx| process_payment_logic(args[:order]) }
302
307
 
303
- undo do |payment_id:, **|
304
- # Runs in worker if execution fails later
305
- PaymentService.refund(payment_id)
308
+ undo do |result, _args, _ctx|
309
+ # Runs in worker when a LATER step fails
310
+ PaymentService.refund(result[:payment_id])
311
+ Success()
306
312
  end
307
313
 
308
- compensate do |payment_id:, **|
309
- # Runs in worker if execution fails later
310
- PaymentService.refund(payment_id)
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()
311
318
  end
312
319
  end
313
320
 
314
321
  step :update_inventory do
315
- run { update_inventory_logic }
322
+ argument :order, result(:validate_order)
323
+ run { |args, _ctx| update_inventory_logic(args[:order]) }
316
324
 
317
- compensate do |order:, **|
325
+ compensate do |_error, args, _ctx|
318
326
  # Runs in worker on failure
319
- InventoryService.restore(order)
327
+ InventoryService.restore(args[:order])
328
+ Success()
320
329
  end
321
330
  end
322
331
  end
@@ -337,16 +346,19 @@ Retries are visible in Sidekiq web UI with:
337
346
  ### Sidekiq Worker Setup
338
347
 
339
348
  ```ruby
340
- # config/sidekiq.rb
341
- require 'ruby_reactor/worker'
342
-
349
+ # config/initializers/ruby_reactor.rb (Rails) or load before booting Sidekiq
343
350
  RubyReactor.configure do |config|
351
+ config.storage.adapter = :redis
352
+ config.storage.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
353
+
344
354
  config.sidekiq_queue = :default
345
355
  config.sidekiq_retry_count = 3
346
356
  config.logger = Logger.new('log/ruby_reactor.log')
347
357
  end
348
358
  ```
349
359
 
360
+ Sidekiq workers are loaded automatically via Zeitwerk when `ruby_reactor` is required — no extra `require` is needed.
361
+
350
362
  ## Performance Considerations
351
363
 
352
364
  ### Worker Pool Sizing
@@ -24,12 +24,15 @@ Steps are the individual units of work within a reactor. Each step has a name an
24
24
 
25
25
  ### Inline Step Definition
26
26
 
27
+ `run` blocks always receive two positional arguments: the resolved arguments hash and the execution context. Declare inputs with `argument :name, source`:
28
+
27
29
  ```ruby
28
30
  step :validate_order do
29
- run do |order_id|
30
- # Step implementation
31
- order = Order.find(order_id)
32
- raise "Order not found" unless order
31
+ argument :order_id, input(:order_id)
32
+
33
+ run do |args, _context|
34
+ order = Order.find(args[:order_id])
35
+ return Failure("Order not found") unless order
33
36
  Success({ order: order })
34
37
  end
35
38
  end
@@ -144,29 +147,22 @@ end
144
147
 
145
148
  ## Results
146
149
 
147
- Every reactor execution returns a comprehensive result object.
148
- <!--
149
- # TODO
150
-
151
- This is not true, update to use instance and what is stored
152
- ```ruby
153
- result = OrderProcessingReactor.run(order_id: 123)
154
-
155
- # Overall status
156
- result.success? # => true/false
157
- result.failure? # => true/false
150
+ `Reactor.run` returns one of four result types:
158
151
 
159
- # Step outputs
160
- result.step_results # => { validate_order: {...}, process_payment: {...} }
161
- result.intermediate_results # => Hash of all step outputs
152
+ - **`RubyReactor::Success`** — `success?` is `true`. `value` holds the output of the step named in `returns`, or the full `intermediate_results` hash if no `returns` is declared.
153
+ - **`RubyReactor::Failure`** `failure?` is `true`. Readers include `error`, `step_name`, `reactor_name`, `step_arguments`, `inputs`, `exception_class`, `file_path`, `line_number`, `backtrace`, `validation_errors`, and `retryable?`.
154
+ - **`RubyReactor::AsyncResult`** returned by an async reactor or when a step hands off to a worker. Readers: `job_id`, `execution_id`, `intermediate_results`.
155
+ - **`RubyReactor::InterruptResult`** — returned when an `interrupt` step pauses execution. Readers: `execution_id`, `correlation_id`, `status` (`:paused`), `timeout_at`, `intermediate_results`.
162
156
 
163
- # Execution tracking
164
- result.completed_steps # => #<Set: {:validate_order, :process_payment}>
165
- result.inputs # => { order_id: 123 }
157
+ Step-by-step state lives on the context, not the result object. Reload via `Reactor.find(execution_id)` to inspect:
166
158
 
167
- # Error information
168
- result.error # => Exception object if failed
169
- ``` -->
159
+ ```ruby
160
+ reactor = OrderProcessingReactor.find(execution_id)
161
+ reactor.context.intermediate_results # => { validate_order: {...}, ... }
162
+ reactor.context.status # => "completed" | "failed" | "paused" | "running"
163
+ reactor.execution_trace # ordered list of run/undo/compensate entries
164
+ reactor.result # reconstructed Success/Failure/InterruptResult
165
+ ```
170
166
 
171
167
  ## Error Handling
172
168
 
@@ -178,14 +174,18 @@ When a step fails, execution stops and compensation begins:
178
174
 
179
175
  ```ruby
180
176
  step :process_payment do
181
- run do
177
+ argument :amount, input(:amount)
178
+ argument :token, input(:card_token)
179
+
180
+ run do |args, _ctx|
182
181
  # This might fail
183
- PaymentService.charge(amount, token)
182
+ PaymentService.charge(args[:amount], args[:token])
184
183
  end
185
184
 
186
- compensate do |payment_id: nil, **|
187
- # Undo the payment if it was created
188
- PaymentService.refund(payment_id) if payment_id
185
+ compensate do |error, args, _ctx|
186
+ # Compensation receives (error, arguments, context)
187
+ # Best-effort cleanup specific to this step's failure
188
+ AuditService.log_payment_failure(args[:token], error.message)
189
189
  end
190
190
  end
191
191
  ```
@@ -349,22 +349,23 @@ result = Reactor.run(inputs)
349
349
 
350
350
  ### Asynchronous Execution
351
351
 
352
- <!-- TODO: review this part -->
353
-
354
352
  ```ruby
355
353
  async_result = Reactor.run(inputs)
356
354
  # Returns immediately
357
- # Check status later
358
-
359
- case async_result.status
360
- when :success
361
- result = async_result.result
362
- when :failed
363
- error = async_result.error
355
+ async_result.execution_id # UUID to look up state later
356
+
357
+ # Reload to inspect status / final result
358
+ reactor = Reactor.find(async_result.execution_id)
359
+ case reactor.context.status.to_s
360
+ when "completed" then reactor.result.value
361
+ when "failed" then reactor.result.error
362
+ when "paused" then reactor.result.correlation_id
363
+ when "running" then :still_running
364
364
  end
365
365
  ```
366
366
 
367
367
  **Characteristics:**
368
+
368
369
  - Non-blocking execution
369
370
  - Background processing with Sidekiq
370
371
  - Retry capabilities
@@ -372,33 +373,38 @@ end
372
373
 
373
374
  ## Step Arguments
374
375
 
375
- Steps receive arguments through keyword arguments, with automatic dependency injection.
376
+ `run` blocks always receive two positional arguments: the resolved arguments hash and the context. Declare each argument explicitly with `argument :name, source` — there is no implicit keyword injection.
377
+
378
+ Sources you can use:
379
+
380
+ - `input(:name)` — value from the reactor's inputs (the hash passed to `Reactor.run`).
381
+ - `input(:name, :path)` — nested path access into a hash input.
382
+ - `result(:step_name)` — full output of a previous step.
383
+ - `result(:step_name, :path)` — nested path into a previous step's output.
384
+ - `value(literal)` — a constant value.
376
385
 
377
386
  ```ruby
378
387
  step :validate_order do
379
- run do |order_id:, customer_id:|
380
- # Direct access to reactor inputs
381
- order = Order.find_by(id: order_id, customer_id: customer_id)
388
+ argument :order_id, input(:order_id)
389
+ argument :customer_id, input(:customer_id)
390
+
391
+ run do |args, _context|
392
+ order = Order.find_by(id: args[:order_id], customer_id: args[:customer_id])
382
393
  Success({ order: order })
383
394
  end
384
395
  end
385
396
 
386
397
  step :process_payment do
387
- argument :order_data, result(:validate_order)
398
+ argument :order, result(:validate_order, :order)
388
399
 
389
400
  run do |args, _context|
390
- # Access results from previous steps
391
- order = args[:order_data][:order]
392
- payment = PaymentService.charge(order.total, order.card_token)
401
+ payment = PaymentService.charge(args[:order].total, args[:order].card_token)
393
402
  Success({ payment_id: payment.id })
394
403
  end
395
404
  end
396
405
  ```
397
406
 
398
- **Argument Resolution:**
399
- 1. **Step Results**: Outputs from completed dependent steps
400
- 2. **Reactor Inputs**: Original inputs passed to `run()`
401
- 3. **Intermediate Results**: Accumulated outputs from all steps
407
+ If a step declares no `argument`s, the reactor's raw inputs hash is passed as `args`.
402
408
 
403
409
  ## Undo
404
410
 
@@ -415,15 +421,16 @@ Unlike compensation which only runs for the failing step, undo is triggered duri
415
421
 
416
422
  ```ruby
417
423
  step :reserve_inventory do
418
- run do |items:|
419
- reservation_id = InventoryService.reserve(items)
424
+ argument :items, input(:items)
425
+
426
+ run do |args, _ctx|
427
+ reservation_id = InventoryService.reserve(args[:items])
420
428
  Success({ reservation_id: reservation_id })
421
429
  end
422
430
 
423
- undo do |reservation_result, arguments, context|
431
+ undo do |result, arguments, context|
424
432
  # Undo receives the step's result, arguments, and full context
425
- reservation_id = reservation_result[:reservation_id]
426
- InventoryService.release(reservation_id)
433
+ InventoryService.release(result[:reservation_id])
427
434
  Success("Inventory reservation released")
428
435
  end
429
436
  end
@@ -438,9 +445,11 @@ Undo blocks receive three parameters:
438
445
 
439
446
  ```ruby
440
447
  step :complex_operation do
441
- run do |input:|
448
+ argument :input, input(:payload)
449
+
450
+ run do |args, _ctx|
442
451
  # Complex operation that modifies external state
443
- record = create_record(input)
452
+ record = create_record(args[:input])
444
453
  notification = send_notification(record)
445
454
  Success({ record_id: record.id, notification_id: notification.id })
446
455
  end
@@ -477,8 +486,10 @@ Compensation runs immediately when a step fails, before the broader rollback pro
477
486
 
478
487
  ```ruby
479
488
  step :reserve_inventory do
480
- run do |items:|
481
- reservation_id = InventoryService.reserve(items)
489
+ argument :items, input(:items)
490
+
491
+ run do |args, _ctx|
492
+ reservation_id = InventoryService.reserve(args[:items])
482
493
  Success({ reservation_id: reservation_id })
483
494
  end
484
495
 
@@ -501,9 +512,12 @@ Compensation blocks receive three parameters:
501
512
 
502
513
  ```ruby
503
514
  step :process_payment do
504
- run do |order:, payment_method:|
515
+ argument :order, result(:validate_order)
516
+ argument :payment_method, input(:payment_method)
517
+
518
+ run do |args, _ctx|
505
519
  # Payment processing logic that might fail
506
- PaymentService.charge(order.total, payment_method)
520
+ PaymentService.charge(args[:order].total, args[:payment_method])
507
521
  end
508
522
 
509
523
  compensate do |error, arguments, context|