ruby_reactor 0.8.3 → 0.8.5
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/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +208 -0
- data/CLAUDE.md +1 -1
- data/README.md +140 -102
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
- data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +21 -18
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +14 -4
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +140 -25
- data/lib/ruby_reactor/step.rb +20 -7
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +20 -7
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +47 -2
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
|
|
|
13
13
|
|
|
14
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.
|
|
15
15
|
|
|
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.
|
|
16
|
+
The key value is **Reliability**: if any part of your workflow fails with an error, Ruby Reactor automatically triggers compensation logic to undo previous steps, ensuring your system never ends up in a corrupted half-state. Only an interruption (a signal, an exit, out of memory) runs no rollback code; the run is recorded as `aborted` so a manual `undo` can roll it back. Whether you're coordinating microservices or monolith modules, you get atomic-like consistency with background processing built-in.
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
@@ -21,8 +21,8 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
21
21
|
- **Background Execution**: Hand a whole reactor, or everything after a declared step, to a background job via Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
|
|
22
22
|
- **Async Steps & Reactors**: `async_step` and `async_reactor` dispatch independent units of work while the reactor keeps running; steps that read their result wait for it.
|
|
23
23
|
- **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
|
|
24
|
-
- **Retries**:
|
|
25
|
-
- **Compensation**: Automatic rollback of completed steps when a
|
|
24
|
+
- **Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.
|
|
25
|
+
- **Compensation**: Automatic rollback of completed steps when any error occurs after them, including a raising argument transform or an exception that is not a `StandardError`.
|
|
26
26
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
27
27
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
28
28
|
- **Distributed Locks, Semaphores, Rate Limits, Periods & Ordered Locks**: 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"), `with_period` to dedup reactors to once per calendar bucket, and `with_ordered_lock` for strict transaction ordering via a monotonically increasing nonce assigned at enqueue. Background jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
|
|
@@ -32,7 +32,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
32
32
|
| Feature | Ruby Reactor | dry-transaction | Trailblazer | Custom Sidekiq Jobs |
|
|
33
33
|
|--------------------------|--------------|-----------------|-------------|---------------------|
|
|
34
34
|
| DAG/Parallel execution | Yes | No | Limited | Manual |
|
|
35
|
-
| Auto compensation/undo | Yes
|
|
35
|
+
| Auto compensation/undo | Yes (steps, compose, map; async units roll back on their own) | No | Manual | Manual |
|
|
36
36
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
37
37
|
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
38
38
|
| Built-in web dashboard | Yes | No | No | No |
|
|
@@ -237,7 +237,7 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
|
|
|
237
237
|
- **`Success(value)`** — step succeeded; `value` flows to dependent steps.
|
|
238
238
|
- **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
|
|
239
239
|
- **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
|
|
240
|
-
- **`Skipped(value)`** — mark this one step skipped
|
|
240
|
+
- **`Skipped(value)`** — mark this one step skipped. The run continues exactly as for `Success` — `value` flows to dependants — and the step is never undone; the execution trace marks it skipped. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
|
|
241
241
|
|
|
242
242
|
One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
|
|
243
243
|
|
|
@@ -247,14 +247,16 @@ One-line helpers end a step immediately from any call depth: `success!(value)`,
|
|
|
247
247
|
class ReserveInventoryStep < RubyReactor::Step
|
|
248
248
|
# The step's input contract: enforced before `run` on every execution path.
|
|
249
249
|
input :order, :hash
|
|
250
|
+
# The step's retry policy: applies in every reactor that uses this step.
|
|
251
|
+
retries max_attempts: 3
|
|
250
252
|
|
|
251
253
|
def run
|
|
252
|
-
reservation_id = InventoryService.reserve(inputs
|
|
254
|
+
reservation_id = InventoryService.reserve(inputs.order[:items])
|
|
253
255
|
Success(reservation_id: reservation_id)
|
|
254
256
|
end
|
|
255
257
|
|
|
256
258
|
def compensate
|
|
257
|
-
InventoryService.release_partial(inputs
|
|
259
|
+
InventoryService.release_partial(inputs.order[:items])
|
|
258
260
|
Success()
|
|
259
261
|
end
|
|
260
262
|
|
|
@@ -327,7 +329,7 @@ require 'ruby_reactor'
|
|
|
327
329
|
|
|
328
330
|
class ValidateEmailStep < RubyReactor::Step
|
|
329
331
|
def run
|
|
330
|
-
email = inputs
|
|
332
|
+
email = inputs.email
|
|
331
333
|
email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
|
|
332
334
|
end
|
|
333
335
|
end
|
|
@@ -336,14 +338,14 @@ class CreateUserStep < RubyReactor::Step
|
|
|
336
338
|
def run
|
|
337
339
|
Success(
|
|
338
340
|
id: rand(10000),
|
|
339
|
-
email: inputs
|
|
340
|
-
password_hash: inputs
|
|
341
|
+
email: inputs.email,
|
|
342
|
+
password_hash: inputs.password_hash,
|
|
341
343
|
created_at: Time.now
|
|
342
344
|
)
|
|
343
345
|
end
|
|
344
346
|
|
|
345
347
|
def compensate
|
|
346
|
-
Notify.to(inputs
|
|
348
|
+
Notify.to(inputs.email)
|
|
347
349
|
Success()
|
|
348
350
|
end
|
|
349
351
|
end
|
|
@@ -358,9 +360,9 @@ class UserRegistrationReactor < RubyReactor::Reactor
|
|
|
358
360
|
|
|
359
361
|
step :hash_password do
|
|
360
362
|
argument :password, input(:password)
|
|
361
|
-
run do |
|
|
363
|
+
run do |inputs, _context|
|
|
362
364
|
require 'digest'
|
|
363
|
-
Success(Digest::SHA256.hexdigest(
|
|
365
|
+
Success(Digest::SHA256.hexdigest(inputs.password))
|
|
364
366
|
end
|
|
365
367
|
end
|
|
366
368
|
|
|
@@ -373,13 +375,13 @@ class UserRegistrationReactor < RubyReactor::Reactor
|
|
|
373
375
|
argument :email, result(:validate_email)
|
|
374
376
|
wait_for :create_user
|
|
375
377
|
|
|
376
|
-
run do |
|
|
377
|
-
Email.send!(
|
|
378
|
+
run do |inputs, _context|
|
|
379
|
+
Email.send!(inputs.email, "verify your email")
|
|
378
380
|
Success()
|
|
379
381
|
end
|
|
380
382
|
|
|
381
|
-
compensate do |_error,
|
|
382
|
-
Email.send("support@acme.com", "Email verification for #{
|
|
383
|
+
compensate do |_error, inputs, _context|
|
|
384
|
+
Email.send("support@acme.com", "Email verification for #{inputs.email} couldn't be sent")
|
|
383
385
|
Success()
|
|
384
386
|
end
|
|
385
387
|
end
|
|
@@ -443,12 +445,12 @@ class CreateUserReactor < RubyReactor::Reactor
|
|
|
443
445
|
input :params
|
|
444
446
|
|
|
445
447
|
step :validate_inputs do
|
|
446
|
-
run { |
|
|
448
|
+
run { |inputs| validate(inputs.params) }
|
|
447
449
|
end
|
|
448
450
|
|
|
449
451
|
step :create_user do
|
|
450
452
|
argument :params, result(:validate_inputs)
|
|
451
|
-
run { |
|
|
453
|
+
run { |inputs| User.create(inputs.params) }
|
|
452
454
|
end
|
|
453
455
|
|
|
454
456
|
# :create_user is the LAST step to run in the calling process.
|
|
@@ -457,13 +459,13 @@ class CreateUserReactor < RubyReactor::Reactor
|
|
|
457
459
|
|
|
458
460
|
step :open_account do
|
|
459
461
|
argument :user, result(:create_user)
|
|
460
|
-
run { |
|
|
462
|
+
run { |inputs| Bank.open_account(inputs.user) }
|
|
461
463
|
end
|
|
462
464
|
|
|
463
465
|
step :report_new_user do
|
|
464
466
|
argument :user, result(:create_user)
|
|
465
467
|
wait_for :open_account
|
|
466
|
-
run { |
|
|
468
|
+
run { |inputs| Analytics.track(inputs.user) }
|
|
467
469
|
end
|
|
468
470
|
end
|
|
469
471
|
|
|
@@ -514,19 +516,19 @@ class SignupReactor < RubyReactor::Reactor
|
|
|
514
516
|
|
|
515
517
|
async_step :send_email do
|
|
516
518
|
argument :to, input(:email)
|
|
517
|
-
run { |
|
|
519
|
+
run { |inputs| Mailer.welcome(inputs.to).deliver_now; Success(:sent) }
|
|
518
520
|
end
|
|
519
521
|
|
|
520
522
|
# Does NOT wait — it has no dependency on :send_email.
|
|
521
523
|
step :record_signup do
|
|
522
524
|
argument :email, input(:email)
|
|
523
|
-
run { |
|
|
525
|
+
run { |inputs| Success(Signup.create!(email: inputs.email)) }
|
|
524
526
|
end
|
|
525
527
|
|
|
526
528
|
# DOES wait, because it reads the result.
|
|
527
529
|
step :confirm_delivery do
|
|
528
530
|
argument :delivery, result(:send_email)
|
|
529
|
-
run { |
|
|
531
|
+
run { |inputs| Success("confirmed") }
|
|
530
532
|
end
|
|
531
533
|
end
|
|
532
534
|
```
|
|
@@ -542,10 +544,14 @@ time. On success the reader gets the raw value; on failure it gets the
|
|
|
542
544
|
|
|
543
545
|
**Compensation is opt-in.** If a dispatched step fails and nothing reads its
|
|
544
546
|
result, the reactor is not compensated — it was dispatched precisely so the
|
|
545
|
-
reactor would not depend on it.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
547
|
+
reactor would not depend on it. The unit's own `compensate` does run: once, in
|
|
548
|
+
the unit's job, after its final attempt fails, recorded on the unit's record as
|
|
549
|
+
`compensation`. A reader that returns `Failure` compensates itself and undoes the
|
|
550
|
+
reactor's completed steps; the unit is not compensated again. The independence
|
|
551
|
+
cuts both ways: async dispatches never enter the parent's undo stack, so a
|
|
552
|
+
parent rolling back for its own reasons never "undoes" a unit that runs (and may
|
|
553
|
+
still succeed) elsewhere. An inline `undo` on an `async_step` would never run, so
|
|
554
|
+
it raises at class-definition time (a step class's `undo` is warned about).
|
|
549
555
|
|
|
550
556
|
#### `async_reactor`: a whole nested reactor, running independently
|
|
551
557
|
|
|
@@ -564,8 +570,8 @@ class SignupReactor < RubyReactor::Reactor
|
|
|
564
570
|
|
|
565
571
|
step :verify do
|
|
566
572
|
argument :account, result(:provision_account) # waits for the child
|
|
567
|
-
run do |
|
|
568
|
-
|
|
573
|
+
run do |inputs|
|
|
574
|
+
inputs.account.success? ? Success(inputs.account.value) : Failure(inputs.account.error)
|
|
569
575
|
end
|
|
570
576
|
end
|
|
571
577
|
end
|
|
@@ -667,7 +673,7 @@ Pause execution to wait for external events like webhooks or user approvals.
|
|
|
667
673
|
```ruby
|
|
668
674
|
class ApprovalReactor < RubyReactor::Reactor
|
|
669
675
|
step :submit_request do
|
|
670
|
-
run { |
|
|
676
|
+
run { |inputs| RequestService.submit(inputs.to_h) }
|
|
671
677
|
end
|
|
672
678
|
|
|
673
679
|
interrupt :wait_for_manager do
|
|
@@ -678,8 +684,8 @@ class ApprovalReactor < RubyReactor::Reactor
|
|
|
678
684
|
|
|
679
685
|
step :process_decision do
|
|
680
686
|
argument :decision, result(:wait_for_manager)
|
|
681
|
-
run do |
|
|
682
|
-
|
|
687
|
+
run do |inputs|
|
|
688
|
+
inputs.decision == 'approved' ? Success() : Failure("Rejected")
|
|
683
689
|
end
|
|
684
690
|
end
|
|
685
691
|
end
|
|
@@ -716,7 +722,7 @@ class RefundOrderReactor < RubyReactor::Reactor
|
|
|
716
722
|
|
|
717
723
|
step :refund do
|
|
718
724
|
argument :order_id, input(:order_id)
|
|
719
|
-
run { |
|
|
725
|
+
run { |inputs| PaymentGateway.refund(inputs.order_id) }
|
|
720
726
|
end
|
|
721
727
|
end
|
|
722
728
|
|
|
@@ -745,7 +751,7 @@ class GeocodeReactor < RubyReactor::Reactor
|
|
|
745
751
|
|
|
746
752
|
step :geocode do
|
|
747
753
|
argument :address, input(:address)
|
|
748
|
-
run { |
|
|
754
|
+
run { |inputs| Geocoder.lookup(inputs.address) }
|
|
749
755
|
end
|
|
750
756
|
end
|
|
751
757
|
|
|
@@ -759,7 +765,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
|
|
|
759
765
|
|
|
760
766
|
step :build do
|
|
761
767
|
argument :org_id, input(:org_id)
|
|
762
|
-
run { |
|
|
768
|
+
run { |inputs| Billing.generate(inputs.org_id) }
|
|
763
769
|
end
|
|
764
770
|
end
|
|
765
771
|
|
|
@@ -775,7 +781,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
775
781
|
|
|
776
782
|
step :charge do
|
|
777
783
|
argument :account_id, input(:account_id)
|
|
778
|
-
run { |
|
|
784
|
+
run { |inputs| Stripe.charge(inputs.account_id) }
|
|
779
785
|
end
|
|
780
786
|
end
|
|
781
787
|
|
|
@@ -792,7 +798,7 @@ class OrderedTransactionReactor < RubyReactor::Reactor
|
|
|
792
798
|
|
|
793
799
|
step :apply do
|
|
794
800
|
argument :transaction, input(:transaction)
|
|
795
|
-
run { |
|
|
801
|
+
run { |inputs| Ledger.apply(inputs.transaction) }
|
|
796
802
|
end
|
|
797
803
|
end
|
|
798
804
|
|
|
@@ -816,7 +822,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
816
822
|
|
|
817
823
|
step :charge do
|
|
818
824
|
argument :account_id, input(:account_id)
|
|
819
|
-
run { |
|
|
825
|
+
run { |inputs| Stripe.charge(inputs.account_id) }
|
|
820
826
|
end
|
|
821
827
|
end
|
|
822
828
|
```
|
|
@@ -841,21 +847,21 @@ A step's `run` block can also return `Halt(reason: "...")` to stop the reactor c
|
|
|
841
847
|
```ruby
|
|
842
848
|
step :ensure_active do
|
|
843
849
|
argument :user, result(:fetch_user)
|
|
844
|
-
run do |
|
|
845
|
-
next Halt(reason: "user_opted_out") if
|
|
846
|
-
Success(
|
|
850
|
+
run do |inputs, _ctx|
|
|
851
|
+
next Halt(reason: "user_opted_out") if inputs.user.opted_out?
|
|
852
|
+
Success(inputs.user)
|
|
847
853
|
end
|
|
848
854
|
end
|
|
849
855
|
```
|
|
850
856
|
|
|
851
|
-
To
|
|
857
|
+
To mark a *single* step as having had nothing to do — the rest of the workflow runs as usual — return `Skipped(value)` instead. `Skipped` is only an instrumentation mark, so an engineer reviewing the execution can see the step did not need to run. It never changes how the run executes: the value flows to dependants, and a `background` hand-off and a `with_period` bucket treat it as a completed step. A skipped step had nothing to do, so it is never undone. (`where`/`guard` were removed; skipping from the body is the only way.)
|
|
852
858
|
|
|
853
859
|
```ruby
|
|
854
860
|
step :maybe_sync do
|
|
855
861
|
argument :user, result(:fetch_user)
|
|
856
|
-
run do |
|
|
857
|
-
next Skipped(
|
|
858
|
-
Success(sync!(
|
|
862
|
+
run do |inputs, _ctx|
|
|
863
|
+
next Skipped(inputs.user) if inputs.user.already_synced?
|
|
864
|
+
Success(sync!(inputs.user))
|
|
859
865
|
end
|
|
860
866
|
end
|
|
861
867
|
```
|
|
@@ -879,7 +885,7 @@ class DataProcessingReactor < RubyReactor::Reactor
|
|
|
879
885
|
|
|
880
886
|
step :transform do
|
|
881
887
|
argument :item, input(:item)
|
|
882
|
-
run { |
|
|
888
|
+
run { |inputs| transform_item(inputs.item) }
|
|
883
889
|
end
|
|
884
890
|
|
|
885
891
|
returns :transform
|
|
@@ -891,6 +897,14 @@ A `fan_out` map is a **hand-off point**: the reactor stops at the map (the calle
|
|
|
891
897
|
|
|
892
898
|
By using `fan_out` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
|
|
893
899
|
|
|
900
|
+
**Rollback.** A map rolls back like a composed reactor, with no map-level rollback DSL: the `undo`s
|
|
901
|
+
already declared on the element reactor's steps are each element's rollback. When the map fails
|
|
902
|
+
(an element fails under `fail_fast`, or `collect` raises), and when a later step fails or the run is
|
|
903
|
+
undone manually, every element that completed is rolled back, highest index first, in inline and
|
|
904
|
+
fan-out mode alike. A fail-fast fan-out map lets elements already in flight finish before it reports
|
|
905
|
+
the failure. Make element `undo`s idempotent. See
|
|
906
|
+
[Rollback](documentation/data_pipelines.md#rollback).
|
|
907
|
+
|
|
894
908
|
`batch_size` is optional: with `fan_out` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
|
|
895
909
|
|
|
896
910
|
> **Breaking change:** `async true` inside a `map` block has been **removed** — it read like `async_step`/`async_reactor`, which dispatch independent units the reactor does not stop for. It now raises at class-definition time. The exact replacement is `fan_out` (`fan_out batch_size: N`).
|
|
@@ -913,7 +927,7 @@ map :archive_old_users do
|
|
|
913
927
|
|
|
914
928
|
step :archive do
|
|
915
929
|
argument :user, input(:user)
|
|
916
|
-
run { |
|
|
930
|
+
run { |inputs| inputs.user.archive! }
|
|
917
931
|
end
|
|
918
932
|
|
|
919
933
|
returns :archive
|
|
@@ -921,9 +935,9 @@ end
|
|
|
921
935
|
|
|
922
936
|
step :summary do
|
|
923
937
|
argument :results, result(:archive_old_users)
|
|
924
|
-
run do |
|
|
925
|
-
puts "Archived: #{
|
|
926
|
-
puts "Failed: #{
|
|
938
|
+
run do |inputs|
|
|
939
|
+
puts "Archived: #{inputs.results.successes.count}"
|
|
940
|
+
puts "Failed: #{inputs.results.failures.count}"
|
|
927
941
|
Success()
|
|
928
942
|
end
|
|
929
943
|
end
|
|
@@ -1005,12 +1019,12 @@ class ValidatedUserReactor < RubyReactor::Reactor
|
|
|
1005
1019
|
argument :age, input(:age)
|
|
1006
1020
|
argument :bio, input(:bio)
|
|
1007
1021
|
|
|
1008
|
-
run do |
|
|
1022
|
+
run do |inputs, context|
|
|
1009
1023
|
profile = {
|
|
1010
|
-
name:
|
|
1011
|
-
email:
|
|
1012
|
-
age:
|
|
1013
|
-
bio:
|
|
1024
|
+
name: inputs.name,
|
|
1025
|
+
email: inputs.email,
|
|
1026
|
+
age: inputs.age,
|
|
1027
|
+
bio: inputs.bio || "No bio provided",
|
|
1014
1028
|
created_at: Time.now
|
|
1015
1029
|
}
|
|
1016
1030
|
Success(profile)
|
|
@@ -1124,7 +1138,7 @@ step :charge do
|
|
|
1124
1138
|
argument :amount, input(:amount)
|
|
1125
1139
|
argument :currency, input(:currency)
|
|
1126
1140
|
|
|
1127
|
-
run { |
|
|
1141
|
+
run { |inputs, _| charge!(inputs) }
|
|
1128
1142
|
end
|
|
1129
1143
|
```
|
|
1130
1144
|
|
|
@@ -1201,9 +1215,9 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1201
1215
|
step :validate_user do
|
|
1202
1216
|
argument :user_id, input(:user_id)
|
|
1203
1217
|
|
|
1204
|
-
run do |
|
|
1218
|
+
run do |inputs, context|
|
|
1205
1219
|
# Check if user exists and has permission to purchase
|
|
1206
|
-
user = find_user(
|
|
1220
|
+
user = find_user(inputs.user_id)
|
|
1207
1221
|
user ? Success(user) : Failure("User not found")
|
|
1208
1222
|
end
|
|
1209
1223
|
end
|
|
@@ -1211,8 +1225,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1211
1225
|
step :validate_products do
|
|
1212
1226
|
argument :product_ids, input(:product_ids)
|
|
1213
1227
|
|
|
1214
|
-
run do |
|
|
1215
|
-
products =
|
|
1228
|
+
run do |inputs, context|
|
|
1229
|
+
products = inputs.product_ids.map { |id| find_product(id) }
|
|
1216
1230
|
if products.all?
|
|
1217
1231
|
Success(products)
|
|
1218
1232
|
else
|
|
@@ -1224,8 +1238,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1224
1238
|
step :calculate_total do
|
|
1225
1239
|
argument :products, result(:validate_products)
|
|
1226
1240
|
|
|
1227
|
-
run do |
|
|
1228
|
-
total =
|
|
1241
|
+
run do |inputs, context|
|
|
1242
|
+
total = inputs.products.sum { |p| p[:price] }
|
|
1229
1243
|
Success(total)
|
|
1230
1244
|
end
|
|
1231
1245
|
end
|
|
@@ -1233,8 +1247,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1233
1247
|
step :check_inventory do
|
|
1234
1248
|
argument :products, result(:validate_products)
|
|
1235
1249
|
|
|
1236
|
-
run do |
|
|
1237
|
-
available =
|
|
1250
|
+
run do |inputs, context|
|
|
1251
|
+
available = inputs.products.all? { |p| p[:stock] > 0 }
|
|
1238
1252
|
available ? Success(true) : Failure("Out of stock")
|
|
1239
1253
|
end
|
|
1240
1254
|
end
|
|
@@ -1243,15 +1257,15 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1243
1257
|
argument :user, result(:validate_user)
|
|
1244
1258
|
argument :total, result(:calculate_total)
|
|
1245
1259
|
|
|
1246
|
-
run do |
|
|
1260
|
+
run do |inputs, context|
|
|
1247
1261
|
# Process payment logic here
|
|
1248
|
-
payment_id = process_payment(
|
|
1262
|
+
payment_id = process_payment(inputs.user[:id], inputs.total)
|
|
1249
1263
|
Success(payment_id)
|
|
1250
1264
|
end
|
|
1251
1265
|
|
|
1252
|
-
undo do |error,
|
|
1266
|
+
undo do |error, inputs, context|
|
|
1253
1267
|
# Refund payment on failure
|
|
1254
|
-
refund_payment(
|
|
1268
|
+
refund_payment(inputs.payment_id)
|
|
1255
1269
|
Success()
|
|
1256
1270
|
end
|
|
1257
1271
|
end
|
|
@@ -1261,14 +1275,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1261
1275
|
argument :products, result(:validate_products)
|
|
1262
1276
|
argument :payment_id, result(:process_payment)
|
|
1263
1277
|
|
|
1264
|
-
run do |
|
|
1265
|
-
order = create_order_record(
|
|
1278
|
+
run do |inputs, context|
|
|
1279
|
+
order = create_order_record(inputs.user, inputs.products, inputs.payment_id)
|
|
1266
1280
|
Success(order)
|
|
1267
1281
|
end
|
|
1268
1282
|
|
|
1269
|
-
undo do |error,
|
|
1283
|
+
undo do |error, inputs, context|
|
|
1270
1284
|
# Cancel order and update inventory
|
|
1271
|
-
cancel_order(
|
|
1285
|
+
cancel_order(inputs.order[:id])
|
|
1272
1286
|
Success()
|
|
1273
1287
|
end
|
|
1274
1288
|
end
|
|
@@ -1276,14 +1290,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1276
1290
|
step :update_inventory do
|
|
1277
1291
|
argument :products, result(:validate_products)
|
|
1278
1292
|
|
|
1279
|
-
run do |
|
|
1280
|
-
|
|
1293
|
+
run do |inputs, context|
|
|
1294
|
+
inputs.products.each { |p| decrement_stock(p[:id]) }
|
|
1281
1295
|
Success(true)
|
|
1282
1296
|
end
|
|
1283
1297
|
|
|
1284
|
-
undo do |error,
|
|
1298
|
+
undo do |error, inputs, context|
|
|
1285
1299
|
# Restock products
|
|
1286
|
-
|
|
1300
|
+
inputs.products.each { |p| increment_stock(p[:id]) }
|
|
1287
1301
|
Success()
|
|
1288
1302
|
end
|
|
1289
1303
|
end
|
|
@@ -1292,8 +1306,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
|
|
|
1292
1306
|
argument :user, result(:validate_user)
|
|
1293
1307
|
argument :order, result(:create_order)
|
|
1294
1308
|
|
|
1295
|
-
run do |
|
|
1296
|
-
send_email(
|
|
1309
|
+
run do |inputs, context|
|
|
1310
|
+
send_email(inputs.user[:email], "Order confirmed", order_details(inputs.order))
|
|
1297
1311
|
Success(true)
|
|
1298
1312
|
end
|
|
1299
1313
|
end
|
|
@@ -1304,7 +1318,7 @@ end
|
|
|
1304
1318
|
|
|
1305
1319
|
### Error Handling and Compensation
|
|
1306
1320
|
|
|
1307
|
-
When a step fails, RubyReactor automatically undoes completed steps in reverse order, compensate only runs in the failing step and backwalks the executed steps undo blocks:
|
|
1321
|
+
When a step fails, RubyReactor automatically undoes completed steps in reverse order, compensate only runs in the failing step and backwalks the executed steps undo blocks. Composed reactors and maps are completed steps too: undoing one replays its child's (or each completed element's) own step `undo`s:
|
|
1308
1322
|
|
|
1309
1323
|
```ruby
|
|
1310
1324
|
class TransactionReactor < RubyReactor::Reactor
|
|
@@ -1316,9 +1330,9 @@ class TransactionReactor < RubyReactor::Reactor
|
|
|
1316
1330
|
argument :from_account, input(:from_account)
|
|
1317
1331
|
argument :to_account, input(:to_account)
|
|
1318
1332
|
|
|
1319
|
-
run do |
|
|
1320
|
-
from = find_account(
|
|
1321
|
-
to = find_account(
|
|
1333
|
+
run do |inputs, context|
|
|
1334
|
+
from = find_account(inputs.from_account)
|
|
1335
|
+
to = find_account(inputs.to_account)
|
|
1322
1336
|
|
|
1323
1337
|
if from && to && from != to
|
|
1324
1338
|
Success({from: from, to: to})
|
|
@@ -1332,9 +1346,9 @@ class TransactionReactor < RubyReactor::Reactor
|
|
|
1332
1346
|
argument :accounts, result(:validate_accounts)
|
|
1333
1347
|
argument :amount, input(:amount)
|
|
1334
1348
|
|
|
1335
|
-
run do |
|
|
1336
|
-
if
|
|
1337
|
-
Success(
|
|
1349
|
+
run do |inputs, context|
|
|
1350
|
+
if inputs.accounts[:from][:balance] >= inputs.amount
|
|
1351
|
+
Success(inputs.accounts)
|
|
1338
1352
|
else
|
|
1339
1353
|
Failure("Insufficient funds")
|
|
1340
1354
|
end
|
|
@@ -1345,14 +1359,14 @@ class TransactionReactor < RubyReactor::Reactor
|
|
|
1345
1359
|
argument :accounts, result(:check_balance)
|
|
1346
1360
|
argument :amount, input(:amount)
|
|
1347
1361
|
|
|
1348
|
-
run do |
|
|
1349
|
-
debit(
|
|
1350
|
-
Success(
|
|
1362
|
+
run do |inputs, context|
|
|
1363
|
+
debit(inputs.accounts[:from][:id], inputs.amount)
|
|
1364
|
+
Success(inputs.accounts)
|
|
1351
1365
|
end
|
|
1352
1366
|
|
|
1353
|
-
undo do |error,
|
|
1367
|
+
undo do |error, inputs, context|
|
|
1354
1368
|
# Credit the amount back
|
|
1355
|
-
credit(
|
|
1369
|
+
credit(inputs.accounts[:from][:id], inputs.amount)
|
|
1356
1370
|
Success()
|
|
1357
1371
|
end
|
|
1358
1372
|
end
|
|
@@ -1361,14 +1375,14 @@ class TransactionReactor < RubyReactor::Reactor
|
|
|
1361
1375
|
argument :accounts, result(:debit_account)
|
|
1362
1376
|
argument :amount, input(:amount)
|
|
1363
1377
|
|
|
1364
|
-
run do |
|
|
1365
|
-
credit(
|
|
1378
|
+
run do |inputs, context|
|
|
1379
|
+
credit(inputs.accounts[:to][:id], inputs.amount)
|
|
1366
1380
|
Success({transaction_id: generate_transaction_id()})
|
|
1367
1381
|
end
|
|
1368
1382
|
|
|
1369
|
-
undo do |error,
|
|
1383
|
+
undo do |error, inputs, context|
|
|
1370
1384
|
# Debit the amount back from recipient
|
|
1371
|
-
debit(
|
|
1385
|
+
debit(inputs.accounts[:to][:id], inputs.amount)
|
|
1372
1386
|
Success()
|
|
1373
1387
|
end
|
|
1374
1388
|
end
|
|
@@ -1377,9 +1391,9 @@ class TransactionReactor < RubyReactor::Reactor
|
|
|
1377
1391
|
argument :accounts, result(:validate_accounts)
|
|
1378
1392
|
wait_for :credit_account, :debit_account
|
|
1379
1393
|
|
|
1380
|
-
run do |
|
|
1381
|
-
Notify.to(
|
|
1382
|
-
Notify.to(
|
|
1394
|
+
run do |inputs, context|
|
|
1395
|
+
Notify.to(inputs.accounts[:from])
|
|
1396
|
+
Notify.to(inputs.accounts[:to])
|
|
1383
1397
|
end
|
|
1384
1398
|
|
|
1385
1399
|
end
|
|
@@ -1406,9 +1420,33 @@ result.rollback_failures
|
|
|
1406
1420
|
```
|
|
1407
1421
|
|
|
1408
1422
|
`reason` is `:coordination_unavailable`, `:returned_failure`, or `:raised`; `kind`
|
|
1409
|
-
is `:undo` or `:compensate`.
|
|
1423
|
+
is `:undo` or `:compensate`. An entry from a map element's rollback also carries
|
|
1424
|
+
`map_step:` and `element_index:`, and a map reports an element it could not roll
|
|
1425
|
+
back with `reason: :context_unavailable` (its stored context, or the map's element index, expired) or
|
|
1426
|
+
`:element_in_flight` (a duplicate of it was still running). See
|
|
1410
1427
|
[Step Rollback](documentation/locks_and_semaphores.md#step-rollback).
|
|
1411
1428
|
|
|
1429
|
+
Every error after a completed step rolls back, not only a step body's failure.
|
|
1430
|
+
An `argument` source, `transform` or result path that raises fails that step
|
|
1431
|
+
with `RubyReactor::Error::ArgumentResolutionError`. The step is not compensated
|
|
1432
|
+
(its body never started) nor retried; completed steps are undone. The Failure names the
|
|
1433
|
+
step, and `exception_class` reports the original error's class:
|
|
1434
|
+
|
|
1435
|
+
```ruby
|
|
1436
|
+
result.step_name # => :charge
|
|
1437
|
+
result.exception_class # => "ArgumentError"
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
That holds for every exception, not only a `StandardError`: a step class that
|
|
1441
|
+
never implemented `run` (`NotImplementedError`) or a custom `Exception` subclass
|
|
1442
|
+
fails its step and rolls back the same way, and `exception_class` names it.
|
|
1443
|
+
|
|
1444
|
+
Only an interruption (a signal such as `Interrupt`, `SystemExit`, `NoMemoryError`,
|
|
1445
|
+
or an enclosing `Timeout.timeout`) reaches the caller unchanged and runs no
|
|
1446
|
+
rollback code. A run in the caller's process is stored with status `aborted`,
|
|
1447
|
+
the steps not yet undone still outstanding; `MyReactor.undo(id)` rolls them
|
|
1448
|
+
back. A run in a worker is redelivered instead.
|
|
1449
|
+
|
|
1412
1450
|
### Using Pre-defined Schemas
|
|
1413
1451
|
|
|
1414
1452
|
You can use existing dry-validation schemas:
|
|
@@ -1430,8 +1468,8 @@ class SchemaValidatedReactor < RubyReactor::Reactor
|
|
|
1430
1468
|
step :process_user do
|
|
1431
1469
|
argument :user, input(:user)
|
|
1432
1470
|
|
|
1433
|
-
run do |
|
|
1434
|
-
Success(
|
|
1471
|
+
run do |inputs, context|
|
|
1472
|
+
Success(inputs.user)
|
|
1435
1473
|
end
|
|
1436
1474
|
end
|
|
1437
1475
|
|
|
@@ -1483,7 +1521,7 @@ Discover how to build complex, modular workflows by composing reactors within ot
|
|
|
1483
1521
|
Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
|
|
1484
1522
|
|
|
1485
1523
|
### [Retry Configuration](documentation/retry_configuration.md)
|
|
1486
|
-
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to
|
|
1524
|
+
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to declare retries on a step class or inline step, and how background retries work without blocking workers.
|
|
1487
1525
|
|
|
1488
1526
|
### [Interrupts](documentation/interrupts.md)
|
|
1489
1527
|
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.
|
|
@@ -137,9 +137,38 @@ module RubyReactor
|
|
|
137
137
|
builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
|
|
138
138
|
builder.instance_eval(&block) if block_given?
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
config = builder.build(async_dispatch: :step)
|
|
141
|
+
check_async_step_undo!(builder, config, caller_locations(1, 1).first)
|
|
142
|
+
steps[name] = config
|
|
141
143
|
end
|
|
142
144
|
|
|
145
|
+
# An async_step is never undone: its parent does not track it for undo
|
|
146
|
+
# (008 R-09/R-10), so an `undo` on it would be dead code. An inline one
|
|
147
|
+
# is rejected; a step class's is only warned about, because the same
|
|
148
|
+
# class is legitimately reused by ordinary steps, where it does run.
|
|
149
|
+
def check_async_step_undo!(builder, config, site)
|
|
150
|
+
if config.undo_block
|
|
151
|
+
raise RubyReactor::Error::ValidationError,
|
|
152
|
+
"`undo` on async_step :#{config.name} would never run: the parent never undoes an " \
|
|
153
|
+
"independent async unit. Put failure cleanup in the unit's `compensate` (it runs in the " \
|
|
154
|
+
"unit's job when its last attempt fails) or in the reading step's `compensate`; for cleanup " \
|
|
155
|
+
"that must run when the parent rolls back, use a `step`/`compose`/`map` (tracked for undo) " \
|
|
156
|
+
"or an `async_reactor` child whose steps declare `undo`."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return unless defines_undo?(config.impl)
|
|
160
|
+
|
|
161
|
+
builder.send(:warn_definition, site, nil,
|
|
162
|
+
"async_step :#{config.name} uses #{config.impl}; its `undo` will not run for this async " \
|
|
163
|
+
"use (async units are never undone).")
|
|
164
|
+
end
|
|
165
|
+
private :check_async_step_undo!
|
|
166
|
+
|
|
167
|
+
def defines_undo?(impl)
|
|
168
|
+
impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step
|
|
169
|
+
end
|
|
170
|
+
private :defines_undo?
|
|
171
|
+
|
|
143
172
|
# Dispatch a whole nested reactor to run INDEPENDENTLY — linked
|
|
144
173
|
# to this one by execution id for traceability, but excluded from its
|
|
145
174
|
# compensation graph. Fire-and-forget unless a later step reads
|