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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +199 -30
- data/lib/ruby_reactor/configuration.rb +7 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +60 -29
- 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/ordered_lock_support.rb +307 -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 +145 -50
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +41 -0
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +50 -8
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -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
- data/lib/ruby_reactor.rb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66c0a0c5591cd862dc61063d752f4d92111370808256bfdae749825fec68429b
|
|
4
|
+
data.tar.gz: 212ac4ce7ef87e5d28606cab0aff8358afde437389fbb5b2d717b1b5874daa77
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75e3bd7ead2281ef7bd1a74fe42a1aaaa1ff5ac92db2b72172e779b0fa9591878268b9829c939def027a6d368ad0ad7b00eddaa05bbb5497f0678229b68b17c0
|
|
7
|
+
data.tar.gz: 46c44e73bef1e7f2a11d7a5de51de83a2a9a2505aa8c5b06cf64215bc97c4c272f67f23a220187431a188a6dcca216ee13741566fc61e1ead6a8d7dc7b392d74
|
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 &
|
|
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 &
|
|
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
|
-
#
|
|
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
|
|
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
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
-
#
|
|
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 &
|
|
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
|
|
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 &
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
end
|
|
589
|
+
input :user
|
|
590
|
+
input :payload, redact: true
|
|
591
|
+
```
|
|
523
592
|
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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,
|
|
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 &
|
|
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`),
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
&
|
|
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
|
|