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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/README.md +145 -16
- data/lib/ruby_reactor/configuration.rb +7 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +19 -28
- data/lib/ruby_reactor/dsl/reactor.rb +38 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/step_executor.rb +10 -5
- data/lib/ruby_reactor/executor.rb +82 -19
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54ecb36ac72eedf48af0025dff29d83986449586683300ce3fe8fd874c1412d5
|
|
4
|
+
data.tar.gz: 5e3565e3e238bad746d93982ca7b01560893a68755be18fbfce95df6f54ce5b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 708acdb0c74582cea4c33210ea1bac055080c7dd19c69d166db4b2c22705de2935346d6b09d4fc6c9cbb1bda5ba235cade92a88f04fe791e364bb78bda256138
|
|
7
|
+
data.tar.gz: e3f03e46d71babe276224571eaad3e794c7d8c695e2865333f5021e680feb21a12cd3b33527035e1349dc600d01faec87b573b691d7e50521c4f0d81610c8b8f
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
end
|
|
549
|
+
input :user
|
|
550
|
+
input :payload, redact: true
|
|
551
|
+
```
|
|
523
552
|
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
&
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
37
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
76
|
+
return finalize_skipped(skipped)
|
|
81
77
|
end
|
|
82
78
|
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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.
|
|
210
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
222
|
-
# must not skip itself when its own
|
|
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
|
-
|
|
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
|
data/lib/ruby_reactor/version.rb
CHANGED
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.
|
|
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
|