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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +208 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +140 -102
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  13. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  14. data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
  15. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  16. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  17. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  19. data/lib/ruby_reactor/executor/result_handler.rb +21 -18
  20. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  21. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  22. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  23. data/lib/ruby_reactor/executor.rb +38 -4
  24. data/lib/ruby_reactor/map/collector.rb +21 -11
  25. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  26. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  27. data/lib/ruby_reactor/map/helpers.rb +32 -2
  28. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  29. data/lib/ruby_reactor/reactor.rb +24 -0
  30. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  31. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  32. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  33. data/lib/ruby_reactor/step/compose_step.rb +14 -4
  34. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  35. data/lib/ruby_reactor/step/inputs.rb +57 -0
  36. data/lib/ruby_reactor/step/map_step.rb +140 -25
  37. data/lib/ruby_reactor/step.rb +20 -7
  38. data/lib/ruby_reactor/step_worker.rb +46 -22
  39. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  40. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  41. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  42. data/lib/ruby_reactor/version.rb +1 -1
  43. data/lib/ruby_reactor/web/api.rb +1 -1
  44. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  45. data/lib/ruby_reactor/web/public/index.html +1 -1
  46. data/lib/ruby_reactor/worker.rb +3 -1
  47. data/lib/ruby_reactor.rb +20 -7
  48. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  49. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  50. data/specs/006-step-retry-declarations/data-model.md +58 -0
  51. data/specs/006-step-retry-declarations/plan.md +187 -0
  52. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  53. data/specs/006-step-retry-declarations/research.md +194 -0
  54. data/specs/006-step-retry-declarations/spec.md +453 -0
  55. data/specs/006-step-retry-declarations/tasks.md +382 -0
  56. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  57. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  58. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  59. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  60. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  61. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  62. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  63. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  64. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  65. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  66. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  67. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  68. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  69. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  72. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  73. data/specs/007-execution-flow-analysis/plan.md +127 -0
  74. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  75. data/specs/007-execution-flow-analysis/research.md +202 -0
  76. data/specs/007-execution-flow-analysis/spec.md +270 -0
  77. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  78. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  79. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  80. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  81. data/specs/008-rollback-reliability/data-model.md +139 -0
  82. data/specs/008-rollback-reliability/plan.md +233 -0
  83. data/specs/008-rollback-reliability/quickstart.md +105 -0
  84. data/specs/008-rollback-reliability/research.md +653 -0
  85. data/specs/008-rollback-reliability/spec.md +561 -0
  86. data/specs/008-rollback-reliability/tasks.md +1110 -0
  87. data/specs/future_improvements.md +48 -0
  88. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  89. 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**: Configurable retry logic for failed steps, with exponential backoff.
25
- - **Compensation**: Automatic rollback of completed steps when a failure occurs.
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 | No | Manual | Manual |
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; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
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[:order][:items])
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[:order][:items])
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[:email]
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[:email],
340
- password_hash: inputs[:password_hash],
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[:email])
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 |args, _context|
363
+ run do |inputs, _context|
362
364
  require 'digest'
363
- Success(Digest::SHA256.hexdigest(args[:password]))
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 |args, _context|
377
- Email.send!(args[:email], "verify your email")
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, args, _context|
382
- Email.send("support@acme.com", "Email verification for #{args[:email]} couldn't be sent")
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 { |args| validate(args[:params]) }
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 { |args| User.create(args[:params]) }
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 { |args| Bank.open_account(args[:user]) }
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 { |args| Analytics.track(args[:user]) }
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 { |args| Mailer.welcome(args[:to]).deliver_now; Success(:sent) }
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 { |args| Success(Signup.create!(email: args[:email])) }
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 { |args| Success("confirmed") }
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. A reader that returns `Failure` triggers
546
- compensation normally. The independence cuts both ways: async dispatches never
547
- enter the parent's undo stack, so a parent rolling back for its own reasons
548
- never "undoes" a unit that runs (and may still succeed) elsewhere.
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 |args|
568
- args[:account].success? ? Success(args[:account].value) : Failure(args[:account].error)
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 { |args| RequestService.submit(args) }
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 |args|
682
- args[:decision] == 'approved' ? Success() : Failure("Rejected")
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 { |args| PaymentGateway.refund(args[:order_id]) }
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 { |args| Geocoder.lookup(args[:address]) }
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 { |args| Billing.generate(args[:org_id]) }
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 { |args| Stripe.charge(args[:account_id]) }
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 { |args| Ledger.apply(args[:transaction]) }
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 { |args| Stripe.charge(args[:account_id]) }
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 |args, _ctx|
845
- next Halt(reason: "user_opted_out") if args[:user].opted_out?
846
- Success(args[:user])
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 skip a *single* step while the reactor continues — the step did nothing, but the rest of the workflow should still run — return `Skipped(value)` instead. The value flows to dependants exactly like a `Success` value, and the step is not enrolled for rollback:
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 |args, _ctx|
857
- next Skipped(args[:user]) if args[:user].already_synced?
858
- Success(sync!(args[:user]))
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 { |args| transform_item(args[:item]) }
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 { |args| args[:user].archive! }
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 |args|
925
- puts "Archived: #{args[:results].successes.count}"
926
- puts "Failed: #{args[:results].failures.count}"
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 |args, context|
1022
+ run do |inputs, context|
1009
1023
  profile = {
1010
- name: args[:name],
1011
- email: args[:email],
1012
- age: args[:age],
1013
- bio: args[:bio] || "No bio provided",
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 { |args, _| charge!(args) }
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 |args, context|
1218
+ run do |inputs, context|
1205
1219
  # Check if user exists and has permission to purchase
1206
- user = find_user(args[:user_id])
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 |args, context|
1215
- products = args[:product_ids].map { |id| find_product(id) }
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 |args, context|
1228
- total = args[:products].sum { |p| p[:price] }
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 |args, context|
1237
- available = args[:products].all? { |p| p[:stock] > 0 }
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 |args, context|
1260
+ run do |inputs, context|
1247
1261
  # Process payment logic here
1248
- payment_id = process_payment(args[:user][:id], args[:total])
1262
+ payment_id = process_payment(inputs.user[:id], inputs.total)
1249
1263
  Success(payment_id)
1250
1264
  end
1251
1265
 
1252
- undo do |error, args, context|
1266
+ undo do |error, inputs, context|
1253
1267
  # Refund payment on failure
1254
- refund_payment(args[:payment_id])
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 |args, context|
1265
- order = create_order_record(args[:user], args[:products], args[:payment_id])
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, args, context|
1283
+ undo do |error, inputs, context|
1270
1284
  # Cancel order and update inventory
1271
- cancel_order(args[:order][:id])
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 |args, context|
1280
- args[:products].each { |p| decrement_stock(p[:id]) }
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, args, context|
1298
+ undo do |error, inputs, context|
1285
1299
  # Restock products
1286
- args[:products].each { |p| increment_stock(p[:id]) }
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 |args, context|
1296
- send_email(args[:user][:email], "Order confirmed", order_details(args[:order]))
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 |args, context|
1320
- from = find_account(args[:from_account])
1321
- to = find_account(args[:to_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 |args, context|
1336
- if args[:accounts][:from][:balance] >= args[:amount]
1337
- Success(args[:accounts])
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 |args, context|
1349
- debit(args[:accounts][:from][:id], args[:amount])
1350
- Success(args[:accounts])
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, args, context|
1367
+ undo do |error, inputs, context|
1354
1368
  # Credit the amount back
1355
- credit(args[:accounts][:from][:id], args[:amount])
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 |args, context|
1365
- credit(args[:accounts][:to][:id], args[:amount])
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, args, context|
1383
+ undo do |error, inputs, context|
1370
1384
  # Debit the amount back from recipient
1371
- debit(args[:accounts][:to][:id], args[:amount])
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 |args, context|
1381
- Notify.to(args[:accounts][:from])
1382
- Notify.to(args[:accounts][: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`. See
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 |args, context|
1434
- Success(args[:user])
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 configure retries at the reactor or step level, and how background retries work without blocking workers.
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
- steps[name] = builder.build(async_dispatch: :step)
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