ruby_reactor 0.5.0 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 134fc0713b22bad85e193592696b24417cb3c30afe8e1e7cec41328df4dafeb5
4
- data.tar.gz: 469b9dc316a61f0814b964ff1afd9292bf4a98790807e367ffda3c575c62e4f5
3
+ metadata.gz: 54ecb36ac72eedf48af0025dff29d83986449586683300ce3fe8fd874c1412d5
4
+ data.tar.gz: 5e3565e3e238bad746d93982ca7b01560893a68755be18fbfce95df6f54ce5b3
5
5
  SHA512:
6
- metadata.gz: 6c28f483542f87de327e23554783dfffc1d92b89c181c85d1317763aaa0a4f824f07cfbc8dbc9589f9b814bae2d4debe11acaf87c84a9b9f6b7a501110a71ff4
7
- data.tar.gz: b856bce9fb30b8e2e8e1b27d01b4801ffa87e95b2214cccddd73d1b0447b5801dd3f90a722977e6955786b81e70b60ac2339450552df216df37fb334a05735b5
6
+ metadata.gz: 708acdb0c74582cea4c33210ea1bac055080c7dd19c69d166db4b2c22705de2935346d6b09d4fc6c9cbb1bda5ba235cade92a88f04fe791e364bb78bda256138
7
+ data.tar.gz: e3f03e46d71babe276224571eaad3e794c7d8c695e2865333f5021e680feb21a12cd3b33527035e1349dc600d01faec87b573b691d7e50521c4f0d81610c8b8f
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.0"
2
+ ".": "0.5.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1](https://github.com/arturictus/ruby_reactor/compare/v0.5.0...v0.5.1) (2026-06-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
3
10
  ## [0.5.0](https://github.com/arturictus/ruby_reactor/compare/v0.4.1...v0.5.0) (2026-06-11)
4
11
 
5
12
 
data/README.md CHANGED
@@ -110,6 +110,10 @@ RubyReactor.configure do |config|
110
110
  config.lock_snooze_jitter = 5
111
111
  config.lock_snooze_max_attempts = 20
112
112
 
113
+ # Named rate limits shared across reactors. Reference them with
114
+ # `with_rate_limit(:stripe)`. See Locks, Semaphores, Rate Limits & Periods.
115
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
116
+
113
117
  # Logger configuration
114
118
  config.logger = Logger.new($stdout)
115
119
  end
@@ -361,7 +365,7 @@ Coordinate across processes with Redis-backed primitives:
361
365
 
362
366
  - **`with_lock`** — at-most-one runner per key at a time (concurrency control).
363
367
  - **`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").
368
+ - **`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
369
  - **`with_period`** — run at most once per calendar bucket (dedup / once-per-day, once-per-month, etc).
366
370
 
367
371
  ```ruby
@@ -421,6 +425,28 @@ class ChargeReactor < RubyReactor::Reactor
421
425
  end
422
426
  ```
423
427
 
428
+ **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:
429
+
430
+ ```ruby
431
+ RubyReactor.configure do |config|
432
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
433
+ config.rate_limits.register(:twilio, limit: 10, period: :second)
434
+ end
435
+
436
+ class ChargeReactor < RubyReactor::Reactor
437
+ input :account_id
438
+
439
+ with_rate_limit(:stripe) # shared :stripe quota across every reactor
440
+
441
+ step :charge do
442
+ argument :account_id, input(:account_id)
443
+ run { |args| Stripe.charge(args[:account_id]) }
444
+ end
445
+ end
446
+ ```
447
+
448
+ Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so async snooze behavior is identical.
449
+
424
450
  On contention:
425
451
 
426
452
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError`.
@@ -513,26 +539,73 @@ end
513
539
 
514
540
  ### Input Validation
515
541
 
516
- RubyReactor integrates with dry-validation for input validation:
542
+ RubyReactor integrates with dry-validation for input validation. A single
543
+ `input` method escalates from a bare declaration to a full nested schema.
544
+ Name and optionality are declared once.
545
+
546
+ **Form 0 — declaration only** (no validation, value passes through as-is):
517
547
 
518
548
  ```ruby
519
- class ValidatedUserReactor < RubyReactor::Reactor
520
- input :name do
521
- required(:name).filled(:string, min_size?: 2)
522
- end
549
+ input :user
550
+ input :payload, redact: true
551
+ ```
523
552
 
524
- input :email do
525
- required(:email).filled(:string)
526
- end
553
+ **Form 1 — inline scalar** (the common case). The type is an optional
554
+ positional; any keyword ending in `?` is a dry-schema predicate:
527
555
 
528
- input :age do
529
- required(:age).filled(:integer, gteq?: 18)
530
- end
556
+ ```ruby
557
+ input :name, :string, min_size?: 2
558
+ input :email, :string, format?: /\A[^@\s]+@[^@\s]+\z/
559
+ input :age, :integer, gteq?: 18
560
+
561
+ # optional: true flips required(...).filled -> optional(...).maybe
562
+ input :bio, :string, optional: true, max_size?: 100
563
+ ```
531
564
 
532
- # Optional inputs
533
- input :bio, optional: true do
534
- optional(:bio).maybe(:string, max_size?: 100)
565
+ **Form 1b — class / module** maps to a `type?` instance check:
566
+
567
+ ```ruby
568
+ input :user, User # required(:user).filled(type?: User)
569
+ input :items, Array, min_size?: 1 # instance check + predicate
570
+ ```
571
+
572
+ **Form 2 — macro block** for nested / complex schemas. The block receives the
573
+ input's macro (already bound to the name and optionality), and because it is a
574
+ block argument your class constants and helpers stay reachable:
575
+
576
+ ```ruby
577
+ EMAIL = /\A[^@\s]+@[^@\s]+\z/
578
+
579
+ input :order do |i|
580
+ i.hash do
581
+ required(:customer).hash do
582
+ required(:email).filled(:string, format?: EMAIL)
583
+ end
584
+ required(:items).each do
585
+ schema { required(:product_id).filled(:string) }
586
+ end
535
587
  end
588
+ end
589
+ ```
590
+
591
+ **Form 3 — pre-built schema / contract** (also the home for coercing dry-types):
592
+
593
+ ```ruby
594
+ UserSchema = Dry::Schema.Params do
595
+ required(:user).hash { required(:email).filled(:string) }
596
+ end
597
+
598
+ input :user, validate: UserSchema
599
+ ```
600
+
601
+ Putting the inline forms together:
602
+
603
+ ```ruby
604
+ class ValidatedUserReactor < RubyReactor::Reactor
605
+ input :name, :string, min_size?: 2
606
+ input :email, :string
607
+ input :age, :integer, gteq?: 18
608
+ input :bio, :string, optional: true, max_size?: 100
536
609
 
537
610
  step :create_profile do
538
611
  argument :name, input(:name)
@@ -571,6 +644,62 @@ result = ValidatedUserReactor.run(
571
644
  )
572
645
  ```
573
646
 
647
+ All validation failures — inputs, step arguments, step output, and interrupt
648
+ payloads — surface uniformly as a `RubyReactor::Error::InputValidationError`
649
+ with a `field_errors` hash:
650
+
651
+ ```ruby
652
+ result = ValidatedUserReactor.run(name: "A")
653
+ result.error # => RubyReactor::Error::InputValidationError
654
+ result.error.field_errors[:name] # => "size cannot be less than 2"
655
+ ```
656
+
657
+ ### Step Argument & Output Validation
658
+
659
+ Arguments can be validated inline using the same forms as `input`. Inline rules
660
+ compose with a `validate_args` block (used for cross-field rules):
661
+
662
+ ```ruby
663
+ step :charge do
664
+ argument :amount, input(:amount), :decimal, gt?: 0
665
+ argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
666
+ argument :user, input(:user), User # type? instance check
667
+
668
+ # Optional cross-field block (composes with the inline rules above)
669
+ validate_args do
670
+ required(:amount).filled(:decimal, lt?: 10_000)
671
+ end
672
+
673
+ run { |args, _| charge!(args) }
674
+ end
675
+ ```
676
+
677
+ Output validation is scalar-aware — pass a type/predicates for a single value,
678
+ or a block for a hash output:
679
+
680
+ ```ruby
681
+ validate_output :integer, gteq?: 0 # scalar return value
682
+ validate_output do # hash return value
683
+ required(:id).filled(:string)
684
+ end
685
+ ```
686
+
687
+ ### Interrupt Payload Validation
688
+
689
+ Validate the payload supplied on resume with `validate_payload` (the older
690
+ `validate` is kept as a deprecated alias):
691
+
692
+ ```ruby
693
+ interrupt :await_approval do
694
+ wait_for :submit_request
695
+
696
+ validate_payload do
697
+ required(:approved).filled(:bool)
698
+ optional(:note).maybe(:string)
699
+ end
700
+ end
701
+ ```
702
+
574
703
  ### Complex Workflows with Dependencies
575
704
 
576
705
  Steps can depend on results from multiple other steps:
@@ -578,7 +707,7 @@ Steps can depend on results from multiple other steps:
578
707
  ```ruby
579
708
  class OrderProcessingReactor < RubyReactor::Reactor
580
709
  input :user_id
581
- input :product_ids, validate: ->(ids) { ids.is_a?(Array) && ids.any? }
710
+ input :product_ids, Array, min_size?: 1
582
711
 
583
712
  step :validate_user do
584
713
  argument :user_id, input(:user_id)
@@ -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
@@ -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
@@ -86,44 +86,35 @@ module RubyReactor
86
86
  # limits: { second: 3, minute: 100, hour: 5000 }
87
87
  # ) { |i| "stripe:#{i[:account_id]}" }
88
88
  #
89
+ # @example Named global limit (registered in `RubyReactor.configure`)
90
+ # with_rate_limit(:stripe)
91
+ #
92
+ # @param name [Symbol] reference a rate limit registered via
93
+ # `config.rate_limits.register`. When given, the limit is shared
94
+ # across every reactor using that name (the name is the key base);
95
+ # no `limit:`/`period:`/`limits:` or block is accepted.
89
96
  # @param limit [Integer] requests per period (single-window form)
90
97
  # @param period [Symbol, Integer] :second / :minute / :hour / :day /
91
98
  # :week / :month / :year, or integer seconds (single-window form)
92
99
  # @param limits [Hash{Symbol,Integer => Integer}] mapping of period
93
100
  # 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)
101
+ # @yield [inputs] Block returning the rate-limit key base (inline forms).
102
+ def with_rate_limit(name = nil, limit: nil, period: nil, limits: nil, &block)
103
+ if name
104
+ if limit || period || limits || block
105
+ raise ArgumentError, "with_rate_limit(:#{name}) references a registered limit; " \
106
+ "do not also pass :limit/:period/:limits or a block"
107
+ end
108
+
109
+ @rate_limit_config = { name: name.to_sym }
110
+ return
111
+ end
97
112
 
98
113
  @rate_limit_config = {
99
- limits: normalized,
114
+ limits: RubyReactor::RateLimit.normalize_specs(limit: limit, period: period, limits: limits),
100
115
  key_proc: block
101
116
  }
102
117
  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
118
  end
128
119
  end
129
120
  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
 
@@ -20,17 +20,23 @@ module RubyReactor
20
20
  @conditions = []
21
21
  @guards = []
22
22
  @dependencies = []
23
+ @arg_validations = []
24
+ @validate_args_input = nil
23
25
  @args_validator = nil
24
26
  @output_validator = nil
25
27
  @async = false
26
28
  @retry_config = {}
27
29
  end
28
30
 
29
- def argument(name, source, transform: nil)
31
+ def argument(name, source, type = nil, transform: nil, **predicates)
30
32
  @arguments[name] = {
31
33
  source: source,
32
34
  transform: transform
33
35
  }
36
+
37
+ return unless type || predicates.any?
38
+
39
+ @arg_validations << [name, type, false, predicates]
34
40
  end
35
41
 
36
42
  def run(&block)
@@ -57,20 +63,26 @@ module RubyReactor
57
63
  @dependencies.concat(step_names)
58
64
  end
59
65
 
66
+ # Cross-field rules over the whole resolved argument hash. Composes with
67
+ # per-argument inline validations declared via `argument`; the block (or
68
+ # pre-built schema) is applied last and wins on conflicts.
60
69
  def validate_args(schema_or_validator = nil, &block)
61
- if block_given?
62
- @args_validator = build_input_validator(block)
63
- elsif schema_or_validator
64
- @args_validator = build_input_validator(schema_or_validator)
65
- end
70
+ @validate_args_input = block || schema_or_validator
66
71
  end
67
72
 
68
- def validate_output(schema_or_validator = nil, &block)
69
- if block_given?
70
- @output_validator = build_input_validator(block)
71
- elsif schema_or_validator
72
- @output_validator = build_input_validator(schema_or_validator)
73
- end
73
+ # Scalar-aware output validation.
74
+ # validate_output :integer, gteq?: 0 # single value
75
+ # validate_output do ... end # hash output
76
+ # validate_output SomeSchema # pre-built schema
77
+ def validate_output(type = nil, **predicates, &block)
78
+ @output_validator =
79
+ if block
80
+ create_input_validator(block)
81
+ elsif type.is_a?(Symbol) || type.is_a?(Module) || predicates.any?
82
+ build_scalar_validator(type, predicates)
83
+ elsif type
84
+ create_input_validator(type)
85
+ end
74
86
  end
75
87
 
76
88
  def async(async = true)
@@ -96,7 +108,7 @@ module RubyReactor
96
108
  conditions: @conditions,
97
109
  guards: @guards,
98
110
  dependencies: @dependencies,
99
- args_validator: @args_validator,
111
+ args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
100
112
  output_validator: @output_validator,
101
113
  async: @async,
102
114
  retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
@@ -104,32 +116,6 @@ module RubyReactor
104
116
 
105
117
  RubyReactor::Dsl::StepConfig.new(step_config)
106
118
  end
107
-
108
- private
109
-
110
- def build_input_validator(schema_or_block)
111
- check_dry_validation_available!
112
-
113
- schema = case schema_or_block
114
- when Proc
115
- build_validation_schema(&schema_or_block)
116
- else
117
- schema_or_block
118
- end
119
-
120
- RubyReactor::Validation::InputValidator.new(schema)
121
- end
122
-
123
- def build_validation_schema(&block)
124
- RubyReactor::Validation::SchemaBuilder.build_from_block(&block)
125
- end
126
-
127
- def check_dry_validation_available!
128
- return if defined?(Dry::Schema)
129
-
130
- raise LoadError,
131
- "dry-validation gem is required for validation features. Add 'gem \"dry-validation\"' to your Gemfile."
132
- end
133
119
  end
134
120
 
135
121
  class StepConfig
@@ -22,6 +22,40 @@ module RubyReactor
22
22
  RubyReactor::Validation::InputValidator.new(schema)
23
23
  end
24
24
 
25
+ # Form 1 / 1b — inline scalar or class type for a single named value.
26
+ def build_inline_validator(name, type, optional, predicates)
27
+ check_dry_validation_available!
28
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(name, type, optional, predicates)
29
+ RubyReactor::Validation::InputValidator.new(schema)
30
+ end
31
+
32
+ # Form 2 — block bound to the value's macro (`required`/`optional`).
33
+ def build_macro_validator(name, optional, &block)
34
+ check_dry_validation_available!
35
+ schema = RubyReactor::Validation::SchemaBuilder.build_macro(name, optional, &block)
36
+ RubyReactor::Validation::InputValidator.new(schema)
37
+ end
38
+
39
+ # Compose per-argument inline rules with an optional `validate_args`
40
+ # block / pre-built schema. Returns nil when there is nothing to validate.
41
+ def build_args_validator(inline_rules, validate_input)
42
+ return nil if inline_rules.empty? && validate_input.nil?
43
+
44
+ check_dry_validation_available!
45
+ schema = RubyReactor::Validation::SchemaBuilder.build_args(inline_rules, validate_input)
46
+ return nil unless schema
47
+
48
+ RubyReactor::Validation::InputValidator.new(schema)
49
+ end
50
+
51
+ # Scalar-aware single-value validator (used by `validate_output`). The
52
+ # value is wrapped under `:value` before validation.
53
+ def build_scalar_validator(type, predicates)
54
+ check_dry_validation_available!
55
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(:value, type, false, predicates)
56
+ RubyReactor::Validation::InputValidator.new(schema, wrap_key: :value)
57
+ end
58
+
25
59
  private
26
60
 
27
61
  def check_dry_validation_available!
@@ -4,6 +4,10 @@ module RubyReactor
4
4
  module Error
5
5
  class InputValidationError < Base
6
6
  attr_reader :field_errors
7
+ # Step attribution, set at the raise site when the failure happened at a
8
+ # step boundary (argument or output validation) rather than at reactor
9
+ # input validation. Nil for reactor-level input failures.
10
+ attr_accessor :step_name, :step_arguments
7
11
 
8
12
  def initialize(field_errors)
9
13
  @field_errors = field_errors
@@ -33,8 +33,11 @@ module RubyReactor
33
33
  when Error::StepFailureError
34
34
  handle_step_failure_error(error)
35
35
  when Error::InputValidationError
36
- # Preserve validation errors as-is for proper error handling
37
- RubyReactor.Failure(error, validation_errors: error.field_errors)
36
+ # Unified validation failure shape (inputs, step args, step output).
37
+ # Roll back any completed steps so saga semantics hold for mid-reactor
38
+ # validation failures (a no-op for input validation at reactor start).
39
+ @compensation_manager.rollback_completed_steps
40
+ build_validation_failure(error)
38
41
  when Error::Base
39
42
  # Other errors need rollback
40
43
  @compensation_manager.rollback_completed_steps
@@ -56,6 +59,25 @@ module RubyReactor
56
59
 
57
60
  private
58
61
 
62
+ # Failure for a validation error (reactor inputs, step arguments, or
63
+ # step output), carrying both the structured field errors and the step/
64
+ # reactor attribution stamped at the raise site (nil step_name for
65
+ # reactor-level input failures).
66
+ def build_validation_failure(error)
67
+ redact_inputs = []
68
+ redact_inputs = @context.reactor_class.inputs.select { |_, c| c[:redact] }.keys if @context.reactor_class
69
+
70
+ RubyReactor.Failure(
71
+ error,
72
+ validation_errors: error.field_errors,
73
+ step_name: error.step_name,
74
+ step_arguments: error.step_arguments || {},
75
+ inputs: @context.inputs,
76
+ redact_inputs: redact_inputs,
77
+ reactor_name: @context.reactor_class&.name
78
+ )
79
+ end
80
+
59
81
  # A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
60
82
  # event in the trace, do NOT push to the undo stack (so existing
61
83
  # completed steps stay as-is — no compensation), and stamp the step
@@ -180,12 +202,17 @@ module RubyReactor
180
202
  output_validation_result = step_config.output_validator.call(value)
181
203
  return if output_validation_result.success?
182
204
 
183
- raise Error::StepFailureError.new(
184
- "Step '#{step_config.name}' output validation failed: #{output_validation_result.error.message}",
185
- step: step_config.name,
186
- context: @context,
187
- step_arguments: resolved_arguments
188
- )
205
+ error = output_validation_result.error
206
+ error.step_name = step_config.name
207
+ error.step_arguments = resolved_arguments
208
+
209
+ # The step DID run — its side effect exists even though its output is
210
+ # invalid. Treat it like a step failure: run the step's own
211
+ # compensation and roll back prior steps, so the side effect is not
212
+ # orphaned. Then surface the structured validation error (the later
213
+ # rollback in handle_execution_error is a no-op — stack already clear).
214
+ @compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
215
+ raise error
189
216
  end
190
217
 
191
218
  def extract_location(backtrace)
@@ -128,6 +128,10 @@ module RubyReactor
128
128
  def safe_execute_step_sync(step_config, resolved_arguments = nil)
129
129
  resolved_arguments ||= resolve_arguments(step_config)
130
130
  execute_step_sync_without_result_handling(step_config, resolved_arguments)
131
+ rescue Error::InputValidationError
132
+ # Validation failures are not retryable and must surface as a structured
133
+ # InputValidationError (with field_errors), so let them propagate.
134
+ raise
131
135
  rescue StandardError => e
132
136
  # Identify redacted inputs
133
137
  redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
@@ -240,11 +244,12 @@ module RubyReactor
240
244
  validation_result = step_config.args_validator.call(resolved_arguments)
241
245
  return if validation_result.success?
242
246
 
243
- raise Error::StepFailureError.new(
244
- "Step '#{step_config.name}' argument validation failed: #{validation_result.error.message}",
245
- step: step_config.name,
246
- context: @context
247
- )
247
+ # Stamp step attribution so the resulting Failure can say WHERE the
248
+ # validation failed, not just what was invalid.
249
+ error = validation_result.error
250
+ error.step_name = step_config.name
251
+ error.step_arguments = resolved_arguments
252
+ raise error
248
253
  end
249
254
 
250
255
  def resolve_arguments(step_config)
@@ -71,20 +71,28 @@ module RubyReactor
71
71
  middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
72
72
  completed = false
73
73
 
74
- skipped = check_period_gate
75
- if skipped
76
- @result = skipped
77
- update_context_status(@result)
78
- save_context
74
+ if (skipped = check_period_gate)
79
75
  completed = true
80
- return @result
76
+ return finalize_skipped(skipped)
81
77
  end
82
78
 
83
- acquire_locks_with_telemetry
84
-
79
+ # Validate inputs BEFORE consuming a rate-limit slot or grabbing a
80
+ # lock/semaphore: a run that can never start must not burn quota or
81
+ # briefly block other callers.
85
82
  input_validator = InputValidator.new(@reactor_class, @context)
86
83
  input_validator.validate!
87
84
 
85
+ acquire_locks_with_telemetry
86
+
87
+ # Re-check the period gate now that we hold the lock. The pre-lock check
88
+ # is a fast path; this one closes the race where two callers both passed
89
+ # it and then serialized on the lock — without it the second caller would
90
+ # re-run work the first already marked. (No-op when no lock is configured.)
91
+ if (skipped = check_period_gate)
92
+ completed = true
93
+ return finalize_skipped(skipped)
94
+ end
95
+
88
96
  @context.status = :running
89
97
  save_context
90
98
 
@@ -100,7 +108,8 @@ module RubyReactor
100
108
  @result
101
109
  rescue RubyReactor::Lock::AcquisitionError,
102
110
  RubyReactor::Semaphore::AcquisitionError,
103
- RubyReactor::RateLimit::ExceededError => e
111
+ RubyReactor::RateLimit::ExceededError,
112
+ RubyReactor::RateLimitRegistry::UnknownLimitError => e
104
113
  raise e
105
114
  rescue StandardError => e
106
115
  @result = @result_handler.handle_execution_error(e)
@@ -118,13 +127,33 @@ module RubyReactor
118
127
  end
119
128
  end
120
129
 
121
- def resume_execution
130
+ def resume_execution # rubocop:disable Metrics/MethodLength
122
131
  middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
123
132
  completed = false
133
+ # A fresh async reactor run reaches the worker through resume_execution
134
+ # (it never calls execute), so the period and rate-limit gates that live
135
+ # in execute must be applied here too. Genuine resumes (a step already ran
136
+ # or we paused mid-flight, so current_step is set) must NOT re-gate: a
137
+ # paused reactor must not throttle or skip itself on the way back in.
138
+ first_run = first_execution?
124
139
  begin
125
140
  @context.status = :running
126
- acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
127
- acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
141
+
142
+ if first_run && (skipped = check_period_gate)
143
+ completed = true
144
+ return finalize_skipped(skipped)
145
+ end
146
+ check_rate_limit if first_run
147
+
148
+ acquire_concurrency_primitives
149
+
150
+ # Post-lock re-check (see execute) — closes the period race for the
151
+ # first run of a locked async reactor.
152
+ if first_run && (skipped = check_period_gate)
153
+ completed = true
154
+ return finalize_skipped(skipped)
155
+ end
156
+
128
157
  prepare_for_resume
129
158
  save_context
130
159
 
@@ -142,7 +171,8 @@ module RubyReactor
142
171
  @result
143
172
  rescue RubyReactor::Lock::AcquisitionError,
144
173
  RubyReactor::Semaphore::AcquisitionError,
145
- RubyReactor::RateLimit::ExceededError
174
+ RubyReactor::RateLimit::ExceededError,
175
+ RubyReactor::RateLimitRegistry::UnknownLimitError
146
176
  raise
147
177
  rescue StandardError => e
148
178
  handle_resume_error(e)
@@ -196,6 +226,10 @@ module RubyReactor
196
226
 
197
227
  def acquire_locks
198
228
  check_rate_limit
229
+ acquire_concurrency_primitives
230
+ end
231
+
232
+ def acquire_concurrency_primitives
199
233
  acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
200
234
  acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
201
235
  end
@@ -206,20 +240,49 @@ module RubyReactor
206
240
 
207
241
  # Consume one slot from each configured rate-limit window. Raises
208
242
  # `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
209
- # hint) if any window is full. Only consulted on initial `execute`; resumes
210
- # never re-check (a paused reactor must not block itself on resume).
243
+ # hint) if any window is full. Consulted on the first execution only —
244
+ # `execute` for sync reactors, the first `resume_execution` pass for async
245
+ # reactors. Genuine resumes never re-check (a paused reactor must not block
246
+ # itself on resume).
211
247
  def check_rate_limit
212
248
  return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
213
249
 
214
250
  config = @reactor_class.rate_limit_config
215
- key_base = config[:key_proc].call(@context.inputs)
216
251
 
217
- RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
252
+ if config[:name]
253
+ # Named global limit: the name is the shared key base and the windows
254
+ # come from the registry (resolved lazily so config order doesn't matter).
255
+ key_base = config[:name].to_s
256
+ limits = RubyReactor.configuration.rate_limits.fetch(config[:name])
257
+ else
258
+ key_base = config[:key_proc].call(@context.inputs)
259
+ limits = config[:limits]
260
+ end
261
+
262
+ RubyReactor::RateLimit.new(key_base, limits: limits).check_and_increment!
263
+ end
264
+
265
+ # True when nothing has run yet for this context — the very first execution
266
+ # of the reactor, including an async reactor's first worker pass. A genuine
267
+ # resume (paused, async-handed-off, or retried step) always records a
268
+ # `current_step` before serializing, so it is never mistaken for a first run.
269
+ def first_execution?
270
+ @context.current_step.nil? && @context.intermediate_results.empty?
271
+ end
272
+
273
+ # Record and persist a Skipped result, then return it. Shared by the
274
+ # pre-lock and post-lock period gates in both execute and resume.
275
+ def finalize_skipped(skipped)
276
+ @result = skipped
277
+ update_context_status(@result)
278
+ save_context
279
+ @result
218
280
  end
219
281
 
220
282
  # Returns a Skipped result if the period bucket is already marked, else nil.
221
- # Only consulted on initial `execute`; resumes never re-check (a paused run
222
- # must not skip itself when its own marker eventually appears).
283
+ # Consulted before AND after lock acquisition on a first execution; genuine
284
+ # resumes never re-check (a paused run must not skip itself when its own
285
+ # marker eventually appears).
223
286
  def check_period_gate
224
287
  return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
225
288
 
@@ -25,6 +25,34 @@ module RubyReactor
25
25
  end
26
26
  end
27
27
 
28
+ # Normalize the user-facing window args into the internal spec array that
29
+ # `RateLimit#initialize` expects. Shared by the reactor DSL (`with_rate_limit`)
30
+ # and the global registry (`config.rate_limits.register`).
31
+ #
32
+ # Accepts either a single window (`limit:` + `period:`) or a hash of windows
33
+ # (`limits:`). Returns Array<Hash{period_seconds:, limit:, name:}>.
34
+ def self.normalize_specs(limit: nil, period: nil, limits: nil)
35
+ if limits
36
+ raise ArgumentError, "rate limit: use either :limits, or :limit + :period, not both" if limit || period
37
+
38
+ limits.map do |period_key, limit_val|
39
+ {
40
+ period_seconds: RubyReactor::Period.period_seconds(period_key),
41
+ limit: Integer(limit_val),
42
+ name: period_key.to_s
43
+ }
44
+ end
45
+ elsif limit && period
46
+ [{
47
+ period_seconds: RubyReactor::Period.period_seconds(period),
48
+ limit: Integer(limit),
49
+ name: period.to_s
50
+ }]
51
+ else
52
+ raise ArgumentError, "rate limit requires :limit + :period, or :limits"
53
+ end
54
+ end
55
+
28
56
  attr_reader :key_base, :limits
29
57
 
30
58
  # @param key_base [String] caller-provided key (e.g. "stripe:account_42")
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Global registry of named rate limits, configured once via
5
+ # `RubyReactor.configure`. Lets multiple reactors share a single quota for an
6
+ # external service (e.g. all Stripe-calling reactors throttle against one
7
+ # `:stripe` bucket).
8
+ #
9
+ # @example
10
+ # RubyReactor.configure do |config|
11
+ # config.rate_limits.register(:stripe, limit: 3, period: :second)
12
+ # config.rate_limits.register(:twilio, limits: { second: 10, minute: 100 })
13
+ # end
14
+ #
15
+ # class ChargeReactor < RubyReactor::Reactor
16
+ # with_rate_limit(:stripe)
17
+ # end
18
+ class RateLimitRegistry
19
+ # Raised when a reactor references a rate-limit name that was never
20
+ # registered. This is a configuration error, so it propagates out of
21
+ # `execute` rather than being swallowed into a step failure result.
22
+ class UnknownLimitError < StandardError; end
23
+
24
+ def initialize
25
+ @limits = {}
26
+ end
27
+
28
+ # Register a named rate limit. Same window args as the inline DSL form:
29
+ # a single window (`limit:` + `period:`) or layered windows (`limits:`).
30
+ def register(name, limit: nil, period: nil, limits: nil)
31
+ @limits[name.to_sym] = RubyReactor::RateLimit.normalize_specs(
32
+ limit: limit, period: period, limits: limits
33
+ )
34
+ self
35
+ end
36
+
37
+ # Return the normalized spec array for a registered name, or raise if the
38
+ # name was never registered (resolved lazily at execute time, so the error
39
+ # surfaces with a clear message instead of a nil dereference).
40
+ def fetch(name)
41
+ @limits.fetch(name.to_sym) do
42
+ raise UnknownLimitError, "Unknown rate limit #{name.inspect}. " \
43
+ "Register it with config.rate_limits.register(#{name.inspect}, ...)."
44
+ end
45
+ end
46
+
47
+ def registered?(name)
48
+ @limits.key?(name.to_sym)
49
+ end
50
+ end
51
+ end
@@ -60,6 +60,10 @@ module RubyReactor
60
60
  # budget or appear as an error in dashboards. After the configured
61
61
  # cap is reached we escalate by marking the reactor as failed.
62
62
  handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
63
+ rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
64
+ # Permanent configuration error — snoozing or retrying the same job
65
+ # will keep failing. Mark the context failed immediately.
66
+ escalate_snooze(context, snooze_count, e)
63
67
  end
64
68
  end
65
69
 
@@ -19,7 +19,10 @@ module RubyReactor
19
19
 
20
20
  def failure(errors)
21
21
  error = RubyReactor::Error::InputValidationError.new(errors)
22
- RubyReactor.Failure(error)
22
+ # Carry the structured field errors on the Failure itself so every
23
+ # validation failure shape (reactor inputs via `run`, step args, step
24
+ # output) exposes `validation_errors` uniformly.
25
+ RubyReactor.Failure(error, validation_errors: errors)
23
26
  end
24
27
  end
25
28
  end
@@ -5,14 +5,16 @@ require "dry-validation"
5
5
  module RubyReactor
6
6
  module Validation
7
7
  class InputValidator < Base
8
- attr_reader :schema
8
+ attr_reader :schema, :wrap_key
9
9
 
10
- def initialize(schema)
10
+ def initialize(schema, wrap_key: nil)
11
11
  super()
12
12
  @schema = schema
13
+ @wrap_key = wrap_key
13
14
  end
14
15
 
15
16
  def call(data)
17
+ data = { @wrap_key => data } if @wrap_key
16
18
  result = schema.call(data)
17
19
 
18
20
  if result.success?
@@ -12,6 +12,88 @@ module RubyReactor
12
12
  def self.build_contract_from_block(&block)
13
13
  Class.new(Dry::Validation::Contract, &block).new
14
14
  end
15
+
16
+ # Form 1 / 1b — a single named value with an optional type and predicates.
17
+ #
18
+ # build_inline(:age, :integer, false, { gteq?: 18 })
19
+ # => required(:age).filled(:integer, gteq?: 18)
20
+ # build_inline(:user, User, false, {})
21
+ # => required(:user).filled(type?: User)
22
+ # build_inline(:bio, :string, true, { max_size?: 100 })
23
+ # => optional(:bio).maybe(:string, max_size?: 100)
24
+ def self.build_inline(name, type, optional, predicates)
25
+ rules = [[name, type, optional, predicates]]
26
+ Dry::Schema.Params do
27
+ SchemaBuilder.apply_inline_rules(self, rules)
28
+ end
29
+ end
30
+
31
+ # Form 2 — the block receives the macro already bound to the name and
32
+ # optionality (`required(name)` / `optional(name)`). Because it is invoked
33
+ # as a block argument (not via instance_eval) the caller's `self` and
34
+ # lexical scope (class constants, helper methods) stay reachable.
35
+ def self.build_macro(name, optional, &block)
36
+ macro_method = optional ? :optional : :required
37
+ Dry::Schema.Params do
38
+ block.call(public_send(macro_method, name))
39
+ end
40
+ end
41
+
42
+ # Compose per-argument inline rules with an optional `validate_args`
43
+ # input (block or pre-built schema). Block/inline rules are applied first;
44
+ # a pre-built schema is merged last so its rules win.
45
+ def self.build_args(inline_rules, validate_input)
46
+ block = validate_input.is_a?(Proc) ? validate_input : nil
47
+ prebuilt = !validate_input.nil? && block.nil? ? schema_for(validate_input) : nil
48
+
49
+ composed = build_args_base(inline_rules, block)
50
+
51
+ if prebuilt && composed
52
+ composed.merge(prebuilt)
53
+ elsif prebuilt
54
+ prebuilt
55
+ else
56
+ composed
57
+ end
58
+ end
59
+
60
+ def self.build_args_base(inline_rules, block)
61
+ return Dry::Schema.Params(&block) if inline_rules.empty? && block
62
+ return nil if inline_rules.empty?
63
+
64
+ Dry::Schema.Params do
65
+ SchemaBuilder.apply_inline_rules(self, inline_rules)
66
+ instance_exec(&block) if block
67
+ end
68
+ end
69
+
70
+ # Apply a list of inline rules ([name, type, optional, predicates]) onto a
71
+ # dry-schema DSL context.
72
+ def self.apply_inline_rules(dsl, rules)
73
+ rules.each do |name, type, optional, predicates|
74
+ macro_method = optional ? :optional : :required
75
+ rule_method = optional ? :maybe : :filled
76
+ positional = type.is_a?(Symbol) ? [type] : []
77
+ kwargs = type.is_a?(Module) ? { type?: type, **predicates } : predicates
78
+
79
+ macro = dsl.public_send(macro_method, name)
80
+ if kwargs.empty?
81
+ macro.public_send(rule_method, *positional)
82
+ else
83
+ macro.public_send(rule_method, *positional, **kwargs)
84
+ end
85
+ end
86
+ end
87
+
88
+ # Unwrap an InputValidator into its underlying dry-schema; pass dry-schemas
89
+ # through unchanged.
90
+ def self.schema_for(schema_or_validator)
91
+ if schema_or_validator.is_a?(RubyReactor::Validation::InputValidator)
92
+ schema_or_validator.schema
93
+ else
94
+ schema_or_validator
95
+ end
96
+ end
15
97
  end
16
98
  end
17
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -141,6 +141,7 @@ files:
141
141
  - lib/ruby_reactor/open_telemetry.rb
142
142
  - lib/ruby_reactor/period.rb
143
143
  - lib/ruby_reactor/rate_limit.rb
144
+ - lib/ruby_reactor/rate_limit_registry.rb
144
145
  - lib/ruby_reactor/reactor.rb
145
146
  - lib/ruby_reactor/registry.rb
146
147
  - lib/ruby_reactor/retry_context.rb