ruby_reactor 0.8.2 → 0.8.4

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +94 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +128 -93
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  16. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  18. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  19. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  20. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  21. data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
  22. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  23. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  24. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  25. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  26. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  27. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  28. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  29. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  30. data/lib/ruby_reactor/executor/result_handler.rb +34 -13
  31. data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
  32. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  33. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  34. data/lib/ruby_reactor/executor.rb +90 -20
  35. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  36. data/lib/ruby_reactor/map/helpers.rb +35 -11
  37. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  38. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  39. data/lib/ruby_reactor/retry_context.rb +31 -2
  40. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  41. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  42. data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
  43. data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
  44. data/lib/ruby_reactor/step/compose_step.rb +22 -7
  45. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  46. data/lib/ruby_reactor/step/inputs.rb +57 -0
  47. data/lib/ruby_reactor/step/map_step.rb +32 -22
  48. data/lib/ruby_reactor/step.rb +59 -11
  49. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  50. data/lib/ruby_reactor/step_worker.rb +260 -37
  51. data/lib/ruby_reactor/version.rb +1 -1
  52. data/lib/ruby_reactor/web/api.rb +72 -7
  53. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  54. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  55. data/lib/ruby_reactor/web/public/index.html +1 -1
  56. data/lib/ruby_reactor/worker.rb +56 -30
  57. data/lib/ruby_reactor.rb +30 -6
  58. data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
  59. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  60. data/specs/006-step-retry-declarations/data-model.md +58 -0
  61. data/specs/006-step-retry-declarations/plan.md +187 -0
  62. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  63. data/specs/006-step-retry-declarations/research.md +194 -0
  64. data/specs/006-step-retry-declarations/spec.md +453 -0
  65. data/specs/006-step-retry-declarations/tasks.md +382 -0
  66. data/specs/future_improvements.md +250 -0
  67. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  68. metadata +20 -28
  69. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  70. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  71. data/specs/002-step-input-contracts/data-model.md +0 -115
  72. data/specs/002-step-input-contracts/plan.md +0 -165
  73. data/specs/002-step-input-contracts/quickstart.md +0 -170
  74. data/specs/002-step-input-contracts/research.md +0 -233
  75. data/specs/002-step-input-contracts/spec.md +0 -359
  76. data/specs/002-step-input-contracts/tasks.md +0 -367
  77. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  78. data/specs/004-inheritable-step-class/data-model.md +0 -116
  79. data/specs/004-inheritable-step-class/plan.md +0 -174
  80. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  81. data/specs/004-inheritable-step-class/research.md +0 -308
  82. data/specs/004-inheritable-step-class/spec.md +0 -316
  83. data/specs/004-inheritable-step-class/tasks.md +0 -258
  84. data/specs/active_job.md +0 -259
  85. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  86. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  87. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  88. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  89. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  90. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  91. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  92. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  93. data/specs/possible_feature.md +0 -22
data/README.md CHANGED
@@ -21,7 +21,7 @@ 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.
24
+ - **Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.
25
25
  - **Compensation**: Automatic rollback of completed steps when a failure occurs.
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.
@@ -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
  ```
@@ -564,8 +566,8 @@ class SignupReactor < RubyReactor::Reactor
564
566
 
565
567
  step :verify do
566
568
  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)
569
+ run do |inputs|
570
+ inputs.account.success? ? Success(inputs.account.value) : Failure(inputs.account.error)
569
571
  end
570
572
  end
571
573
  end
@@ -667,7 +669,7 @@ Pause execution to wait for external events like webhooks or user approvals.
667
669
  ```ruby
668
670
  class ApprovalReactor < RubyReactor::Reactor
669
671
  step :submit_request do
670
- run { |args| RequestService.submit(args) }
672
+ run { |inputs| RequestService.submit(inputs.to_h) }
671
673
  end
672
674
 
673
675
  interrupt :wait_for_manager do
@@ -678,8 +680,8 @@ class ApprovalReactor < RubyReactor::Reactor
678
680
 
679
681
  step :process_decision do
680
682
  argument :decision, result(:wait_for_manager)
681
- run do |args|
682
- args[:decision] == 'approved' ? Success() : Failure("Rejected")
683
+ run do |inputs|
684
+ inputs.decision == 'approved' ? Success() : Failure("Rejected")
683
685
  end
684
686
  end
685
687
  end
@@ -716,7 +718,24 @@ class RefundOrderReactor < RubyReactor::Reactor
716
718
 
717
719
  step :refund do
718
720
  argument :order_id, input(:order_id)
719
- run { |args| PaymentGateway.refund(args[:order_id]) }
721
+ run { |inputs| PaymentGateway.refund(inputs.order_id) }
722
+ end
723
+ end
724
+
725
+ class ChargeStep < RubyReactor::Step
726
+ input :account_id
727
+
728
+ # All five macros also work declared on a STEP, not just the reactor —
729
+ # keying the critical section down to this one operation instead of the
730
+ # whole workflow. Surrounding steps (audit, notify, ...) keep overlapping
731
+ # across concurrent executions; only :charge serializes. Its undo re-takes
732
+ # the key, waiting up to `rollback_wait:` (default: `ttl`; 60 s for a
733
+ # step `with_semaphore`) — an undo that still can't run is reported on
734
+ # `Failure#rollback_failures`, never dropped.
735
+ with_lock(ttl: 60, rollback_wait: nil) { |args| "acct:#{args[:account_id]}" }
736
+
737
+ def run
738
+ Success(charge!(inputs))
720
739
  end
721
740
  end
722
741
 
@@ -728,7 +747,7 @@ class GeocodeReactor < RubyReactor::Reactor
728
747
 
729
748
  step :geocode do
730
749
  argument :address, input(:address)
731
- run { |args| Geocoder.lookup(args[:address]) }
750
+ run { |inputs| Geocoder.lookup(inputs.address) }
732
751
  end
733
752
  end
734
753
 
@@ -742,7 +761,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
742
761
 
743
762
  step :build do
744
763
  argument :org_id, input(:org_id)
745
- run { |args| Billing.generate(args[:org_id]) }
764
+ run { |inputs| Billing.generate(inputs.org_id) }
746
765
  end
747
766
  end
748
767
 
@@ -758,7 +777,7 @@ class ChargeReactor < RubyReactor::Reactor
758
777
 
759
778
  step :charge do
760
779
  argument :account_id, input(:account_id)
761
- run { |args| Stripe.charge(args[:account_id]) }
780
+ run { |inputs| Stripe.charge(inputs.account_id) }
762
781
  end
763
782
  end
764
783
 
@@ -775,7 +794,7 @@ class OrderedTransactionReactor < RubyReactor::Reactor
775
794
 
776
795
  step :apply do
777
796
  argument :transaction, input(:transaction)
778
- run { |args| Ledger.apply(args[:transaction]) }
797
+ run { |inputs| Ledger.apply(inputs.transaction) }
779
798
  end
780
799
  end
781
800
 
@@ -799,7 +818,7 @@ class ChargeReactor < RubyReactor::Reactor
799
818
 
800
819
  step :charge do
801
820
  argument :account_id, input(:account_id)
802
- run { |args| Stripe.charge(args[:account_id]) }
821
+ run { |inputs| Stripe.charge(inputs.account_id) }
803
822
  end
804
823
  end
805
824
  ```
@@ -824,9 +843,9 @@ A step's `run` block can also return `Halt(reason: "...")` to stop the reactor c
824
843
  ```ruby
825
844
  step :ensure_active do
826
845
  argument :user, result(:fetch_user)
827
- run do |args, _ctx|
828
- next Halt(reason: "user_opted_out") if args[:user].opted_out?
829
- Success(args[:user])
846
+ run do |inputs, _ctx|
847
+ next Halt(reason: "user_opted_out") if inputs.user.opted_out?
848
+ Success(inputs.user)
830
849
  end
831
850
  end
832
851
  ```
@@ -836,14 +855,14 @@ To skip a *single* step while the reactor continues — the step did nothing, bu
836
855
  ```ruby
837
856
  step :maybe_sync do
838
857
  argument :user, result(:fetch_user)
839
- run do |args, _ctx|
840
- next Skipped(args[:user]) if args[:user].already_synced?
841
- Success(sync!(args[:user]))
858
+ run do |inputs, _ctx|
859
+ next Skipped(inputs.user) if inputs.user.already_synced?
860
+ Success(sync!(inputs.user))
842
861
  end
843
862
  end
844
863
  ```
845
864
 
846
- See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, and operational notes.
865
+ See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, step-scoped coordination, and operational notes.
847
866
 
848
867
  ### Map & Parallel Execution
849
868
 
@@ -862,7 +881,7 @@ class DataProcessingReactor < RubyReactor::Reactor
862
881
 
863
882
  step :transform do
864
883
  argument :item, input(:item)
865
- run { |args| transform_item(args[:item]) }
884
+ run { |inputs| transform_item(inputs.item) }
866
885
  end
867
886
 
868
887
  returns :transform
@@ -896,7 +915,7 @@ map :archive_old_users do
896
915
 
897
916
  step :archive do
898
917
  argument :user, input(:user)
899
- run { |args| args[:user].archive! }
918
+ run { |inputs| inputs.user.archive! }
900
919
  end
901
920
 
902
921
  returns :archive
@@ -904,9 +923,9 @@ end
904
923
 
905
924
  step :summary do
906
925
  argument :results, result(:archive_old_users)
907
- run do |args|
908
- puts "Archived: #{args[:results].successes.count}"
909
- puts "Failed: #{args[:results].failures.count}"
926
+ run do |inputs|
927
+ puts "Archived: #{inputs.results.successes.count}"
928
+ puts "Failed: #{inputs.results.failures.count}"
910
929
  Success()
911
930
  end
912
931
  end
@@ -988,12 +1007,12 @@ class ValidatedUserReactor < RubyReactor::Reactor
988
1007
  argument :age, input(:age)
989
1008
  argument :bio, input(:bio)
990
1009
 
991
- run do |args, context|
1010
+ run do |inputs, context|
992
1011
  profile = {
993
- name: args[:name],
994
- email: args[:email],
995
- age: args[:age],
996
- bio: args[:bio] || "No bio provided",
1012
+ name: inputs.name,
1013
+ email: inputs.email,
1014
+ age: inputs.age,
1015
+ bio: inputs.bio || "No bio provided",
997
1016
  created_at: Time.now
998
1017
  }
999
1018
  Success(profile)
@@ -1107,7 +1126,7 @@ step :charge do
1107
1126
  argument :amount, input(:amount)
1108
1127
  argument :currency, input(:currency)
1109
1128
 
1110
- run { |args, _| charge!(args) }
1129
+ run { |inputs, _| charge!(inputs) }
1111
1130
  end
1112
1131
  ```
1113
1132
 
@@ -1184,9 +1203,9 @@ class OrderProcessingReactor < RubyReactor::Reactor
1184
1203
  step :validate_user do
1185
1204
  argument :user_id, input(:user_id)
1186
1205
 
1187
- run do |args, context|
1206
+ run do |inputs, context|
1188
1207
  # Check if user exists and has permission to purchase
1189
- user = find_user(args[:user_id])
1208
+ user = find_user(inputs.user_id)
1190
1209
  user ? Success(user) : Failure("User not found")
1191
1210
  end
1192
1211
  end
@@ -1194,8 +1213,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1194
1213
  step :validate_products do
1195
1214
  argument :product_ids, input(:product_ids)
1196
1215
 
1197
- run do |args, context|
1198
- products = args[:product_ids].map { |id| find_product(id) }
1216
+ run do |inputs, context|
1217
+ products = inputs.product_ids.map { |id| find_product(id) }
1199
1218
  if products.all?
1200
1219
  Success(products)
1201
1220
  else
@@ -1207,8 +1226,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1207
1226
  step :calculate_total do
1208
1227
  argument :products, result(:validate_products)
1209
1228
 
1210
- run do |args, context|
1211
- total = args[:products].sum { |p| p[:price] }
1229
+ run do |inputs, context|
1230
+ total = inputs.products.sum { |p| p[:price] }
1212
1231
  Success(total)
1213
1232
  end
1214
1233
  end
@@ -1216,8 +1235,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1216
1235
  step :check_inventory do
1217
1236
  argument :products, result(:validate_products)
1218
1237
 
1219
- run do |args, context|
1220
- available = args[:products].all? { |p| p[:stock] > 0 }
1238
+ run do |inputs, context|
1239
+ available = inputs.products.all? { |p| p[:stock] > 0 }
1221
1240
  available ? Success(true) : Failure("Out of stock")
1222
1241
  end
1223
1242
  end
@@ -1226,15 +1245,15 @@ class OrderProcessingReactor < RubyReactor::Reactor
1226
1245
  argument :user, result(:validate_user)
1227
1246
  argument :total, result(:calculate_total)
1228
1247
 
1229
- run do |args, context|
1248
+ run do |inputs, context|
1230
1249
  # Process payment logic here
1231
- payment_id = process_payment(args[:user][:id], args[:total])
1250
+ payment_id = process_payment(inputs.user[:id], inputs.total)
1232
1251
  Success(payment_id)
1233
1252
  end
1234
1253
 
1235
- undo do |error, args, context|
1254
+ undo do |error, inputs, context|
1236
1255
  # Refund payment on failure
1237
- refund_payment(args[:payment_id])
1256
+ refund_payment(inputs.payment_id)
1238
1257
  Success()
1239
1258
  end
1240
1259
  end
@@ -1244,14 +1263,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
1244
1263
  argument :products, result(:validate_products)
1245
1264
  argument :payment_id, result(:process_payment)
1246
1265
 
1247
- run do |args, context|
1248
- order = create_order_record(args[:user], args[:products], args[:payment_id])
1266
+ run do |inputs, context|
1267
+ order = create_order_record(inputs.user, inputs.products, inputs.payment_id)
1249
1268
  Success(order)
1250
1269
  end
1251
1270
 
1252
- undo do |error, args, context|
1271
+ undo do |error, inputs, context|
1253
1272
  # Cancel order and update inventory
1254
- cancel_order(args[:order][:id])
1273
+ cancel_order(inputs.order[:id])
1255
1274
  Success()
1256
1275
  end
1257
1276
  end
@@ -1259,14 +1278,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
1259
1278
  step :update_inventory do
1260
1279
  argument :products, result(:validate_products)
1261
1280
 
1262
- run do |args, context|
1263
- args[:products].each { |p| decrement_stock(p[:id]) }
1281
+ run do |inputs, context|
1282
+ inputs.products.each { |p| decrement_stock(p[:id]) }
1264
1283
  Success(true)
1265
1284
  end
1266
1285
 
1267
- undo do |error, args, context|
1286
+ undo do |error, inputs, context|
1268
1287
  # Restock products
1269
- args[:products].each { |p| increment_stock(p[:id]) }
1288
+ inputs.products.each { |p| increment_stock(p[:id]) }
1270
1289
  Success()
1271
1290
  end
1272
1291
  end
@@ -1275,8 +1294,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1275
1294
  argument :user, result(:validate_user)
1276
1295
  argument :order, result(:create_order)
1277
1296
 
1278
- run do |args, context|
1279
- send_email(args[:user][:email], "Order confirmed", order_details(args[:order]))
1297
+ run do |inputs, context|
1298
+ send_email(inputs.user[:email], "Order confirmed", order_details(inputs.order))
1280
1299
  Success(true)
1281
1300
  end
1282
1301
  end
@@ -1299,9 +1318,9 @@ class TransactionReactor < RubyReactor::Reactor
1299
1318
  argument :from_account, input(:from_account)
1300
1319
  argument :to_account, input(:to_account)
1301
1320
 
1302
- run do |args, context|
1303
- from = find_account(args[:from_account])
1304
- to = find_account(args[:to_account])
1321
+ run do |inputs, context|
1322
+ from = find_account(inputs.from_account)
1323
+ to = find_account(inputs.to_account)
1305
1324
 
1306
1325
  if from && to && from != to
1307
1326
  Success({from: from, to: to})
@@ -1315,9 +1334,9 @@ class TransactionReactor < RubyReactor::Reactor
1315
1334
  argument :accounts, result(:validate_accounts)
1316
1335
  argument :amount, input(:amount)
1317
1336
 
1318
- run do |args, context|
1319
- if args[:accounts][:from][:balance] >= args[:amount]
1320
- Success(args[:accounts])
1337
+ run do |inputs, context|
1338
+ if inputs.accounts[:from][:balance] >= inputs.amount
1339
+ Success(inputs.accounts)
1321
1340
  else
1322
1341
  Failure("Insufficient funds")
1323
1342
  end
@@ -1328,14 +1347,14 @@ class TransactionReactor < RubyReactor::Reactor
1328
1347
  argument :accounts, result(:check_balance)
1329
1348
  argument :amount, input(:amount)
1330
1349
 
1331
- run do |args, context|
1332
- debit(args[:accounts][:from][:id], args[:amount])
1333
- Success(args[:accounts])
1350
+ run do |inputs, context|
1351
+ debit(inputs.accounts[:from][:id], inputs.amount)
1352
+ Success(inputs.accounts)
1334
1353
  end
1335
1354
 
1336
- undo do |error, args, context|
1355
+ undo do |error, inputs, context|
1337
1356
  # Credit the amount back
1338
- credit(args[:accounts][:from][:id], args[:amount])
1357
+ credit(inputs.accounts[:from][:id], inputs.amount)
1339
1358
  Success()
1340
1359
  end
1341
1360
  end
@@ -1344,14 +1363,14 @@ class TransactionReactor < RubyReactor::Reactor
1344
1363
  argument :accounts, result(:debit_account)
1345
1364
  argument :amount, input(:amount)
1346
1365
 
1347
- run do |args, context|
1348
- credit(args[:accounts][:to][:id], args[:amount])
1366
+ run do |inputs, context|
1367
+ credit(inputs.accounts[:to][:id], inputs.amount)
1349
1368
  Success({transaction_id: generate_transaction_id()})
1350
1369
  end
1351
1370
 
1352
- undo do |error, args, context|
1371
+ undo do |error, inputs, context|
1353
1372
  # Debit the amount back from recipient
1354
- debit(args[:accounts][:to][:id], args[:amount])
1373
+ debit(inputs.accounts[:to][:id], inputs.amount)
1355
1374
  Success()
1356
1375
  end
1357
1376
  end
@@ -1360,9 +1379,9 @@ class TransactionReactor < RubyReactor::Reactor
1360
1379
  argument :accounts, result(:validate_accounts)
1361
1380
  wait_for :credit_account, :debit_account
1362
1381
 
1363
- run do |args, context|
1364
- Notify.to(args[:accounts][:from])
1365
- Notify.to(args[:accounts][:to])
1382
+ run do |inputs, context|
1383
+ Notify.to(inputs.accounts[:from])
1384
+ Notify.to(inputs.accounts[:to])
1366
1385
  end
1367
1386
 
1368
1387
  end
@@ -1376,6 +1395,22 @@ end
1376
1395
  # Result: Complete rollback of the transaction
1377
1396
  ```
1378
1397
 
1398
+ A rollback that did not complete is never silent. `Failure#rollback_failures`
1399
+ lists every undo or compensation that raised, returned a `Failure`, or could not
1400
+ re-take its step's lock/semaphore within `rollback_wait:` — including those of
1401
+ composed children (flattened). It is always an Array, empty when rollback
1402
+ completed, and is part of `Failure#to_h`:
1403
+
1404
+ ```ruby
1405
+ result = TransactionReactor.run(from_account: 1, to_account: 2, amount: 10)
1406
+ result.rollback_failures
1407
+ # => [{ step: :debit_account, kind: :undo, key: nil, reason: :raised, message: "gateway timeout" }]
1408
+ ```
1409
+
1410
+ `reason` is `:coordination_unavailable`, `:returned_failure`, or `:raised`; `kind`
1411
+ is `:undo` or `:compensate`. See
1412
+ [Step Rollback](documentation/locks_and_semaphores.md#step-rollback).
1413
+
1379
1414
  ### Using Pre-defined Schemas
1380
1415
 
1381
1416
  You can use existing dry-validation schemas:
@@ -1397,8 +1432,8 @@ class SchemaValidatedReactor < RubyReactor::Reactor
1397
1432
  step :process_user do
1398
1433
  argument :user, input(:user)
1399
1434
 
1400
- run do |args, context|
1401
- Success(args[:user])
1435
+ run do |inputs, context|
1436
+ Success(inputs.user)
1402
1437
  end
1403
1438
  end
1404
1439
 
@@ -1450,7 +1485,7 @@ Discover how to build complex, modular workflows by composing reactors within ot
1450
1485
  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.
1451
1486
 
1452
1487
  ### [Retry Configuration](documentation/retry_configuration.md)
1453
- 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.
1488
+ 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.
1454
1489
 
1455
1490
  ### [Interrupts](documentation/interrupts.md)
1456
1491
  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.
@@ -1460,7 +1495,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
1460
1495
 
1461
1496
  ### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
1462
1497
 
1463
- 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`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-background contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1498
+ 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`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Every primitive is also available [declared on a step](documentation/locks_and_semaphores.md#step-scoped-coordination) instead of the whole reactor, keying the critical section down to one operation. 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, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1464
1499
 
1465
1500
  ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
1466
1501
 
@@ -34,6 +34,25 @@ module RubyReactor
34
34
  RubyReactor::DispatchResult.new(job_id: job_id)
35
35
  end
36
36
 
37
+ # Delayed re-enqueue of an `async_step`'s body — the park path
38
+ # (Finding 6). See Sidekiq::Router#perform_step_in.
39
+ # rubocop:disable Metrics/ParameterLists
40
+ def self.perform_step_in(delay, root_context_id:, reactor_class_name:, step_context_id:, step_name:,
41
+ contention_attempts: 0)
42
+ # rubocop:enable Metrics/ParameterLists
43
+ job_id = RubyReactor::Adapters::ActiveJob::StepWorker.perform_in(
44
+ delay,
45
+ {
46
+ "root_context_id" => root_context_id,
47
+ "reactor_class_name" => reactor_class_name,
48
+ "step_context_id" => step_context_id,
49
+ "step_name" => step_name.to_s,
50
+ "contention_attempts" => contention_attempts
51
+ }
52
+ )
53
+ RubyReactor::DispatchResult.new(job_id: job_id)
54
+ end
55
+
37
56
  # rubocop:disable Metrics/ParameterLists
38
57
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
58
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
@@ -34,6 +34,27 @@ module RubyReactor
34
34
  RubyReactor::DispatchResult.new(job_id: job_id)
35
35
  end
36
36
 
37
+ # Delayed re-enqueue of an `async_step`'s body — the park path
38
+ # (Finding 6): the worker rescues `StepCoordination::Contended` and
39
+ # reschedules itself instead of failing, carrying the contention
40
+ # attempt count forward so `lock_snooze_max_attempts` still bounds it.
41
+ # rubocop:disable Metrics/ParameterLists
42
+ def self.perform_step_in(delay, root_context_id:, reactor_class_name:, step_context_id:, step_name:,
43
+ contention_attempts: 0)
44
+ # rubocop:enable Metrics/ParameterLists
45
+ job_id = RubyReactor::Adapters::Sidekiq::StepWorker.perform_in(
46
+ delay,
47
+ {
48
+ "root_context_id" => root_context_id,
49
+ "reactor_class_name" => reactor_class_name,
50
+ "step_context_id" => step_context_id,
51
+ "step_name" => step_name.to_s,
52
+ "contention_attempts" => contention_attempts
53
+ }
54
+ )
55
+ RubyReactor::DispatchResult.new(job_id: job_id)
56
+ end
57
+
37
58
  # rubocop:disable Metrics/ParameterLists
38
59
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
60
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
@@ -29,6 +29,19 @@ module RubyReactor
29
29
  :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
30
30
  :middlewares
31
31
 
32
+ # Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
33
+ # and `deserialize_from_retry` below — confirmed by
34
+ # reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
35
+ # everything coordinated with this context (research D5): set by the
36
+ # `async_step` worker to a per-job id so re-entrancy never crosses a
37
+ # process hand-off (US4-5).
38
+ attr_accessor :coordination_owner
39
+
40
+ # Transient, NOT serialized: the step whose coordination hook is firing
41
+ # right now (set by `StepCoordination#emit` for the duration of the hook),
42
+ # so a middleware can tell a step-level hold from a reactor-level one.
43
+ attr_accessor :coordinating_step
44
+
32
45
  def initialize(inputs = {}, reactor_class = nil)
33
46
  @context_id = SecureRandom.uuid
34
47
  @inputs = inputs
@@ -95,6 +108,19 @@ module RubyReactor
95
108
  @intermediate_results[step_name.to_sym] = value
96
109
  end
97
110
 
111
+ # Set once this execution's reactor-level gates (rate limit, period,
112
+ # post-lock period re-check) have passed, and never unset — it rides
113
+ # `private_data`, so it survives parks, retries, pauses and redeliveries.
114
+ # "Is this a fresh run?" reads it instead of inferring from
115
+ # `current_step`, which is only the resume cursor (005 R-03).
116
+ def admitted?
117
+ !!(private_data[:admitted] || private_data["admitted"])
118
+ end
119
+
120
+ def admit!
121
+ private_data[:admitted] = true
122
+ end
123
+
98
124
  def with_step(step_name)
99
125
  old_step = @current_step
100
126
  @current_step = step_name
@@ -57,7 +57,8 @@ module RubyReactor
57
57
  "file_path" => value.file_path,
58
58
  "line_number" => value.line_number,
59
59
  "code_snippet" => serialize_value(value.code_snippet),
60
- "validation_errors" => serialize_value(value.validation_errors)
60
+ "validation_errors" => serialize_value(value.validation_errors),
61
+ "rollback_failures" => serialize_value(value.rollback_failures)
61
62
  }
62
63
  when RubyReactor::Context
63
64
  { "_type" => "Context", "value" => value.serialize_for_retry }
@@ -140,7 +141,8 @@ module RubyReactor
140
141
  file_path: value["file_path"],
141
142
  line_number: value["line_number"],
142
143
  code_snippet: deserialize_value(value["code_snippet"]),
143
- validation_errors: deserialize_value(value["validation_errors"])
144
+ validation_errors: deserialize_value(value["validation_errors"]),
145
+ rollback_failures: deserialize_value(value["rollback_failures"])
144
146
  )
145
147
  when "Context"
146
148
  Context.deserialize_from_retry(value["value"])