ruby_reactor 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +199 -30
  5. data/lib/ruby_reactor/configuration.rb +7 -0
  6. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  7. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  8. data/lib/ruby_reactor/dsl/lockable.rb +60 -29
  9. data/lib/ruby_reactor/dsl/reactor.rb +38 -7
  10. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  11. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  12. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  13. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  14. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  15. data/lib/ruby_reactor/executor/step_executor.rb +10 -5
  16. data/lib/ruby_reactor/executor.rb +145 -50
  17. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  18. data/lib/ruby_reactor/rate_limit.rb +28 -0
  19. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  20. data/lib/ruby_reactor/reactor.rb +41 -0
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
  28. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
  29. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  30. data/lib/ruby_reactor/validation/base.rb +4 -1
  31. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  32. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  33. data/lib/ruby_reactor/version.rb +1 -1
  34. data/lib/ruby_reactor.rb +1 -0
  35. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 134fc0713b22bad85e193592696b24417cb3c30afe8e1e7cec41328df4dafeb5
4
- data.tar.gz: 469b9dc316a61f0814b964ff1afd9292bf4a98790807e367ffda3c575c62e4f5
3
+ metadata.gz: 66c0a0c5591cd862dc61063d752f4d92111370808256bfdae749825fec68429b
4
+ data.tar.gz: 212ac4ce7ef87e5d28606cab0aff8358afde437389fbb5b2d717b1b5874daa77
5
5
  SHA512:
6
- metadata.gz: 6c28f483542f87de327e23554783dfffc1d92b89c181c85d1317763aaa0a4f824f07cfbc8dbc9589f9b814bae2d4debe11acaf87c84a9b9f6b7a501110a71ff4
7
- data.tar.gz: b856bce9fb30b8e2e8e1b27d01b4801ffa87e95b2214cccddd73d1b0447b5801dd3f90a722977e6955786b81e70b60ac2339450552df216df37fb334a05735b5
6
+ metadata.gz: 75e3bd7ead2281ef7bd1a74fe42a1aaaa1ff5ac92db2b72172e779b0fa9591878268b9829c939def027a6d368ad0ad7b00eddaa05bbb5497f0678229b68b17c0
7
+ data.tar.gz: 46c44e73bef1e7f2a11d7a5de51de83a2a9a2505aa8c5b06cf64215bc97c4c272f67f23a220187431a188a6dcca216ee13741566fc61e1ead6a8d7dc7b392d74
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.0"
2
+ ".": "0.5.2"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2](https://github.com/arturictus/ruby_reactor/compare/v0.5.1...v0.5.2) (2026-06-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * Nonce lock ([#26](https://github.com/arturictus/ruby_reactor/issues/26)) ([5925cac](https://github.com/arturictus/ruby_reactor/commit/5925cac7af93f59be6c0a8a98ab020f96080f60b))
9
+
10
+ ## [0.5.1](https://github.com/arturictus/ruby_reactor/compare/v0.5.0...v0.5.1) (2026-06-14)
11
+
12
+
13
+ ### Features
14
+
15
+ * streamline input validation DSL and enhance error handling ([#35](https://github.com/arturictus/ruby_reactor/issues/35)) ([e32f3ec](https://github.com/arturictus/ruby_reactor/commit/e32f3ec91d87cf7a5060558ee705089f1dc76ca6))
16
+
3
17
  ## [0.5.0](https://github.com/arturictus/ruby_reactor/compare/v0.4.1...v0.5.0) (2026-06-11)
4
18
 
5
19
 
data/README.md CHANGED
@@ -24,7 +24,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
24
24
  - **Compensation**: Automatic rollback of completed steps when a failure occurs.
25
25
  - **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
26
26
  - **Input Validation**: Integrated with `dry-validation` for robust input checking.
27
- - **Distributed Locks, Semaphores, Rate Limits & Periods**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), and `with_period` to dedup reactors to once per calendar bucket (once per day/month/year/etc). Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
27
+ - **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. Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
28
28
 
29
29
  ## Comparison
30
30
 
@@ -58,7 +58,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
58
58
  - [Full Reactor Async](#full-reactor-async)
59
59
  - [Step-Level Async](#step-level-async)
60
60
  - [Interrupts (Pause & Resume)](#interrupts-pause--resume)
61
- - [Locks & Semaphores](#locks--semaphores)
61
+ - [Locks, Semaphores & Ordered Locks](#locks-semaphores--ordered-locks)
62
62
  - [Map & Parallel Execution](#map--parallel-execution)
63
63
  - [Map with Dynamic Source (ActiveRecord)](#map-with-dynamic-source-activerecord)
64
64
  - [Input Validation](#input-validation)
@@ -92,29 +92,51 @@ Or install it yourself as:
92
92
 
93
93
  Configure RubyReactor with your Sidekiq and Redis settings:
94
94
 
95
+ Every setting below is **optional** — RubyReactor ships with the defaults shown. Override only what you need.
96
+
95
97
  ```ruby
96
98
  RubyReactor.configure do |config|
97
- # Redis configuration for state persistence
99
+ # Storage adapter. Default: :redis (the only adapter shipped today).
98
100
  config.storage.adapter = :redis
101
+ # Redis URL. Default: "redis://localhost:6379/0".
99
102
  config.storage.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
103
+ # Extra options passed to Redis.new. Default: {}.
100
104
  config.storage.redis_options = { timeout: 1 }
101
105
 
102
- # Sidekiq configuration for async execution
106
+ # Sidekiq queue used by RubyReactor's async worker. Default: :default.
103
107
  config.sidekiq_queue = :default
108
+ # Sidekiq retry count for infrastructure failures only (deserialization,
109
+ # Redis, network). Step retries are managed separately. Default: 3.
104
110
  config.sidekiq_retry_count = 3
105
111
 
106
- # Lock contention snooze behavior for async reactors. When a Sidekiq worker
107
- # cannot acquire a lock or semaphore, it re-enqueues itself with this delay
108
- # (plus jitter) up to `lock_snooze_max_attempts` times before giving up.
112
+ # Lock/semaphore/rate-limit/ordered-lock contention snooze behavior for
113
+ # async reactors. When a Sidekiq worker cannot acquire a primitive it
114
+ # re-enqueues itself with `lock_snooze_base_delay + rand(0..lock_snooze_jitter)`
115
+ # seconds (rate-limit uses a precise `retry_after_seconds` hint from the error;
116
+ # ordered-lock waits re-poll at the base delay so a successor catches its
117
+ # blocker finishing fast), up to `lock_snooze_max_attempts` times before
118
+ # marking the context :failed. Defaults: 5 / 5 / 20. Set max_attempts to
119
+ # :infinity to never give up.
109
120
  config.lock_snooze_base_delay = 5
110
121
  config.lock_snooze_jitter = 5
111
122
  config.lock_snooze_max_attempts = 20
112
123
 
113
- # Logger configuration
124
+ # Named rate limits shared across reactors. Reference them with
125
+ # `with_rate_limit(:stripe)`. See Locks, Semaphores, Rate Limits & Periods.
126
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
127
+
128
+ # Logger. Default: Logger.new($stderr).
114
129
  config.logger = Logger.new($stdout)
130
+
131
+ # Async router. Default: RubyReactor::SidekiqAdapter. Swap for a custom
132
+ # adapter if you don't use Sidekiq — the adapter only needs to respond to
133
+ # `perform_async(serialized_context, reactor_class_name, **)`.
134
+ # config.async_router = MyCustomAdapter
115
135
  end
116
136
  ```
117
137
 
138
+ You can also leave out the `configure` block entirely — defaults work for local development against a Redis on `localhost:6379`.
139
+
118
140
 
119
141
  ## Quick Start
120
142
 
@@ -355,14 +377,15 @@ ApprovalReactor.continue_by_correlation_id(
355
377
  )
356
378
  ```
357
379
 
358
- ### Locks & Semaphores
380
+ ### Locks, Semaphores & Ordered Locks
359
381
 
360
382
  Coordinate across processes with Redis-backed primitives:
361
383
 
362
384
  - **`with_lock`** — at-most-one runner per key at a time (concurrency control).
363
385
  - **`with_semaphore`** — cap total concurrent runners per key (capacity control).
364
- - **`with_rate_limit`** — fixed-window rate limit, single or multi-window ("3/sec AND 100/min").
386
+ - **`with_rate_limit`** — fixed-window rate limit, single or multi-window ("3/sec AND 100/min"). Inline per-reactor, or reference a named limit registered once in `RubyReactor.configure` and shared across reactors.
365
387
  - **`with_period`** — run at most once per calendar bucket (dedup / once-per-day, once-per-month, etc).
388
+ - **`with_ordered_lock`** — strict transaction ordering via a monotonically increasing nonce assigned at enqueue. Workers can only proceed when their nonce equals `last_completed + 1`.
366
389
 
367
390
  ```ruby
368
391
  class RefundOrderReactor < RubyReactor::Reactor
@@ -419,12 +442,55 @@ class ChargeReactor < RubyReactor::Reactor
419
442
  run { |args| Stripe.charge(args[:account_id]) }
420
443
  end
421
444
  end
445
+
446
+ class OrderedTransactionReactor < RubyReactor::Reactor
447
+ async
448
+ input :account_id
449
+ input :transaction
450
+
451
+ # Strict order: a monotonically increasing nonce is assigned at enqueue
452
+ # time (inside `Reactor.run`). Workers only execute when their nonce
453
+ # equals last_completed + 1; otherwise they snooze. After the sequence
454
+ # fully drains the counter resets to 0.
455
+ with_ordered_lock(poison_pill_timeout: 300) { |inputs| "txs:#{inputs[:account_id]}" }
456
+
457
+ step :apply do
458
+ argument :transaction, input(:transaction)
459
+ run { |args| Ledger.apply(args[:transaction]) }
460
+ end
461
+ end
462
+
463
+ # Caller-side order is preserved; the worker pool may pick jobs in any order
464
+ # but the gate enforces sequential execution per key.
465
+ [tx1, tx2, tx3].each { |tx| OrderedTransactionReactor.run(account_id: 42, transaction: tx) }
422
466
  ```
423
467
 
468
+ **Named global limits.** When several reactors hit the same external service, register the limit once and reference it by name. The name is the shared key base, so every reactor throttles against one bucket:
469
+
470
+ ```ruby
471
+ RubyReactor.configure do |config|
472
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
473
+ config.rate_limits.register(:twilio, limit: 10, period: :second)
474
+ end
475
+
476
+ class ChargeReactor < RubyReactor::Reactor
477
+ input :account_id
478
+
479
+ with_rate_limit(:stripe) # shared :stripe quota across every reactor
480
+
481
+ step :charge do
482
+ argument :account_id, input(:account_id)
483
+ run { |args| Stripe.charge(args[:account_id]) }
484
+ end
485
+ end
486
+ ```
487
+
488
+ Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so async snooze behavior is identical.
489
+
424
490
  On contention:
425
491
 
426
- - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError`.
427
- - **Async** (Sidekiq) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay is the error's `retry_after_seconds` (precise wakeup); for locks/semaphores it's `lock_snooze_base_delay + jitter`. Snoozes do not count against the Sidekiq retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed.
492
+ - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
493
+ - **Async** (Sidekiq) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay uses the error's `retry_after_seconds` hint (precise wakeup — the bucket roll time is known exactly); for locks, semaphores, and ordered-lock waits it's `lock_snooze_base_delay + jitter` (a short re-poll, since a held lock or a live blocker nonce typically clears in milliseconds). Snoozes do not count against the Sidekiq retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
428
494
 
429
495
  On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
430
496
 
@@ -446,7 +512,7 @@ step :ensure_active do
446
512
  end
447
513
  ```
448
514
 
449
- See [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, and operational notes.
515
+ 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.
450
516
 
451
517
  ### Map & Parallel Execution
452
518
 
@@ -513,26 +579,73 @@ end
513
579
 
514
580
  ### Input Validation
515
581
 
516
- RubyReactor integrates with dry-validation for input validation:
582
+ RubyReactor integrates with dry-validation for input validation. A single
583
+ `input` method escalates from a bare declaration to a full nested schema.
584
+ Name and optionality are declared once.
585
+
586
+ **Form 0 — declaration only** (no validation, value passes through as-is):
517
587
 
518
588
  ```ruby
519
- class ValidatedUserReactor < RubyReactor::Reactor
520
- input :name do
521
- required(:name).filled(:string, min_size?: 2)
522
- end
589
+ input :user
590
+ input :payload, redact: true
591
+ ```
523
592
 
524
- input :email do
525
- required(:email).filled(:string)
526
- end
593
+ **Form 1 — inline scalar** (the common case). The type is an optional
594
+ positional; any keyword ending in `?` is a dry-schema predicate:
527
595
 
528
- input :age do
529
- required(:age).filled(:integer, gteq?: 18)
530
- end
596
+ ```ruby
597
+ input :name, :string, min_size?: 2
598
+ input :email, :string, format?: /\A[^@\s]+@[^@\s]+\z/
599
+ input :age, :integer, gteq?: 18
600
+
601
+ # optional: true flips required(...).filled -> optional(...).maybe
602
+ input :bio, :string, optional: true, max_size?: 100
603
+ ```
604
+
605
+ **Form 1b — class / module** maps to a `type?` instance check:
606
+
607
+ ```ruby
608
+ input :user, User # required(:user).filled(type?: User)
609
+ input :items, Array, min_size?: 1 # instance check + predicate
610
+ ```
531
611
 
532
- # Optional inputs
533
- input :bio, optional: true do
534
- optional(:bio).maybe(:string, max_size?: 100)
612
+ **Form 2 — macro block** for nested / complex schemas. The block receives the
613
+ input's macro (already bound to the name and optionality), and because it is a
614
+ block argument your class constants and helpers stay reachable:
615
+
616
+ ```ruby
617
+ EMAIL = /\A[^@\s]+@[^@\s]+\z/
618
+
619
+ input :order do |i|
620
+ i.hash do
621
+ required(:customer).hash do
622
+ required(:email).filled(:string, format?: EMAIL)
623
+ end
624
+ required(:items).each do
625
+ schema { required(:product_id).filled(:string) }
626
+ end
535
627
  end
628
+ end
629
+ ```
630
+
631
+ **Form 3 — pre-built schema / contract** (also the home for coercing dry-types):
632
+
633
+ ```ruby
634
+ UserSchema = Dry::Schema.Params do
635
+ required(:user).hash { required(:email).filled(:string) }
636
+ end
637
+
638
+ input :user, validate: UserSchema
639
+ ```
640
+
641
+ Putting the inline forms together:
642
+
643
+ ```ruby
644
+ class ValidatedUserReactor < RubyReactor::Reactor
645
+ input :name, :string, min_size?: 2
646
+ input :email, :string
647
+ input :age, :integer, gteq?: 18
648
+ input :bio, :string, optional: true, max_size?: 100
536
649
 
537
650
  step :create_profile do
538
651
  argument :name, input(:name)
@@ -571,6 +684,62 @@ result = ValidatedUserReactor.run(
571
684
  )
572
685
  ```
573
686
 
687
+ All validation failures — inputs, step arguments, step output, and interrupt
688
+ payloads — surface uniformly as a `RubyReactor::Error::InputValidationError`
689
+ with a `field_errors` hash:
690
+
691
+ ```ruby
692
+ result = ValidatedUserReactor.run(name: "A")
693
+ result.error # => RubyReactor::Error::InputValidationError
694
+ result.error.field_errors[:name] # => "size cannot be less than 2"
695
+ ```
696
+
697
+ ### Step Argument & Output Validation
698
+
699
+ Arguments can be validated inline using the same forms as `input`. Inline rules
700
+ compose with a `validate_args` block (used for cross-field rules):
701
+
702
+ ```ruby
703
+ step :charge do
704
+ argument :amount, input(:amount), :decimal, gt?: 0
705
+ argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
706
+ argument :user, input(:user), User # type? instance check
707
+
708
+ # Optional cross-field block (composes with the inline rules above)
709
+ validate_args do
710
+ required(:amount).filled(:decimal, lt?: 10_000)
711
+ end
712
+
713
+ run { |args, _| charge!(args) }
714
+ end
715
+ ```
716
+
717
+ Output validation is scalar-aware — pass a type/predicates for a single value,
718
+ or a block for a hash output:
719
+
720
+ ```ruby
721
+ validate_output :integer, gteq?: 0 # scalar return value
722
+ validate_output do # hash return value
723
+ required(:id).filled(:string)
724
+ end
725
+ ```
726
+
727
+ ### Interrupt Payload Validation
728
+
729
+ Validate the payload supplied on resume with `validate_payload` (the older
730
+ `validate` is kept as a deprecated alias):
731
+
732
+ ```ruby
733
+ interrupt :await_approval do
734
+ wait_for :submit_request
735
+
736
+ validate_payload do
737
+ required(:approved).filled(:bool)
738
+ optional(:note).maybe(:string)
739
+ end
740
+ end
741
+ ```
742
+
574
743
  ### Complex Workflows with Dependencies
575
744
 
576
745
  Steps can depend on results from multiple other steps:
@@ -578,7 +747,7 @@ Steps can depend on results from multiple other steps:
578
747
  ```ruby
579
748
  class OrderProcessingReactor < RubyReactor::Reactor
580
749
  input :user_id
581
- input :product_ids, validate: ->(ids) { ids.is_a?(Array) && ids.any? }
750
+ input :product_ids, Array, min_size?: 1
582
751
 
583
752
  step :validate_user do
584
753
  argument :user_id, input(:user_id)
@@ -857,9 +1026,9 @@ Learn how to pause and resume reactors to handle long-running processes, manual
857
1026
  ### [Testing with RSpec](documentation/testing.md)
858
1027
  Comprehensive guide to testing reactors with RubyReactor's testing utilities. Learn about the `TestSubject` class for reactor execution and introspection, step mocking for isolating dependencies, testing nested and composed reactors, and custom RSpec matchers like `be_success`, `have_run_step`, and `have_retried_step`.
859
1028
 
860
- ### [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md)
1029
+ ### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
861
1030
 
862
- Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), and calendar-bucketed dedup (`with_period`, returning `Skipped` results). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, and once-per-day/month/year scheduling patterns.
1031
+ 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 `Skipped` 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-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.
863
1032
 
864
1033
  ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
865
1034
 
@@ -59,5 +59,12 @@ module RubyReactor
59
59
  def middlewares
60
60
  @middlewares ||= []
61
61
  end
62
+
63
+ # Registry of named rate limits shared across reactors. Configure entries
64
+ # with `config.rate_limits.register(:name, ...)` and reference them from a
65
+ # reactor via `with_rate_limit(:name)`.
66
+ def rate_limits
67
+ @rate_limits ||= RubyReactor::RateLimitRegistry.new
68
+ end
62
69
  end
63
70
  end
@@ -41,6 +41,7 @@ module RubyReactor
41
41
  end
42
42
 
43
43
  def build
44
+ warn_if_child_has_ordered_lock!
44
45
  dependencies = extract_dependencies_from_mappings
45
46
 
46
47
  step_config = {
@@ -78,6 +79,25 @@ module RubyReactor
78
79
 
79
80
  private
80
81
 
82
+ # Composed children bypass `Reactor#run`, so `assign_ordered_lock_nonce!`
83
+ # never fires for them — their `with_ordered_lock` declaration is silently
84
+ # ignored. Surface this at class load so users don't expect ordering
85
+ # enforcement that isn't happening. Nested ordered-lock sequences must be
86
+ # invoked as top-level `Reactor.run` to participate.
87
+ def warn_if_child_has_ordered_lock!
88
+ return unless @composed_reactor_class
89
+ return unless @composed_reactor_class.respond_to?(:ordered_lock_config)
90
+ return unless @composed_reactor_class.ordered_lock_config
91
+
92
+ parent_name = @reactor&.name || "<anonymous>"
93
+ child_name = @composed_reactor_class.name || "<anonymous>"
94
+ RubyReactor.configuration.logger.warn(
95
+ "RubyReactor: `with_ordered_lock` on #{child_name} is ignored when " \
96
+ "composed by #{parent_name}##{@name}. Nested ordered-lock sequences " \
97
+ "are independent and must run via top-level `Reactor.run` to be enforced."
98
+ )
99
+ end
100
+
81
101
  def ensure_composed_reactor_class!
82
102
  raise ArgumentError, "No block provided for inline compose" unless @composed_reactor_class
83
103
  end
@@ -23,9 +23,25 @@ module RubyReactor
23
23
  @timeout_config = { duration: seconds, strategy: strategy }
24
24
  end
25
25
 
26
- def validate(&block)
26
+ # Validate the resume payload. Accepts a block (schema DSL) or a pre-built
27
+ # dry-schema. Payloads are multi-field hashes, so the block stays primary.
28
+ def validate_payload(schema = nil, &block)
27
29
  check_dry_validation_available!
28
- @validation_schema = build_validation_schema(&block)
30
+ @validation_schema =
31
+ if block
32
+ build_validation_schema(&block)
33
+ elsif schema
34
+ RubyReactor::Validation::SchemaBuilder.schema_for(schema)
35
+ end
36
+ end
37
+
38
+ # Deprecated alias for {#validate_payload}.
39
+ def validate(schema = nil, &block)
40
+ unless @warned_validate
41
+ @warned_validate = true
42
+ warn "[RubyReactor] DEPRECATION: interrupt `validate` is deprecated; use `validate_payload` instead."
43
+ end
44
+ validate_payload(schema, &block)
29
45
  end
30
46
 
31
47
  def build
@@ -8,7 +8,7 @@ module RubyReactor
8
8
  end
9
9
 
10
10
  module ClassMethods
11
- attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config
11
+ attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config, :ordered_lock_config
12
12
 
13
13
  # Propagate lock/semaphore/period/rate-limit config to subclasses;
14
14
  # without this a subclass of a configured reactor would silently lose
@@ -19,6 +19,7 @@ module RubyReactor
19
19
  subclass.instance_variable_set(:@semaphore_config, @semaphore_config) if @semaphore_config
20
20
  subclass.instance_variable_set(:@period_config, @period_config) if @period_config
21
21
  subclass.instance_variable_set(:@rate_limit_config, @rate_limit_config) if @rate_limit_config
22
+ subclass.instance_variable_set(:@ordered_lock_config, @ordered_lock_config) if @ordered_lock_config
22
23
  end
23
24
 
24
25
  # Configure locking for this reactor
@@ -74,6 +75,45 @@ module RubyReactor
74
75
  }
75
76
  end
76
77
 
78
+ # Configure strict-ordering nonce gating for this reactor. A
79
+ # monotonically increasing nonce is assigned at enqueue time; the
80
+ # worker can only proceed when its nonce equals `last_completed + 1`.
81
+ # Otherwise the worker raises {OrderedLock::WaitError} and the Sidekiq
82
+ # worker snoozes via `perform_in`.
83
+ #
84
+ # Counters reset to 0 once the sequence fully drains (last_completed
85
+ # catches up to next). Re-entrancy is NOT supported — a nested reactor
86
+ # with its own `with_ordered_lock` is an independent sequence.
87
+ #
88
+ # @param poison_pill_timeout [Integer] seconds since the blocker nonce
89
+ # was assigned before the gate auto-advances past it. Protects
90
+ # against permanent head-of-line blocking from a caller that INCRed
91
+ # the counter but crashed before enqueueing.
92
+ # @param ttl [Integer] TTL on the counter keys, refreshed on every
93
+ # assign. Only fully-drained sequences GC themselves.
94
+ # @param strict [Boolean] When true (default), if any nonce in the
95
+ # sequence terminates with a `Failure`, all subsequent nonces are
96
+ # short-circuited with `Skipped(reason: :ordered_lock_chain_failed)`
97
+ # instead of executing. This models "stop the line on the first
98
+ # problem" pipelines (e.g. ledger transactions). When false, the
99
+ # sequence keeps executing every nonce in order regardless of prior
100
+ # failures. The poison state is per-key and clears on full drain. The
101
+ # check only applies to a fresh `execute`; an already-started run
102
+ # that paused (InterruptResult/AsyncResult) completes on resume even
103
+ # if the chain failed in the meantime.
104
+ # @yield [inputs] Block that returns the ordered-lock key string.
105
+ def with_ordered_lock(poison_pill_timeout: OrderedLock::DEFAULT_POISON_PILL_TIMEOUT,
106
+ ttl: OrderedLock::DEFAULT_TTL,
107
+ strict: true,
108
+ &block)
109
+ @ordered_lock_config = {
110
+ poison_pill_timeout: poison_pill_timeout,
111
+ ttl: ttl,
112
+ strict: strict,
113
+ key_proc: block
114
+ }
115
+ end
116
+
77
117
  # Configure rate limiting for this reactor (fixed-window counter).
78
118
  # Pass either a single window via `limit:` + `period:`, or a hash of
79
119
  # windows via `limits:` for layered API quotas.
@@ -86,44 +126,35 @@ module RubyReactor
86
126
  # limits: { second: 3, minute: 100, hour: 5000 }
87
127
  # ) { |i| "stripe:#{i[:account_id]}" }
88
128
  #
129
+ # @example Named global limit (registered in `RubyReactor.configure`)
130
+ # with_rate_limit(:stripe)
131
+ #
132
+ # @param name [Symbol] reference a rate limit registered via
133
+ # `config.rate_limits.register`. When given, the limit is shared
134
+ # across every reactor using that name (the name is the key base);
135
+ # no `limit:`/`period:`/`limits:` or block is accepted.
89
136
  # @param limit [Integer] requests per period (single-window form)
90
137
  # @param period [Symbol, Integer] :second / :minute / :hour / :day /
91
138
  # :week / :month / :year, or integer seconds (single-window form)
92
139
  # @param limits [Hash{Symbol,Integer => Integer}] mapping of period
93
140
  # unit to limit (multi-window form)
94
- # @yield [inputs] Block returning the rate-limit key base.
95
- def with_rate_limit(limit: nil, period: nil, limits: nil, &block)
96
- normalized = normalize_rate_limit_args(limit, period, limits)
141
+ # @yield [inputs] Block returning the rate-limit key base (inline forms).
142
+ def with_rate_limit(name = nil, limit: nil, period: nil, limits: nil, &block)
143
+ if name
144
+ if limit || period || limits || block
145
+ raise ArgumentError, "with_rate_limit(:#{name}) references a registered limit; " \
146
+ "do not also pass :limit/:period/:limits or a block"
147
+ end
148
+
149
+ @rate_limit_config = { name: name.to_sym }
150
+ return
151
+ end
97
152
 
98
153
  @rate_limit_config = {
99
- limits: normalized,
154
+ limits: RubyReactor::RateLimit.normalize_specs(limit: limit, period: period, limits: limits),
100
155
  key_proc: block
101
156
  }
102
157
  end
103
-
104
- private
105
-
106
- def normalize_rate_limit_args(limit, period, limits)
107
- if limits
108
- raise ArgumentError, "with_rate_limit: use either :limits, or :limit + :period, not both" if limit || period
109
-
110
- limits.map do |period_key, limit_val|
111
- {
112
- period_seconds: RubyReactor::Period.period_seconds(period_key),
113
- limit: Integer(limit_val),
114
- name: period_key.to_s
115
- }
116
- end
117
- elsif limit && period
118
- [{
119
- period_seconds: RubyReactor::Period.period_seconds(period),
120
- limit: Integer(limit),
121
- name: period.to_s
122
- }]
123
- else
124
- raise ArgumentError, "with_rate_limit requires :limit + :period, or :limits"
125
- end
126
- end
127
158
  end
128
159
  end
129
160
  end
@@ -62,8 +62,8 @@ module RubyReactor
62
62
  end
63
63
 
64
64
  # rubocop:disable Metrics/ParameterLists
65
- def input(name, transform: nil, description: nil, validate: nil, optional: false, redact: false,
66
- &validation_block)
65
+ def input(name, type = nil, transform: nil, description: nil, validate: nil, optional: false, redact: false,
66
+ **predicates, &block)
67
67
  # rubocop:enable Metrics/ParameterLists
68
68
  inputs[name] = {
69
69
  transform: transform,
@@ -72,12 +72,41 @@ module RubyReactor
72
72
  redact: redact
73
73
  }
74
74
 
75
- # Handle validation
76
- return unless validate || validation_block
75
+ validator = build_input_validator_for(name, type, optional, validate, predicates, &block)
76
+ input_validations[name] = validator if validator
77
+ end
78
+
79
+ # Dispatch across the layered `input` forms:
80
+ # Form 3 — pre-built schema / contract (`validate:`)
81
+ # Form 2 — block bound to the value macro (`do |i| ... end`)
82
+ # legacy — single-key schema block (`do required(:name)... end`)
83
+ # Form 1 / 1b — inline scalar or class type
84
+ # Form 0 — declaration only (no validator)
85
+ def build_input_validator_for(name, type, optional, validate, predicates, &block)
86
+ if validate
87
+ create_input_validator(validate)
88
+ elsif block
89
+ if block.arity.nonzero?
90
+ build_macro_validator(name, optional, &block)
91
+ else
92
+ warn_deprecated_input_block
93
+ create_input_validator(block)
94
+ end
95
+ elsif type || predicates.any?
96
+ build_inline_validator(name, type, optional, predicates)
97
+ end
98
+ end
99
+ private :build_input_validator_for
100
+
101
+ def warn_deprecated_input_block
102
+ return if @warned_input_block
77
103
 
78
- validator = create_input_validator(validation_block || validate)
79
- input_validations[name] = validator
104
+ @warned_input_block = true
105
+ warn "[RubyReactor] DEPRECATION: the single-key `input :name do required(:name)... end` block is " \
106
+ "deprecated. Use the inline form (`input :name, :string, min_size?: 2`) or the macro block " \
107
+ "(`input :name do |i| ... end`) instead."
80
108
  end
109
+ private :warn_deprecated_input_block
81
110
 
82
111
  def step(name, impl = nil, &block)
83
112
  builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
@@ -149,7 +178,9 @@ module RubyReactor
149
178
  RubyReactor.Success(inputs_hash)
150
179
  else
151
180
  error = RubyReactor::Error::InputValidationError.new(errors)
152
- RubyReactor.Failure(error)
181
+ # Same shape as executor-built validation failures: expose the
182
+ # structured field errors on the Failure itself.
183
+ RubyReactor.Failure(error, validation_errors: errors, reactor_name: name)
153
184
  end
154
185
  end
155
186