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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de5c91a5450cb522097965aa4348760438b60babd05da33f9f7ec1a07fe9bd07
|
|
4
|
+
data.tar.gz: d565e81c6ba6e057b56912311bf16dee95ce0c441eb76f2b893658b751568b2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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].
|
|
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
|
-
|
|
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.
|
|
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
|
|
data/documentation/README.md
CHANGED
|
@@ -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
|
-
- [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 [
|
|
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
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
when
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
when
|
|
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
|
|
92
|
-
# First async step
|
|
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
|
|
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
|
-
|
|
305
|
+
argument :order, result(:validate_order)
|
|
306
|
+
run { |args, _ctx| process_payment_logic(args[:order]) }
|
|
302
307
|
|
|
303
|
-
undo do |
|
|
304
|
-
# Runs in worker
|
|
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 |
|
|
309
|
-
# Runs in worker if
|
|
310
|
-
|
|
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
|
-
|
|
322
|
+
argument :order, result(:validate_order)
|
|
323
|
+
run { |args, _ctx| update_inventory_logic(args[:order]) }
|
|
316
324
|
|
|
317
|
-
compensate do |
|
|
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/
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 |
|
|
187
|
-
#
|
|
188
|
-
|
|
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
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
when
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 :
|
|
398
|
+
argument :order, result(:validate_order, :order)
|
|
388
399
|
|
|
389
400
|
run do |args, _context|
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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 |
|
|
431
|
+
undo do |result, arguments, context|
|
|
424
432
|
# Undo receives the step's result, arguments, and full context
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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|
|