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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-config.json +15 -0
  3. data/.release-please-manifest.json +3 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +13 -0
  6. data/README.md +194 -9
  7. data/lib/ruby_reactor/configuration.rb +18 -1
  8. data/lib/ruby_reactor/context_serializer.rb +10 -1
  9. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  10. data/lib/ruby_reactor/executor/result_handler.rb +19 -0
  11. data/lib/ruby_reactor/executor/step_executor.rb +5 -0
  12. data/lib/ruby_reactor/executor.rb +145 -2
  13. data/lib/ruby_reactor/lock.rb +92 -0
  14. data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
  15. data/lib/ruby_reactor/period.rb +67 -0
  16. data/lib/ruby_reactor/rate_limit.rb +74 -0
  17. data/lib/ruby_reactor/reactor.rb +1 -0
  18. data/lib/ruby_reactor/rspec/matchers.rb +171 -4
  19. data/lib/ruby_reactor/semaphore.rb +58 -0
  20. data/lib/ruby_reactor/sidekiq_workers/worker.rb +128 -9
  21. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  22. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor.rb +49 -0
  25. metadata +13 -51
  26. data/documentation/DAG.md +0 -457
  27. data/documentation/README.md +0 -123
  28. data/documentation/async_reactors.md +0 -369
  29. data/documentation/composition.md +0 -199
  30. data/documentation/core_concepts.md +0 -662
  31. data/documentation/data_pipelines.md +0 -230
  32. data/documentation/examples/inventory_management.md +0 -749
  33. data/documentation/examples/order_processing.md +0 -365
  34. data/documentation/examples/payment_processing.md +0 -654
  35. data/documentation/getting_started.md +0 -224
  36. data/documentation/images/failed_order_processing.png +0 -0
  37. data/documentation/images/payment_workflow.png +0 -0
  38. data/documentation/interrupts.md +0 -161
  39. data/documentation/retry_configuration.md +0 -357
  40. data/documentation/testing.md +0 -812
  41. data/gui/.gitignore +0 -24
  42. data/gui/README.md +0 -73
  43. data/gui/eslint.config.js +0 -23
  44. data/gui/index.html +0 -13
  45. data/gui/package-lock.json +0 -5925
  46. data/gui/package.json +0 -46
  47. data/gui/postcss.config.js +0 -6
  48. data/gui/public/vite.svg +0 -1
  49. data/gui/src/App.css +0 -42
  50. data/gui/src/App.tsx +0 -51
  51. data/gui/src/assets/react.svg +0 -1
  52. data/gui/src/components/DagVisualizer.tsx +0 -424
  53. data/gui/src/components/Dashboard.tsx +0 -163
  54. data/gui/src/components/ErrorBoundary.tsx +0 -47
  55. data/gui/src/components/ReactorDetail.tsx +0 -135
  56. data/gui/src/components/StepInspector.tsx +0 -492
  57. data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
  58. data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
  59. data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
  60. data/gui/src/globals.d.ts +0 -7
  61. data/gui/src/index.css +0 -14
  62. data/gui/src/lib/utils.ts +0 -13
  63. data/gui/src/main.tsx +0 -14
  64. data/gui/src/test/setup.ts +0 -11
  65. data/gui/tailwind.config.js +0 -11
  66. data/gui/tsconfig.app.json +0 -28
  67. data/gui/tsconfig.json +0 -7
  68. data/gui/tsconfig.node.json +0 -26
  69. data/gui/vite.config.ts +0 -8
  70. data/gui/vitest.config.ts +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3ddfd9b6e65d03e5c00d7980563da39b4132fc1d5f0dbe14e5b782d039c7f68
4
- data.tar.gz: 785cc8ac8d426433a00f378eafb8f312b343950ecf496e2af0a6fe9e48115c29
3
+ metadata.gz: 7769f6488ce48db7f07cd9a7bb9774f697cadc6433293ca926a9e1c27b28ce3e
4
+ data.tar.gz: 9dd0d5edd43c8b7629474ca2c12dd8f1c755d43f7fab3bc33aaefb86a183ba7c
5
5
  SHA512:
6
- metadata.gz: 549a49deff5d63e129c0b4c043cbc3987dbbb09b054907a5acb8941af85fedb5f9706bad8d56948bbc7ea889eba19174254962ca987e7835b4cd0a7bf3f9e165
7
- data.tar.gz: 319399c2854cccb505fc7e8f6c964eeeb527bad375ed513b1a2c2ea89b77445283f287bbff5a0892902a56a5a56b5a9aff105a0164006abf407473ad3780a58d
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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.0"
3
+ }
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 comes with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps.
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
- Mount the dashboard engine in your `config/routes.rb`:
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 => '/ruby_reactor'
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
  ![RubyReactor Dashboard Screenshot](documentation/images/failed_order_processing.png)
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].trim)
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
- conpensate do |error, args, context|
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.sent!(args[:email], "verify your 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
- GlobalID::Locator.locate(value["gid"])
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