ruby_reactor 0.3.1 → 0.4.0
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-config.json +15 -0
- data/.release-please-manifest.json +3 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +13 -0
- data/README.md +194 -9
- data/lib/ruby_reactor/configuration.rb +18 -1
- data/lib/ruby_reactor/context_serializer.rb +10 -1
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/executor/result_handler.rb +19 -0
- data/lib/ruby_reactor/executor/step_executor.rb +5 -0
- data/lib/ruby_reactor/executor.rb +145 -2
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/matchers.rb +171 -4
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +128 -9
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +49 -0
- metadata +13 -51
- data/documentation/DAG.md +0 -457
- data/documentation/README.md +0 -123
- data/documentation/async_reactors.md +0 -369
- data/documentation/composition.md +0 -199
- data/documentation/core_concepts.md +0 -662
- data/documentation/data_pipelines.md +0 -230
- data/documentation/examples/inventory_management.md +0 -749
- data/documentation/examples/order_processing.md +0 -365
- data/documentation/examples/payment_processing.md +0 -654
- data/documentation/getting_started.md +0 -224
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +0 -161
- data/documentation/retry_configuration.md +0 -357
- data/documentation/testing.md +0 -812
- data/gui/.gitignore +0 -24
- data/gui/README.md +0 -73
- data/gui/eslint.config.js +0 -23
- data/gui/index.html +0 -13
- data/gui/package-lock.json +0 -5925
- data/gui/package.json +0 -46
- data/gui/postcss.config.js +0 -6
- data/gui/public/vite.svg +0 -1
- data/gui/src/App.css +0 -42
- data/gui/src/App.tsx +0 -51
- data/gui/src/assets/react.svg +0 -1
- data/gui/src/components/DagVisualizer.tsx +0 -424
- data/gui/src/components/Dashboard.tsx +0 -163
- data/gui/src/components/ErrorBoundary.tsx +0 -47
- data/gui/src/components/ReactorDetail.tsx +0 -135
- data/gui/src/components/StepInspector.tsx +0 -492
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
- data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
- data/gui/src/globals.d.ts +0 -7
- data/gui/src/index.css +0 -14
- data/gui/src/lib/utils.ts +0 -13
- data/gui/src/main.tsx +0 -14
- data/gui/src/test/setup.ts +0 -11
- data/gui/tailwind.config.js +0 -11
- data/gui/tsconfig.app.json +0 -28
- data/gui/tsconfig.json +0 -7
- data/gui/tsconfig.node.json +0 -26
- data/gui/vite.config.ts +0 -8
- data/gui/vitest.config.ts +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7769f6488ce48db7f07cd9a7bb9774f697cadc6433293ca926a9e1c27b28ce3e
|
|
4
|
+
data.tar.gz: 9dd0d5edd43c8b7629474ca2c12dd8f1c755d43f7fab3bc33aaefb86a183ba7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: afbabcc37d4dbba1a6fcb83fb15b88160979802b7cd5d69fd0aed47538080c4abd3196b40a7f7464047f94c2aafa6d5317e825446179e9129a960c0e5d119742
|
|
7
|
+
data.tar.gz: caef3d4d35e37a0c9bc35b273ad20f8a0dc31810e35cf2ea1b50df57d347762c85d2a42aa6b3125401e324725d0e8f17f9d1acfecb5f8f3bafeeaf9a7308a5d2
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"packages": {
|
|
4
|
+
".": {
|
|
5
|
+
"release-type": "ruby",
|
|
6
|
+
"package-name": "ruby_reactor",
|
|
7
|
+
"version-file": "lib/ruby_reactor/version.rb",
|
|
8
|
+
"changelog-path": "CHANGELOG.md",
|
|
9
|
+
"bump-minor-pre-major": true,
|
|
10
|
+
"bump-patch-for-minor-pre-major": false,
|
|
11
|
+
"draft": false,
|
|
12
|
+
"prerelease": false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 3.4.8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.4.0](https://github.com/arturictus/ruby_reactor/compare/ruby_reactor-v0.3.2...ruby_reactor/v0.4.0) (2026-05-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* `AsyncResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
|
|
9
|
+
* enhance deserialization error handling in Sidekiq worker ([#23](https://github.com/arturictus/ruby_reactor/issues/23)) ([60dde95](https://github.com/arturictus/ruby_reactor/commit/60dde95606d52cc6a9d352ad0117b4092a1ebb9d))
|
|
10
|
+
* Enhance failure messages with step, reactor, redacted inputs, a… ([#11](https://github.com/arturictus/ruby_reactor/issues/11)) ([952feae](https://github.com/arturictus/ruby_reactor/commit/952feaeb6ebbe5fbe2daf470263d8e769ba64138))
|
|
11
|
+
* Introduce reactor interrupt functionality, allowing pausing and… ([#13](https://github.com/arturictus/ruby_reactor/issues/13)) ([53d0861](https://github.com/arturictus/ruby_reactor/commit/53d0861f0238f0e2247e581b0a27cba2f42cfba6))
|
|
12
|
+
* Rspec helpers ([#19](https://github.com/arturictus/ruby_reactor/issues/19)) ([cb71f80](https://github.com/arturictus/ruby_reactor/commit/cb71f80c0708dacf6c10c0beac88446b00f30f54))
|
|
13
|
+
* Web Dashboard ([#14](https://github.com/arturictus/ruby_reactor/issues/14)) ([80255dd](https://github.com/arturictus/ruby_reactor/commit/80255dd40800af8f6ed804de9c6f151331742fd5))
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
24
24
|
- **Compensation**: Automatic rollback of completed steps when a failure occurs.
|
|
25
25
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
26
26
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
27
|
+
- **Distributed Locks, Semaphores, Rate Limits & Periods**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), and `with_period` to dedup reactors to once per calendar bucket (once per day/month/year/etc). Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
|
|
27
28
|
|
|
28
29
|
## Comparison
|
|
29
30
|
|
|
@@ -32,6 +33,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
32
33
|
| DAG/Parallel execution | Yes | No | Limited | Manual |
|
|
33
34
|
| Auto compensation/undo | Yes | No | Manual | Manual |
|
|
34
35
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
36
|
+
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
35
37
|
| Built-in web dashboard | Yes | No | No | No |
|
|
36
38
|
| Async with Sidekiq | Yes | No | Limited | Yes |
|
|
37
39
|
|
|
@@ -56,6 +58,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
56
58
|
- [Full Reactor Async](#full-reactor-async)
|
|
57
59
|
- [Step-Level Async](#step-level-async)
|
|
58
60
|
- [Interrupts (Pause & Resume)](#interrupts-pause--resume)
|
|
61
|
+
- [Locks & Semaphores](#locks--semaphores)
|
|
59
62
|
- [Map & Parallel Execution](#map--parallel-execution)
|
|
60
63
|
- [Map with Dynamic Source (ActiveRecord)](#map-with-dynamic-source-activerecord)
|
|
61
64
|
- [Input Validation](#input-validation)
|
|
@@ -99,7 +102,14 @@ RubyReactor.configure do |config|
|
|
|
99
102
|
# Sidekiq configuration for async execution
|
|
100
103
|
config.sidekiq_queue = :default
|
|
101
104
|
config.sidekiq_retry_count = 3
|
|
102
|
-
|
|
105
|
+
|
|
106
|
+
# Lock contention snooze behavior for async reactors. When a Sidekiq worker
|
|
107
|
+
# cannot acquire a lock or semaphore, it re-enqueues itself with this delay
|
|
108
|
+
# (plus jitter) up to `lock_snooze_max_attempts` times before giving up.
|
|
109
|
+
config.lock_snooze_base_delay = 5
|
|
110
|
+
config.lock_snooze_jitter = 5
|
|
111
|
+
config.lock_snooze_max_attempts = 20
|
|
112
|
+
|
|
103
113
|
# Logger configuration
|
|
104
114
|
config.logger = Logger.new($stdout)
|
|
105
115
|
end
|
|
@@ -122,22 +132,40 @@ puts result.value # => "Hello from Ruby Reactor!"
|
|
|
122
132
|
|
|
123
133
|
## Web Dashboard
|
|
124
134
|
|
|
125
|
-
RubyReactor
|
|
135
|
+
RubyReactor ships with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps. The dashboard is a Rack app (a [Roda](https://roda.jeremyevans.net/) application) bundled inside the gem with its pre-compiled JS/CSS assets — no extra install or asset build step is required.
|
|
126
136
|
|
|
127
137
|
### Rails Installation
|
|
128
138
|
|
|
129
|
-
|
|
139
|
+
Add the gem to your `Gemfile`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
gem "ruby_reactor"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Then mount the dashboard in your `config/routes.rb`:
|
|
130
146
|
|
|
131
147
|
```ruby
|
|
132
148
|
Rails.application.routes.draw do
|
|
133
149
|
# ... other routes
|
|
134
|
-
mount RubyReactor::Web::Application =>
|
|
150
|
+
mount RubyReactor::Web::Application => "/ruby_reactor"
|
|
135
151
|
end
|
|
136
152
|
```
|
|
137
153
|
|
|
154
|
+
That's it — visit `/ruby_reactor` and the UI loads. `RubyReactor::Web::Application` is autoloaded by Zeitwerk on first reference, so no extra `require` is needed.
|
|
155
|
+
|
|
156
|
+
### Rack / Sinatra / Standalone
|
|
157
|
+
|
|
158
|
+
Because it's a plain Rack app, you can mount it anywhere `call(env)` is accepted:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# config.ru
|
|
162
|
+
require "ruby_reactor/web/application"
|
|
163
|
+
run RubyReactor::Web::Application
|
|
164
|
+
```
|
|
165
|
+
|
|
138
166
|

|
|
139
167
|
|
|
140
|
-
You can secure the dashboard using standard Rails authentication methods (e.g., `authenticate` block with Devise).
|
|
168
|
+
You can secure the dashboard using standard Rails authentication methods (e.g., wrapping the `mount` line in an `authenticate` block with Devise, or in a `constraints` block).
|
|
141
169
|
|
|
142
170
|
## Usage
|
|
143
171
|
|
|
@@ -159,7 +187,7 @@ class UserRegistrationReactor < RubyReactor::Reactor
|
|
|
159
187
|
|
|
160
188
|
run do |args, context|
|
|
161
189
|
if args[:email] && args[:email].include?('@')
|
|
162
|
-
Success(args[:email].
|
|
190
|
+
Success(args[:email].strip)
|
|
163
191
|
else
|
|
164
192
|
Failure("Email must contain @")
|
|
165
193
|
end
|
|
@@ -191,8 +219,9 @@ class UserRegistrationReactor < RubyReactor::Reactor
|
|
|
191
219
|
Success(user)
|
|
192
220
|
end
|
|
193
221
|
|
|
194
|
-
|
|
222
|
+
compensate do |error, args, context|
|
|
195
223
|
Notify.to(args[:email])
|
|
224
|
+
Success()
|
|
196
225
|
end
|
|
197
226
|
end
|
|
198
227
|
|
|
@@ -200,8 +229,8 @@ class UserRegistrationReactor < RubyReactor::Reactor
|
|
|
200
229
|
argument :email, result(:validate_email)
|
|
201
230
|
wait_for :create_user
|
|
202
231
|
|
|
203
|
-
run do |args, _context|
|
|
204
|
-
Email.
|
|
232
|
+
run do |args, _context|
|
|
233
|
+
Email.send!(args[:email], "verify your email")
|
|
205
234
|
Success()
|
|
206
235
|
end
|
|
207
236
|
|
|
@@ -326,6 +355,99 @@ ApprovalReactor.continue_by_correlation_id(
|
|
|
326
355
|
)
|
|
327
356
|
```
|
|
328
357
|
|
|
358
|
+
### Locks & Semaphores
|
|
359
|
+
|
|
360
|
+
Coordinate across processes with Redis-backed primitives:
|
|
361
|
+
|
|
362
|
+
- **`with_lock`** — at-most-one runner per key at a time (concurrency control).
|
|
363
|
+
- **`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").
|
|
365
|
+
- **`with_period`** — run at most once per calendar bucket (dedup / once-per-day, once-per-month, etc).
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
class RefundOrderReactor < RubyReactor::Reactor
|
|
369
|
+
input :order_id
|
|
370
|
+
|
|
371
|
+
# Only one refund per order at a time. Auto-extend keeps the TTL fresh while
|
|
372
|
+
# the reactor runs, so long steps cannot let the lock expire mid-flight.
|
|
373
|
+
with_lock(ttl: 60) { |inputs| "order:#{inputs[:order_id]}" }
|
|
374
|
+
|
|
375
|
+
step :refund do
|
|
376
|
+
argument :order_id, input(:order_id)
|
|
377
|
+
run { |args| PaymentGateway.refund(args[:order_id]) }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
class GeocodeReactor < RubyReactor::Reactor
|
|
382
|
+
input :address
|
|
383
|
+
|
|
384
|
+
# At most 5 geocode calls in flight across the fleet.
|
|
385
|
+
with_semaphore(limit: 5) { |inputs| "geocode_api" }
|
|
386
|
+
|
|
387
|
+
step :geocode do
|
|
388
|
+
argument :address, input(:address)
|
|
389
|
+
run { |args| Geocoder.lookup(args[:address]) }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
class MonthlyBillingReactor < RubyReactor::Reactor
|
|
394
|
+
input :org_id
|
|
395
|
+
|
|
396
|
+
# Run at most once per UTC month per org. Subsequent calls in the same month
|
|
397
|
+
# return RubyReactor::Skipped without executing any step. Pair with
|
|
398
|
+
# with_lock for strict at-most-one even under concurrent racers.
|
|
399
|
+
with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
|
|
400
|
+
|
|
401
|
+
step :build do
|
|
402
|
+
argument :org_id, input(:org_id)
|
|
403
|
+
run { |args| Billing.generate(args[:org_id]) }
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
class ChargeReactor < RubyReactor::Reactor
|
|
408
|
+
input :account_id
|
|
409
|
+
|
|
410
|
+
# Respect upstream Stripe rate limits: 3/sec and 100/min.
|
|
411
|
+
# Async workers snooze for exactly retry_after seconds instead of
|
|
412
|
+
# consuming Sidekiq retry budget.
|
|
413
|
+
with_rate_limit(
|
|
414
|
+
limits: { second: 3, minute: 100 }
|
|
415
|
+
) { |inputs| "stripe:#{inputs[:account_id]}" }
|
|
416
|
+
|
|
417
|
+
step :charge do
|
|
418
|
+
argument :account_id, input(:account_id)
|
|
419
|
+
run { |args| Stripe.charge(args[:account_id]) }
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
On contention:
|
|
425
|
+
|
|
426
|
+
- **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError`.
|
|
427
|
+
- **Async** (Sidekiq) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay is the error's `retry_after_seconds` (precise wakeup); for locks/semaphores it's `lock_snooze_base_delay + jitter`. Snoozes do not count against the Sidekiq retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed.
|
|
428
|
+
|
|
429
|
+
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
result = MonthlyBillingReactor.run(org_id: 42)
|
|
433
|
+
result.success? # true (Skipped is a Success subclass)
|
|
434
|
+
result.skipped? # true on dedup hit, false otherwise
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
A step's `run` block can also return `RubyReactor.Skipped(reason: "...")` to halt the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back").
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
step :ensure_active do
|
|
441
|
+
argument :user, result(:fetch_user)
|
|
442
|
+
run do |args|
|
|
443
|
+
next RubyReactor.Skipped(reason: "user_opted_out") if args[:user].opted_out?
|
|
444
|
+
RubyReactor.Success(args[:user])
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
See [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, and operational notes.
|
|
450
|
+
|
|
329
451
|
### Map & Parallel Execution
|
|
330
452
|
|
|
331
453
|
Process collections in parallel using the `map` step:
|
|
@@ -733,6 +855,10 @@ Learn how to pause and resume reactors to handle long-running processes, manual
|
|
|
733
855
|
### [Testing with RSpec](documentation/testing.md)
|
|
734
856
|
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`.
|
|
735
857
|
|
|
858
|
+
### [Locks, Semaphores, Rate Limits & Periods](documentation/locks_and_semaphores.md)
|
|
859
|
+
|
|
860
|
+
Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), and calendar-bucketed dedup (`with_period`, returning `Skipped` results). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, and once-per-day/month/year scheduling patterns.
|
|
861
|
+
|
|
736
862
|
### Examples
|
|
737
863
|
- [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
|
|
738
864
|
- [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
|
|
@@ -755,6 +881,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
|
|
|
755
881
|
- [X] Sidekiq
|
|
756
882
|
- [ ] ActiveJob
|
|
757
883
|
- [ ] OpenTelemetry support
|
|
884
|
+
- [X] locks
|
|
758
885
|
|
|
759
886
|
## Development
|
|
760
887
|
|
|
@@ -762,6 +889,64 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
|
762
889
|
|
|
763
890
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
764
891
|
|
|
892
|
+
### Running Redis for the test suite
|
|
893
|
+
|
|
894
|
+
The gem's RSpec suite expects Redis on port `6780` (see [spec/spec_helper.rb](spec/spec_helper.rb)). Start it via Docker Compose:
|
|
895
|
+
|
|
896
|
+
```bash
|
|
897
|
+
docker compose up -d redis-test
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
Then run the suite:
|
|
901
|
+
|
|
902
|
+
```bash
|
|
903
|
+
bundle exec rspec
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
Stop it when done:
|
|
907
|
+
|
|
908
|
+
```bash
|
|
909
|
+
docker compose stop redis-test
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
### Running the demo Rails app
|
|
913
|
+
|
|
914
|
+
The demo Rails app under [demo_app/](demo_app/) has its own Redis (port `6380`) and bind-mounts the repo so edits to `lib/` are live. Two ways to run it:
|
|
915
|
+
|
|
916
|
+
**Option A — fully containerized (Redis + Rails + Sidekiq):**
|
|
917
|
+
|
|
918
|
+
```bash
|
|
919
|
+
docker compose up demo-redis demo-app demo-sidekiq
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
App available at <http://localhost:3789>.
|
|
923
|
+
|
|
924
|
+
**Option B — Redis in Docker, Rails on host:**
|
|
925
|
+
|
|
926
|
+
```bash
|
|
927
|
+
docker compose up -d demo-redis
|
|
928
|
+
|
|
929
|
+
cd demo_app
|
|
930
|
+
bin/rails db:prepare
|
|
931
|
+
REDIS_URL=redis://localhost:6380/1 bin/rails server
|
|
932
|
+
# in another shell, if you need Sidekiq:
|
|
933
|
+
REDIS_URL=redis://localhost:6380/1 bundle exec sidekiq
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
To run the demo app specs:
|
|
937
|
+
|
|
938
|
+
```bash
|
|
939
|
+
cd demo_app
|
|
940
|
+
bundle exec rspec
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
Tear everything down:
|
|
944
|
+
|
|
945
|
+
```bash
|
|
946
|
+
docker compose down # stop containers
|
|
947
|
+
docker compose down -v # also remove demo_redis_data + bundle_cache volumes
|
|
948
|
+
```
|
|
949
|
+
|
|
765
950
|
## Contributing
|
|
766
951
|
|
|
767
952
|
Bug reports and pull requests are welcome on GitHub at https://github.com/arturictus/ruby_reactor. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/arturictus/ruby_reactor/blob/main/CODE_OF_CONDUCT.md).
|
|
@@ -7,7 +7,8 @@ module RubyReactor
|
|
|
7
7
|
class Configuration
|
|
8
8
|
include Singleton
|
|
9
9
|
|
|
10
|
-
attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router
|
|
10
|
+
attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
|
|
11
|
+
:lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts
|
|
11
12
|
|
|
12
13
|
def sidekiq_queue
|
|
13
14
|
@sidekiq_queue ||= :default
|
|
@@ -17,6 +18,22 @@ module RubyReactor
|
|
|
17
18
|
@sidekiq_retry_count ||= 3
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# Base seconds the Sidekiq worker waits before re-checking a contended lock.
|
|
22
|
+
def lock_snooze_base_delay
|
|
23
|
+
@lock_snooze_base_delay ||= 5
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Extra random seconds added to the base delay to avoid thundering herd.
|
|
27
|
+
def lock_snooze_jitter
|
|
28
|
+
@lock_snooze_jitter ||= 5
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# How many times a single job can snooze on lock contention before it is
|
|
32
|
+
# marked as failed. Set to :infinity to never escalate.
|
|
33
|
+
def lock_snooze_max_attempts
|
|
34
|
+
@lock_snooze_max_attempts ||= 20
|
|
35
|
+
end
|
|
36
|
+
|
|
20
37
|
def logger
|
|
21
38
|
@logger ||= Logger.new($stderr)
|
|
22
39
|
end
|
|
@@ -145,7 +145,7 @@ module RubyReactor
|
|
|
145
145
|
when "Regexp"
|
|
146
146
|
Regexp.new(value["source"], value["options"])
|
|
147
147
|
when "GlobalID"
|
|
148
|
-
|
|
148
|
+
locate_global_id(value["gid"])
|
|
149
149
|
when "Template::Element"
|
|
150
150
|
RubyReactor::Template::Element.new(value["map_name"], value["path"])
|
|
151
151
|
when "Template::Input"
|
|
@@ -198,6 +198,15 @@ module RubyReactor
|
|
|
198
198
|
|
|
199
199
|
private
|
|
200
200
|
|
|
201
|
+
def locate_global_id(gid)
|
|
202
|
+
unless defined?(GlobalID::Locator)
|
|
203
|
+
raise RubyReactor::Error::DeserializationError,
|
|
204
|
+
"globalid gem is required to deserialize GlobalID values (gid: #{gid})"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
GlobalID::Locator.locate(gid)
|
|
208
|
+
end
|
|
209
|
+
|
|
201
210
|
def validate_size(data)
|
|
202
211
|
size = data.bytesize
|
|
203
212
|
return if size <= MAX_CONTEXT_SIZE
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Dsl
|
|
5
|
+
module Lockable
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config
|
|
12
|
+
|
|
13
|
+
# Propagate lock/semaphore/period/rate-limit config to subclasses;
|
|
14
|
+
# without this a subclass of a configured reactor would silently lose
|
|
15
|
+
# those settings.
|
|
16
|
+
def inherited(subclass)
|
|
17
|
+
super
|
|
18
|
+
subclass.instance_variable_set(:@lock_config, @lock_config) if @lock_config
|
|
19
|
+
subclass.instance_variable_set(:@semaphore_config, @semaphore_config) if @semaphore_config
|
|
20
|
+
subclass.instance_variable_set(:@period_config, @period_config) if @period_config
|
|
21
|
+
subclass.instance_variable_set(:@rate_limit_config, @rate_limit_config) if @rate_limit_config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Configure locking for this reactor
|
|
25
|
+
# @param ttl [Integer] Time to live in seconds (default: 60)
|
|
26
|
+
# @param wait [Integer] Time to wait for lock in seconds (default: 0)
|
|
27
|
+
# @param auto_extend [Boolean] When true (default), a background thread
|
|
28
|
+
# refreshes the lock TTL every ttl/3 seconds while the reactor runs,
|
|
29
|
+
# protecting steps that may legitimately outlast `ttl`. Pass `false`
|
|
30
|
+
# to disable and rely solely on `ttl` for expiry.
|
|
31
|
+
# @yield [inputs] Block that returns the lock key string
|
|
32
|
+
def with_lock(ttl: 60, wait: 0, auto_extend: true, &block)
|
|
33
|
+
@lock_config = {
|
|
34
|
+
ttl: ttl,
|
|
35
|
+
wait: wait,
|
|
36
|
+
auto_extend: auto_extend,
|
|
37
|
+
key_proc: block
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Configure semaphore for this reactor
|
|
42
|
+
# @param limit [Integer] Maximum concurrent executions
|
|
43
|
+
# @param wait [Integer] Time to wait for a token in seconds (default: 0)
|
|
44
|
+
# @yield [inputs] Block that returns the semaphore key string
|
|
45
|
+
def with_semaphore(limit:, wait: 0, &block)
|
|
46
|
+
@semaphore_config = {
|
|
47
|
+
limit: limit,
|
|
48
|
+
wait: wait,
|
|
49
|
+
key_proc: block
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Configure a calendar-aligned dedup window for this reactor. The
|
|
54
|
+
# reactor will run at most once per bucket per key; subsequent calls
|
|
55
|
+
# in the same bucket return `RubyReactor::Skipped` without executing
|
|
56
|
+
# any steps.
|
|
57
|
+
#
|
|
58
|
+
# Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
|
|
59
|
+
# racers can both see no marker and both run. Pair with `with_lock`
|
|
60
|
+
# for true at-most-one semantics within the bucket.
|
|
61
|
+
#
|
|
62
|
+
# @param every [Symbol, Integer] :minute / :hour / :day / :week /
|
|
63
|
+
# :month / :year, or an integer number of seconds for a sliding
|
|
64
|
+
# bucket (index = `time.to_i / every`).
|
|
65
|
+
# @yield [inputs] Block that returns the period key base. The final
|
|
66
|
+
# Redis marker key is `period:<base>:<bucket_id>`.
|
|
67
|
+
def with_period(every:, &block)
|
|
68
|
+
# Validate eagerly so misconfiguration surfaces at class load time.
|
|
69
|
+
RubyReactor::Period.period_seconds(every)
|
|
70
|
+
|
|
71
|
+
@period_config = {
|
|
72
|
+
every: every,
|
|
73
|
+
key_proc: block
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Configure rate limiting for this reactor (fixed-window counter).
|
|
78
|
+
# Pass either a single window via `limit:` + `period:`, or a hash of
|
|
79
|
+
# windows via `limits:` for layered API quotas.
|
|
80
|
+
#
|
|
81
|
+
# @example Single window
|
|
82
|
+
# with_rate_limit(limit: 3, period: :second) { |i| "stripe:#{i[:account_id]}" }
|
|
83
|
+
#
|
|
84
|
+
# @example Multi-window (3/sec AND 100/min AND 5000/hr)
|
|
85
|
+
# with_rate_limit(
|
|
86
|
+
# limits: { second: 3, minute: 100, hour: 5000 }
|
|
87
|
+
# ) { |i| "stripe:#{i[:account_id]}" }
|
|
88
|
+
#
|
|
89
|
+
# @param limit [Integer] requests per period (single-window form)
|
|
90
|
+
# @param period [Symbol, Integer] :second / :minute / :hour / :day /
|
|
91
|
+
# :week / :month / :year, or integer seconds (single-window form)
|
|
92
|
+
# @param limits [Hash{Symbol,Integer => Integer}] mapping of period
|
|
93
|
+
# 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)
|
|
97
|
+
|
|
98
|
+
@rate_limit_config = {
|
|
99
|
+
limits: normalized,
|
|
100
|
+
key_proc: block
|
|
101
|
+
}
|
|
102
|
+
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
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -14,6 +14,9 @@ module RubyReactor
|
|
|
14
14
|
|
|
15
15
|
def handle_step_result(step_config, result, resolved_arguments)
|
|
16
16
|
case result
|
|
17
|
+
when RubyReactor::Skipped
|
|
18
|
+
# Important: must come before the Success branch — Skipped < Success.
|
|
19
|
+
handle_skipped(step_config, result)
|
|
17
20
|
when RubyReactor::Success
|
|
18
21
|
handle_success(step_config, result, resolved_arguments)
|
|
19
22
|
when RubyReactor::MaxRetriesExhaustedFailure
|
|
@@ -53,6 +56,22 @@ module RubyReactor
|
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
# A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
|
|
60
|
+
# event in the trace, do NOT push to the undo stack (so existing
|
|
61
|
+
# completed steps stay as-is — no compensation), and stamp the step
|
|
62
|
+
# name on the result so the caller can see who halted.
|
|
63
|
+
def handle_skipped(step_config, result)
|
|
64
|
+
@step_results[step_config.name] = result
|
|
65
|
+
result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
|
|
66
|
+
@context.execution_trace << {
|
|
67
|
+
type: :skipped,
|
|
68
|
+
step: step_config.name,
|
|
69
|
+
timestamp: Time.now,
|
|
70
|
+
reason: result.reason
|
|
71
|
+
}
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
56
75
|
def handle_success(step_config, result, resolved_arguments)
|
|
57
76
|
validate_step_output(step_config, result.value, resolved_arguments)
|
|
58
77
|
@step_results[step_config.name] = result
|
|
@@ -33,6 +33,11 @@ module RubyReactor
|
|
|
33
33
|
# If a step returns RetryQueuedResult, we need to stop and return it
|
|
34
34
|
return result if result.is_a?(RetryQueuedResult)
|
|
35
35
|
|
|
36
|
+
# If a step returns Skipped, halt the reactor cleanly (no
|
|
37
|
+
# compensation). Must be checked BEFORE Failure / Success because
|
|
38
|
+
# Skipped is a Success subclass.
|
|
39
|
+
return result if result.is_a?(RubyReactor::Skipped)
|
|
40
|
+
|
|
36
41
|
# If a step returns Failure, we need to stop execution and return it
|
|
37
42
|
return result if result.is_a?(RubyReactor::Failure)
|
|
38
43
|
|