ruby_reactor 0.3.0 → 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. metadata +16 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 863c83e92cbfab470e39107c580ceabce444133973b723662a89e5bf0211c3ab
4
- data.tar.gz: 1209b5587efc055bc787731694a6dc2b7eab8b3d5a6ff729728aa29ea601e2e0
3
+ metadata.gz: de5c91a5450cb522097965aa4348760438b60babd05da33f9f7ec1a07fe9bd07
4
+ data.tar.gz: d565e81c6ba6e057b56912311bf16dee95ce0c441eb76f2b893658b751568b2c
5
5
  SHA512:
6
- metadata.gz: 146d14cd32b12c95764e493f2de2d826dbe074ec4e4b44613b788d748d52fe38644fd86f1590f6bb0b1a0d1840c2200ab29a6d8e78db27f8f6abf099faf74756
7
- data.tar.gz: 3d5b7c43182ee2d1c5c06ba90b7e2177a3dffbecdc8fb3e91db14f99bcd02807ebfa98c0e240412b790ec1d69609dc30995d9ee1aea9c8cb8ecd69d4d0fb2ac7
6
+ metadata.gz: dde49e045303aeb4bcf414ea30b7d85d8fb8a782f873dd9ebb4a9d7d0524e74de37ba4e53ddc11ac6dea360bf1226f946d0c50e74e685da2ac70394065f436d6
7
+ data.tar.gz: 7b9b0b5bf75866876bdc1811dfc1118272963337431148f480d6570e19bfc2e9d93a6fdfd91a3629734501a590e23be579b98ff2e2f4f125831410351e0e6865
data/README.md CHANGED
@@ -5,16 +5,16 @@
5
5
 
6
6
  # RubyReactor
7
7
 
8
+ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq** for asynchronous execution and **Redis** for state persistence.
9
+
10
+ ![Payment workflow reactor](documentation/images/payment_workflow.png)
11
+
8
12
  ## Why Ruby Reactor?
9
13
 
10
14
  Building complex business transactions often results in spaghetti code or brittle "god classes." Ruby Reactor solves this by implementing the **Saga Pattern** in a lightweight, developer-friendly package. It lets you define workflows as clear, dependency-driven steps without the boilerplate of heavy enterprise frameworks.
11
15
 
12
16
  The key value is **Reliability**: if any part of your workflow fails, Ruby Reactor automatically triggers compensation logic to undo previous steps, ensuring your system never ends up in a corrupted half-state. Whether you're coordinating microservices or monolith modules, you get atomic-like consistency with background processing built-in.
13
17
 
14
- A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq** for asynchronous execution and **Redis** for state persistence.
15
-
16
- ![Payment workflow reactor](documentation/images/payment_workflow.png)
17
-
18
18
  ## Features
19
19
 
20
20
  - **DAG-based Execution**: Steps are executed based on their dependencies, allowing for parallel execution of independent steps.
@@ -24,6 +24,7 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
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 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
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,12 +58,14 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
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)
62
65
  - [Complex Workflows with Dependencies](#complex-workflows-with-dependencies)
63
66
  - [Error Handling and Compensation](#error-handling-and-compensation)
64
67
  - [Using Pre-defined Schemas](#using-pre-defined-schemas)
68
+ - [Testing](#testing)
65
69
  - [Documentation](#documentation)
66
70
  - [Future improvements](#future-improvements)
67
71
  - [Development](#development)
@@ -98,7 +102,14 @@ RubyReactor.configure do |config|
98
102
  # Sidekiq configuration for async execution
99
103
  config.sidekiq_queue = :default
100
104
  config.sidekiq_retry_count = 3
101
-
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
+
102
113
  # Logger configuration
103
114
  config.logger = Logger.new($stdout)
104
115
  end
@@ -158,7 +169,7 @@ class UserRegistrationReactor < RubyReactor::Reactor
158
169
 
159
170
  run do |args, context|
160
171
  if args[:email] && args[:email].include?('@')
161
- Success(args[:email].trim)
172
+ Success(args[:email].strip)
162
173
  else
163
174
  Failure("Email must contain @")
164
175
  end
@@ -190,8 +201,9 @@ class UserRegistrationReactor < RubyReactor::Reactor
190
201
  Success(user)
191
202
  end
192
203
 
193
- conpensate do |error, args, context|
204
+ compensate do |error, args, context|
194
205
  Notify.to(args[:email])
206
+ Success()
195
207
  end
196
208
  end
197
209
 
@@ -199,8 +211,8 @@ class UserRegistrationReactor < RubyReactor::Reactor
199
211
  argument :email, result(:validate_email)
200
212
  wait_for :create_user
201
213
 
202
- run do |args, _context|
203
- Email.sent!(args[:email], "verify your email")
214
+ run do |args, _context|
215
+ Email.send!(args[:email], "verify your email")
204
216
  Success()
205
217
  end
206
218
 
@@ -325,6 +337,99 @@ ApprovalReactor.continue_by_correlation_id(
325
337
  )
326
338
  ```
327
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
+
328
433
  ### Map & Parallel Execution
329
434
 
330
435
  Process collections in parallel using the `map` step:
@@ -680,6 +785,29 @@ class SchemaValidatedReactor < RubyReactor::Reactor
680
785
  end
681
786
  ```
682
787
 
788
+ ### Testing
789
+
790
+ RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation.
791
+
792
+ ```ruby
793
+ RSpec.describe PaymentReactor do
794
+ it "processes payment successfully" do
795
+ subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
796
+
797
+ expect(subject).to be_success
798
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
799
+ expect(subject.step_result(:charge_card)).to include(status: "completed")
800
+ end
801
+
802
+ it "handles payment failures with mocked steps" do
803
+ subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
804
+ .mock_step(:charge_card) { Failure("Card declined") }
805
+
806
+ expect(subject).to be_failure
807
+ expect(subject.error).to include("Card declined")
808
+ end
809
+ end
810
+ ```
683
811
 
684
812
  ## Documentation
685
813
 
@@ -706,6 +834,13 @@ Configure robust retry policies for your steps. This guide details the available
706
834
  ### [Interrupts](documentation/interrupts.md)
707
835
  Learn how to pause and resume reactors to handle long-running processes, manual approvals, and asynchronous callbacks. Patterns for correlation IDs, timeouts, and payload validation.
708
836
 
837
+ ### [Testing with RSpec](documentation/testing.md)
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`.
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
+
709
844
  ### Examples
710
845
  - [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
711
846
  - [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
@@ -728,6 +863,7 @@ Learn how to pause and resume reactors to handle long-running processes, manual
728
863
  - [X] Sidekiq
729
864
  - [ ] ActiveJob
730
865
  - [ ] OpenTelemetry support
866
+ - [X] locks
731
867
 
732
868
  ## Development
733
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