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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- 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 +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- 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 +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- 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/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- 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**:
|
|
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
|
|
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
|
```
|
|
@@ -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 |
|
|
568
|
-
|
|
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 { |
|
|
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 |
|
|
682
|
-
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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 |
|
|
828
|
-
next Halt(reason: "user_opted_out") if
|
|
829
|
-
Success(
|
|
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 |
|
|
840
|
-
next Skipped(
|
|
841
|
-
Success(sync!(
|
|
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 { |
|
|
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 { |
|
|
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 |
|
|
908
|
-
puts "Archived: #{
|
|
909
|
-
puts "Failed: #{
|
|
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 |
|
|
1010
|
+
run do |inputs, context|
|
|
992
1011
|
profile = {
|
|
993
|
-
name:
|
|
994
|
-
email:
|
|
995
|
-
age:
|
|
996
|
-
bio:
|
|
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 { |
|
|
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 |
|
|
1206
|
+
run do |inputs, context|
|
|
1188
1207
|
# Check if user exists and has permission to purchase
|
|
1189
|
-
user = find_user(
|
|
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 |
|
|
1198
|
-
products =
|
|
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 |
|
|
1211
|
-
total =
|
|
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 |
|
|
1220
|
-
available =
|
|
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 |
|
|
1248
|
+
run do |inputs, context|
|
|
1230
1249
|
# Process payment logic here
|
|
1231
|
-
payment_id = process_payment(
|
|
1250
|
+
payment_id = process_payment(inputs.user[:id], inputs.total)
|
|
1232
1251
|
Success(payment_id)
|
|
1233
1252
|
end
|
|
1234
1253
|
|
|
1235
|
-
undo do |error,
|
|
1254
|
+
undo do |error, inputs, context|
|
|
1236
1255
|
# Refund payment on failure
|
|
1237
|
-
refund_payment(
|
|
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 |
|
|
1248
|
-
order = create_order_record(
|
|
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,
|
|
1271
|
+
undo do |error, inputs, context|
|
|
1253
1272
|
# Cancel order and update inventory
|
|
1254
|
-
cancel_order(
|
|
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 |
|
|
1263
|
-
|
|
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,
|
|
1286
|
+
undo do |error, inputs, context|
|
|
1268
1287
|
# Restock products
|
|
1269
|
-
|
|
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 |
|
|
1279
|
-
send_email(
|
|
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 |
|
|
1303
|
-
from = find_account(
|
|
1304
|
-
to = find_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 |
|
|
1319
|
-
if
|
|
1320
|
-
Success(
|
|
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 |
|
|
1332
|
-
debit(
|
|
1333
|
-
Success(
|
|
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,
|
|
1355
|
+
undo do |error, inputs, context|
|
|
1337
1356
|
# Credit the amount back
|
|
1338
|
-
credit(
|
|
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 |
|
|
1348
|
-
credit(
|
|
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,
|
|
1371
|
+
undo do |error, inputs, context|
|
|
1353
1372
|
# Debit the amount back from recipient
|
|
1354
|
-
debit(
|
|
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 |
|
|
1364
|
-
Notify.to(
|
|
1365
|
-
Notify.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 |
|
|
1401
|
-
Success(
|
|
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
|
|
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-
|
|
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:,
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -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"])
|