ruby_reactor 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +26 -16
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +1 -1
- data/CHANGELOG.md +150 -3
- data/CLAUDE.md +1 -1
- data/README.md +171 -63
- data/lib/ruby_reactor/adapters/active_job/router.rb +2 -2
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +2 -2
- data/lib/ruby_reactor/context.rb +2 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +31 -5
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/result_handler.rb +9 -3
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
- data/lib/ruby_reactor/executor/step_executor.rb +9 -2
- data/lib/ruby_reactor/executor.rb +3 -0
- data/lib/ruby_reactor/map/helpers.rb +9 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +3 -6
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +182 -218
- data/lib/ruby_reactor/step.rb +116 -21
- data/lib/ruby_reactor/step_signals.rb +6 -2
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +5 -2
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +28 -1
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
# RubyReactor
|
|
7
7
|
|
|
8
|
-
A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq or ActiveJob** for
|
|
8
|
+
A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq or ActiveJob** for background execution and **Redis** for state persistence.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -18,13 +18,14 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
20
20
|
- **DAG-based Execution**: Steps are executed based on their dependencies, allowing for parallel execution of independent steps.
|
|
21
|
-
- **
|
|
21
|
+
- **Background Execution**: Hand a whole reactor, or everything after a declared step, to a background job via Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
|
|
22
|
+
- **Async Steps & Reactors**: `async_step` and `async_reactor` dispatch independent units of work while the reactor keeps running; steps that read their result wait for it.
|
|
22
23
|
- **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
|
|
23
24
|
- **Retries**: Configurable retry logic for failed steps, with exponential backoff.
|
|
24
25
|
- **Compensation**: Automatic rollback of completed steps when a failure occurs.
|
|
25
26
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
26
27
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
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.
|
|
28
|
+
- **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. Background jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
|
|
28
29
|
|
|
29
30
|
## Comparison
|
|
30
31
|
|
|
@@ -35,7 +36,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
35
36
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
36
37
|
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
37
38
|
| Built-in web dashboard | Yes | No | No | No |
|
|
38
|
-
|
|
|
39
|
+
| Background (Sidekiq/AJ) | Yes | No | Limited | Yes |
|
|
39
40
|
| Durable crash recovery | Yes | No | No | Manual |
|
|
40
41
|
|
|
41
42
|
## Real-World Use Cases
|
|
@@ -57,9 +58,10 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
57
58
|
- [Rails Installation](#rails-installation)
|
|
58
59
|
- [Usage](#usage)
|
|
59
60
|
- [Basic Example: User Registration](#basic-example-user-registration)
|
|
60
|
-
- [
|
|
61
|
-
- [Full
|
|
61
|
+
- [Background Execution](#background-execution)
|
|
62
|
+
- [Full Background](#full-background)
|
|
62
63
|
- [Background Hand-off](#background-hand-off)
|
|
64
|
+
- [Async Steps & Reactors](#async-steps--reactors)
|
|
63
65
|
- [`async_step`](#async_step-one-step-dispatched-on-its-own)
|
|
64
66
|
- [`async_reactor`](#async_reactor-a-whole-nested-reactor-running-independently)
|
|
65
67
|
- [Durability & Recovery](#durability--recovery)
|
|
@@ -119,7 +121,7 @@ RubyReactor.configure do |config|
|
|
|
119
121
|
|
|
120
122
|
## === Background job backend (Sidekiq by default, or ActiveJob) ===
|
|
121
123
|
|
|
122
|
-
## Queue used by RubyReactor's
|
|
124
|
+
## Queue used by RubyReactor's background worker. Default: :default.
|
|
123
125
|
# config.queue_name = :default
|
|
124
126
|
|
|
125
127
|
## Retry count for infrastructure failures only (deserialization, Redis,
|
|
@@ -180,8 +182,8 @@ RubyReactor.configure do |config|
|
|
|
180
182
|
## Logger. Default: Logger.new($stdout).
|
|
181
183
|
# config.logger = Logger.new($stdout)
|
|
182
184
|
|
|
183
|
-
##
|
|
184
|
-
## built-in ActiveJob adapter (see "
|
|
185
|
+
## Background job router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
|
|
186
|
+
## built-in ActiveJob adapter (see "Background Execution" below), or point at any
|
|
185
187
|
## custom adapter — it only needs to respond to
|
|
186
188
|
## `perform_async(context_id, reactor_class_name, **)`.
|
|
187
189
|
# config.async_router = RubyReactor::Adapters::ActiveJob::Router
|
|
@@ -239,23 +241,24 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
|
|
|
239
241
|
|
|
240
242
|
One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
|
|
241
243
|
|
|
242
|
-
**Class steps**
|
|
244
|
+
**Class steps** subclass `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`, as instance methods reading `inputs` and `context`:
|
|
243
245
|
|
|
244
246
|
```ruby
|
|
245
|
-
class ReserveInventoryStep
|
|
246
|
-
|
|
247
|
+
class ReserveInventoryStep < RubyReactor::Step
|
|
248
|
+
# The step's input contract: enforced before `run` on every execution path.
|
|
249
|
+
input :order, :hash
|
|
247
250
|
|
|
248
|
-
def
|
|
249
|
-
reservation_id = InventoryService.reserve(
|
|
251
|
+
def run
|
|
252
|
+
reservation_id = InventoryService.reserve(inputs[:order][:items])
|
|
250
253
|
Success(reservation_id: reservation_id)
|
|
251
254
|
end
|
|
252
255
|
|
|
253
|
-
def
|
|
254
|
-
InventoryService.release_partial(
|
|
256
|
+
def compensate
|
|
257
|
+
InventoryService.release_partial(inputs[:order][:items])
|
|
255
258
|
Success()
|
|
256
259
|
end
|
|
257
260
|
|
|
258
|
-
def
|
|
261
|
+
def undo
|
|
259
262
|
InventoryService.release(result[:reservation_id])
|
|
260
263
|
Success()
|
|
261
264
|
end
|
|
@@ -322,29 +325,25 @@ RubyReactor allows you to define complex workflows as "reactors" with steps that
|
|
|
322
325
|
```ruby
|
|
323
326
|
require 'ruby_reactor'
|
|
324
327
|
|
|
325
|
-
class ValidateEmailStep
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def self.run(arguments, _context)
|
|
329
|
-
email = arguments[:email]
|
|
328
|
+
class ValidateEmailStep < RubyReactor::Step
|
|
329
|
+
def run
|
|
330
|
+
email = inputs[:email]
|
|
330
331
|
email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
|
|
331
332
|
end
|
|
332
333
|
end
|
|
333
334
|
|
|
334
|
-
class CreateUserStep
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def self.run(arguments, _context)
|
|
335
|
+
class CreateUserStep < RubyReactor::Step
|
|
336
|
+
def run
|
|
338
337
|
Success(
|
|
339
338
|
id: rand(10000),
|
|
340
|
-
email:
|
|
341
|
-
password_hash:
|
|
339
|
+
email: inputs[:email],
|
|
340
|
+
password_hash: inputs[:password_hash],
|
|
342
341
|
created_at: Time.now
|
|
343
342
|
)
|
|
344
343
|
end
|
|
345
344
|
|
|
346
|
-
def
|
|
347
|
-
Notify.to(
|
|
345
|
+
def compensate
|
|
346
|
+
Notify.to(inputs[:email])
|
|
348
347
|
Success()
|
|
349
348
|
end
|
|
350
349
|
end
|
|
@@ -401,7 +400,7 @@ else
|
|
|
401
400
|
end
|
|
402
401
|
```
|
|
403
402
|
|
|
404
|
-
###
|
|
403
|
+
### Background Execution
|
|
405
404
|
|
|
406
405
|
Execute reactors in the background using Sidekiq or ActiveJob. The backend is
|
|
407
406
|
chosen via `config.async_router` (defaults to the Sidekiq adapter); to run on
|
|
@@ -413,13 +412,14 @@ RubyReactor.configure do |config|
|
|
|
413
412
|
end
|
|
414
413
|
```
|
|
415
414
|
|
|
416
|
-
That's the only switch — everything below (
|
|
417
|
-
|
|
415
|
+
That's the only switch — everything below (background execution, `async_step`
|
|
416
|
+
/ `async_reactor`, durability, retries, snoozing) works identically on either
|
|
417
|
+
backend.
|
|
418
418
|
|
|
419
|
-
#### Full
|
|
419
|
+
#### Full Background
|
|
420
420
|
|
|
421
421
|
```ruby
|
|
422
|
-
class
|
|
422
|
+
class BackgroundReactor < RubyReactor::Reactor
|
|
423
423
|
background all: true # Entire reactor runs in background
|
|
424
424
|
|
|
425
425
|
step :long_running_task do
|
|
@@ -428,7 +428,7 @@ class AsyncReactor < RubyReactor::Reactor
|
|
|
428
428
|
end
|
|
429
429
|
|
|
430
430
|
# Returns immediately with DispatchResult
|
|
431
|
-
result =
|
|
431
|
+
result = BackgroundReactor.run(params)
|
|
432
432
|
```
|
|
433
433
|
|
|
434
434
|
#### Background Hand-off
|
|
@@ -496,6 +496,13 @@ changes where code runs, not the saga contract.
|
|
|
496
496
|
> to `async_step`/`async_reactor`, whose names mean something else. It now raises
|
|
497
497
|
> at class-definition time. The exact replacement is `background all: true`.
|
|
498
498
|
|
|
499
|
+
### Async Steps & Reactors
|
|
500
|
+
|
|
501
|
+
`async_step` and `async_reactor` dispatch an **independent** unit of work to its
|
|
502
|
+
own job while the reactor keeps running. Unlike `background`, they do not move
|
|
503
|
+
the reactor itself — they are separate units with their own outcome and
|
|
504
|
+
compensation.
|
|
505
|
+
|
|
499
506
|
#### `async_step`: one step, dispatched on its own
|
|
500
507
|
|
|
501
508
|
Where `background` relocates the *rest* of a reactor, `async_step` dispatches one
|
|
@@ -572,12 +579,12 @@ parent back.
|
|
|
572
579
|
|
|
573
580
|
Lock ownership is never shared across the async boundary: a child declaring a key
|
|
574
581
|
the parent holds fails at dispatch with an explanatory error rather than
|
|
575
|
-
deadlocking. See [Async
|
|
582
|
+
deadlocking. See [Background & Async Execution](documentation/background_and_async.md) for the full
|
|
576
583
|
rules.
|
|
577
584
|
|
|
578
585
|
### Durability & Recovery
|
|
579
586
|
|
|
580
|
-
|
|
587
|
+
Background reactors are durable: state lives in Redis, not in the job payload. Before
|
|
581
588
|
any background job is enqueued the root context is persisted, and after every
|
|
582
589
|
completed step a checkpoint advances the stored blob — so a crash re-runs at most
|
|
583
590
|
one step, never the whole reactor. Each running reactor also holds a short
|
|
@@ -743,7 +750,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
743
750
|
input :account_id
|
|
744
751
|
|
|
745
752
|
# Respect upstream Stripe rate limits: 3/sec and 100/min.
|
|
746
|
-
#
|
|
753
|
+
# Background workers snooze for exactly retry_after seconds instead of
|
|
747
754
|
# consuming the backend's retry budget.
|
|
748
755
|
with_rate_limit(
|
|
749
756
|
limits: { second: 3, minute: 100 }
|
|
@@ -756,7 +763,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
756
763
|
end
|
|
757
764
|
|
|
758
765
|
class OrderedTransactionReactor < RubyReactor::Reactor
|
|
759
|
-
|
|
766
|
+
background all: true
|
|
760
767
|
input :account_id
|
|
761
768
|
input :transaction
|
|
762
769
|
|
|
@@ -797,12 +804,12 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
797
804
|
end
|
|
798
805
|
```
|
|
799
806
|
|
|
800
|
-
Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so
|
|
807
|
+
Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so background snooze behavior is identical.
|
|
801
808
|
|
|
802
809
|
On contention:
|
|
803
810
|
|
|
804
811
|
- **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
|
|
805
|
-
- **
|
|
812
|
+
- **Background** (Sidekiq or ActiveJob) 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 backend's retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
|
|
806
813
|
|
|
807
814
|
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
|
|
808
815
|
|
|
@@ -850,8 +857,8 @@ class DataProcessingReactor < RubyReactor::Reactor
|
|
|
850
857
|
source input(:items)
|
|
851
858
|
argument :item, element(:process_items)
|
|
852
859
|
|
|
853
|
-
#
|
|
854
|
-
|
|
860
|
+
# Run elements as background jobs, in batches
|
|
861
|
+
fan_out batch_size: 50
|
|
855
862
|
|
|
856
863
|
step :transform do
|
|
857
864
|
argument :item, input(:item)
|
|
@@ -863,9 +870,13 @@ class DataProcessingReactor < RubyReactor::Reactor
|
|
|
863
870
|
end
|
|
864
871
|
```
|
|
865
872
|
|
|
866
|
-
|
|
873
|
+
A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`).
|
|
874
|
+
|
|
875
|
+
By using `fan_out` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
|
|
876
|
+
|
|
877
|
+
`batch_size` is optional: with `fan_out` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
|
|
867
878
|
|
|
868
|
-
|
|
879
|
+
> **Breaking change:** `async true` inside a `map` block has been **removed** — it read like `async_step`/`async_reactor`, which dispatch independent units the reactor does not stop for. It now raises at class-definition time. The exact replacement is `fan_out` (`fan_out batch_size: N`).
|
|
869
880
|
|
|
870
881
|
#### Map with Dynamic Source (ActiveRecord)
|
|
871
882
|
|
|
@@ -881,7 +892,7 @@ map :archive_old_users do
|
|
|
881
892
|
end
|
|
882
893
|
|
|
883
894
|
argument :user, element(:archive_old_users)
|
|
884
|
-
|
|
895
|
+
fan_out batch_size: 100
|
|
885
896
|
|
|
886
897
|
step :archive do
|
|
887
898
|
argument :user, input(:user)
|
|
@@ -1018,26 +1029,123 @@ result.error # => RubyReactor::Error::InputValidationError
|
|
|
1018
1029
|
result.error.field_errors[:name] # => "size cannot be less than 2"
|
|
1019
1030
|
```
|
|
1020
1031
|
|
|
1021
|
-
### Step
|
|
1032
|
+
### Step Input Contracts
|
|
1022
1033
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1034
|
+
A step declares the inputs it accepts; the reactor only says where each value
|
|
1035
|
+
comes from. Rules live with the unit of work, so a step reused by three
|
|
1036
|
+
reactors is validated the same way in all three.
|
|
1025
1037
|
|
|
1026
1038
|
```ruby
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1039
|
+
class ChargeStep < RubyReactor::Step
|
|
1040
|
+
input :amount, :integer, gteq?: 1
|
|
1041
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1042
|
+
input :user, User # type? instance check
|
|
1043
|
+
input :note, :string, optional: true, default: "", max_size?: 100
|
|
1044
|
+
input :token, :string, redact: true # "[REDACTED]" in failures and traces
|
|
1045
|
+
|
|
1046
|
+
# Cross-field rules over the whole argument hash, applied last
|
|
1047
|
+
validate_inputs do
|
|
1048
|
+
required(:amount).filled(:integer, lt?: 10_000)
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def run
|
|
1052
|
+
Success(charge!(inputs))
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
`input` takes the same forms as a reactor `input` (inline type and predicates,
|
|
1058
|
+
`do |i| ... end` macro block, `validate: Schema`). The contract is enforced before
|
|
1059
|
+
`run` on every path: inline execution, each retry attempt, `async_step` and
|
|
1060
|
+
`background` workers, resume, every `map` element, and a direct
|
|
1061
|
+
`ChargeStep.run(args, context)` call. A violation raises
|
|
1062
|
+
`RubyReactor::Error::InputValidationError`; inside a reactor that becomes a
|
|
1063
|
+
`Failure` with `validation_errors` and the step's name, after completed steps are
|
|
1064
|
+
rolled back, and `have_validation_error(:amount)` matches it.
|
|
1065
|
+
|
|
1066
|
+
**Wiring.** `argument` only maps values:
|
|
1067
|
+
|
|
1068
|
+
```ruby
|
|
1069
|
+
class ChargeReactor < RubyReactor::Reactor
|
|
1070
|
+
input :amount
|
|
1071
|
+
input :currency
|
|
1072
|
+
input :user
|
|
1073
|
+
|
|
1074
|
+
step :charge, ChargeStep do
|
|
1075
|
+
argument :user, result(:load_user) # explicit wiring always wins
|
|
1076
|
+
end # :amount and :currency resolve by name
|
|
1077
|
+
end
|
|
1078
|
+
```
|
|
1031
1079
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1080
|
+
A declared input with no `argument` resolves from the reactor input of the same
|
|
1081
|
+
name. Step results are never used for this. A required input that is neither
|
|
1082
|
+
wired nor a reactor input raises `RubyReactor::Error::ValidationError` before any
|
|
1083
|
+
step runs, naming the reactor, the step, the input, and both fixes.
|
|
1084
|
+
`ChargeReactor.validate_definition!` runs the same check without running the
|
|
1085
|
+
reactor, e.g. in an initializer or a CI task.
|
|
1086
|
+
|
|
1087
|
+
For a step that owns a contract, these raise `RubyReactor::Error::ValidationError`
|
|
1088
|
+
when the `step` line is evaluated:
|
|
1089
|
+
|
|
1090
|
+
- a type or predicate on `argument` (`argument :amount, input(:amount), :integer`)
|
|
1091
|
+
- `validate_args`
|
|
1092
|
+
- an `argument` naming an input the step does not declare
|
|
1093
|
+
|
|
1094
|
+
There are no per-reactor overrides. If two reactors need different bounds for the
|
|
1095
|
+
same value, write two steps (or a parameterized step), or relax the contract.
|
|
1096
|
+
|
|
1097
|
+
**Inline steps** declare the same lines inside `inputs do ... end`. Inside the
|
|
1098
|
+
block `input` declares; outside it, `input(:x)` is still the template reference:
|
|
1099
|
+
|
|
1100
|
+
```ruby
|
|
1101
|
+
step :charge do
|
|
1102
|
+
inputs do
|
|
1103
|
+
input :amount, :integer, gteq?: 1
|
|
1104
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1035
1105
|
end
|
|
1036
1106
|
|
|
1107
|
+
argument :amount, input(:amount)
|
|
1108
|
+
argument :currency, input(:currency)
|
|
1109
|
+
|
|
1037
1110
|
run { |args, _| charge!(args) }
|
|
1038
1111
|
end
|
|
1039
1112
|
```
|
|
1040
1113
|
|
|
1114
|
+
Moving an inline step into a class is deleting the `inputs do` wrapper.
|
|
1115
|
+
|
|
1116
|
+
**Presence.** A value is provided when its key exists, not when it is truthy:
|
|
1117
|
+
|
|
1118
|
+
| Supplied | Required input | Optional input with `default:` |
|
|
1119
|
+
|---|---|---|
|
|
1120
|
+
| `false`, `0`, `""`, `[]` | passes, value kept | value kept |
|
|
1121
|
+
| `nil` | fails ("must be filled") | default applied |
|
|
1122
|
+
| key absent | fails ("is missing") | default applied |
|
|
1123
|
+
|
|
1124
|
+
**Migrating from rules on `argument`.** Rules on `argument` and `validate_args`
|
|
1125
|
+
still work for steps with no contract, and print a one-time deprecation notice per
|
|
1126
|
+
declaration site. Removal is no earlier than the next major version.
|
|
1127
|
+
|
|
1128
|
+
```ruby
|
|
1129
|
+
# Before
|
|
1130
|
+
step :charge, ChargeStep do
|
|
1131
|
+
argument :amount, input(:amount), :integer, gteq?: 1
|
|
1132
|
+
validate_args { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# After: the rules move into the step
|
|
1136
|
+
class ChargeStep < RubyReactor::Step
|
|
1137
|
+
input :amount, :integer, gteq?: 1
|
|
1138
|
+
validate_inputs { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
step :charge, ChargeStep # or keep `argument :amount, input(:amount)` for wiring
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
For an inline step, put the same `input` / `validate_inputs` lines inside
|
|
1145
|
+
`inputs do ... end`.
|
|
1146
|
+
|
|
1147
|
+
### Step Output Validation
|
|
1148
|
+
|
|
1041
1149
|
Output validation is scalar-aware — pass a type/predicates for a single value,
|
|
1042
1150
|
or a block for a hash output:
|
|
1043
1151
|
|
|
@@ -1332,8 +1440,8 @@ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Con
|
|
|
1332
1440
|
### [DAG (Directed Acyclic Graph)](documentation/DAG.md)
|
|
1333
1441
|
Deep dive into how RubyReactor manages dependencies. This guide explains how the Directed Acyclic Graph is constructed to ensure steps execute in the correct topological order, enabling automatic parallelization of independent steps.
|
|
1334
1442
|
|
|
1335
|
-
### [Async
|
|
1336
|
-
Explore the ways to move work off the calling process:
|
|
1443
|
+
### [Background & Async Execution](documentation/background_and_async.md)
|
|
1444
|
+
Explore the ways to move work off the calling process: background execution (`background all: true` and the `background after:`/`before:` hand-off) and async units (`async_step`, `async_reactor`). Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
|
|
1337
1445
|
|
|
1338
1446
|
### [Composition](documentation/composition.md)
|
|
1339
1447
|
Discover how to build complex, modular workflows by composing reactors within other reactors. This guide covers inline composition, class-based composition, and how to manage dependencies between composed workflows.
|
|
@@ -1342,7 +1450,7 @@ Discover how to build complex, modular workflows by composing reactors within ot
|
|
|
1342
1450
|
Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
|
|
1343
1451
|
|
|
1344
1452
|
### [Retry Configuration](documentation/retry_configuration.md)
|
|
1345
|
-
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to configure retries at the reactor or step level, and how
|
|
1453
|
+
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to configure retries at the reactor or step level, and how background retries work without blocking workers.
|
|
1346
1454
|
|
|
1347
1455
|
### [Interrupts](documentation/interrupts.md)
|
|
1348
1456
|
Learn how to pause and resume reactors to handle long-running processes, manual approvals, and asynchronous callbacks. Patterns for correlation IDs, timeouts, and payload validation.
|
|
@@ -1352,11 +1460,11 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
|
|
|
1352
1460
|
|
|
1353
1461
|
### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
|
|
1354
1462
|
|
|
1355
|
-
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 `Halt` 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-
|
|
1463
|
+
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 `Halt` 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-background 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.
|
|
1356
1464
|
|
|
1357
1465
|
### [Middlewares & OpenTelemetry](documentation/middlewares.md)
|
|
1358
1466
|
|
|
1359
|
-
Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo,
|
|
1467
|
+
Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo, background hand-off, locks/semaphores), writing and registering custom middlewares (global and per-reactor), and the built-in `RubyReactor::OpenTelemetry` tracing middleware — span structure, input/argument redaction, distributed trace propagation across background/retry boundaries, and custom exporters.
|
|
1360
1468
|
|
|
1361
1469
|
### Examples
|
|
1362
1470
|
- [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
|
|
@@ -1376,7 +1484,7 @@ Hook into the execution lifecycle with observer middlewares. Covers the full set
|
|
|
1376
1484
|
- [ ] Multiple storage adapters
|
|
1377
1485
|
- [X] Redis
|
|
1378
1486
|
- [ ] ActiveRecord
|
|
1379
|
-
- [X] Multiple
|
|
1487
|
+
- [X] Multiple background job adapters
|
|
1380
1488
|
- [X] Sidekiq
|
|
1381
1489
|
- [X] ActiveJob
|
|
1382
1490
|
- [X] OpenTelemetry support
|
|
@@ -10,13 +10,13 @@ module RubyReactor
|
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
|
|
12
12
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
-
|
|
13
|
+
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
18
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
-
|
|
19
|
+
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# One `async_step`'s work, dispatched as its own independent unit. The
|
|
@@ -10,13 +10,13 @@ module RubyReactor
|
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
|
|
12
12
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
-
|
|
13
|
+
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
18
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
-
|
|
19
|
+
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# One `async_step`'s work, dispatched as its own independent unit. The
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -64,7 +64,7 @@ module RubyReactor
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def get_input(name, path = nil)
|
|
67
|
-
value = @inputs
|
|
67
|
+
value = Utils::FetchIndifferent.call(@inputs, name)
|
|
68
68
|
return nil if value.nil?
|
|
69
69
|
|
|
70
70
|
if path
|
|
@@ -76,7 +76,7 @@ module RubyReactor
|
|
|
76
76
|
alias input get_input
|
|
77
77
|
|
|
78
78
|
def get_result(step_name, path = nil)
|
|
79
|
-
value = @intermediate_results
|
|
79
|
+
value = Utils::FetchIndifferent.call(@intermediate_results, step_name)
|
|
80
80
|
return nil if value.nil?
|
|
81
81
|
|
|
82
82
|
if path
|
|
@@ -36,6 +36,12 @@ module RubyReactor
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def inputs(*)
|
|
40
|
+
raise RubyReactor::Error::ValidationError,
|
|
41
|
+
"interrupt :#{@name} does not take an `inputs` contract; validate the resume payload with " \
|
|
42
|
+
"`validate_payload`."
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
# Deprecated alias for {#validate_payload}.
|
|
40
46
|
def validate(schema = nil, &block)
|
|
41
47
|
unless @warned_validate
|
|
@@ -20,7 +20,7 @@ module RubyReactor
|
|
|
20
20
|
end
|
|
21
21
|
@reactor = reactor
|
|
22
22
|
@argument_mappings = {}
|
|
23
|
-
@
|
|
23
|
+
@fan_out = false
|
|
24
24
|
@strict_ordering = true
|
|
25
25
|
@batch_size = nil
|
|
26
26
|
@source_enumerable = nil
|
|
@@ -40,9 +40,28 @@ module RubyReactor
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# Run every element as its own background job. `batch_size` caps how many
|
|
44
|
+
# element jobs are enqueued at a time (back pressure); without it the whole
|
|
45
|
+
# source fans out at once. A fan-out map is a hand-off point: the reactor
|
|
46
|
+
# stops at the map and resumes in a worker once the collector has every
|
|
47
|
+
# element's outcome.
|
|
48
|
+
def fan_out(enabled = true, batch_size: nil)
|
|
49
|
+
@fan_out = enabled
|
|
50
|
+
self.batch_size(batch_size) unless batch_size.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `async` on a map named element fan-out with the word that now means
|
|
54
|
+
# `async_step` / `async_reactor` — independent units the reactor does not
|
|
55
|
+
# stop for. A fan-out map does stop the reactor, so it gets its own word.
|
|
56
|
+
def async(*)
|
|
57
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
58
|
+
"`async` inside a `map` block has been removed: it read as `async_step`/`async_reactor`, which " \
|
|
59
|
+
"dispatch independent units, whereas a fan-out map hands the reactor off until every element " \
|
|
60
|
+
"finishes. Use `fan_out` instead (`fan_out batch_size: N` for back pressure) — note this also " \
|
|
61
|
+
"changes worker dispatch: each element now runs in its own background worker rather than being " \
|
|
62
|
+
"suppressed inline by `inline_async_execution` as the old `async true` was.",
|
|
63
|
+
step: @name
|
|
64
|
+
)
|
|
46
65
|
end
|
|
47
66
|
|
|
48
67
|
def strict_ordering(enabled = true)
|
|
@@ -50,6 +69,13 @@ module RubyReactor
|
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
def batch_size(size)
|
|
72
|
+
unless size.is_a?(Integer) && size.positive?
|
|
73
|
+
raise RubyReactor::Error::ValidationError.new(
|
|
74
|
+
"`batch_size` must be a positive Integer, got #{size.inspect}",
|
|
75
|
+
step: @name
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
53
79
|
@batch_size = size
|
|
54
80
|
end
|
|
55
81
|
|
|
@@ -98,7 +124,7 @@ module RubyReactor
|
|
|
98
124
|
batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
|
|
99
125
|
collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
|
|
100
126
|
fail_fast: { source: RubyReactor::Template::Value.new(@fail_fast) },
|
|
101
|
-
|
|
127
|
+
fan_out: { source: RubyReactor::Template::Value.new(@fan_out) }
|
|
102
128
|
},
|
|
103
129
|
run_block: nil,
|
|
104
130
|
compensate_block: nil,
|
|
@@ -82,25 +82,11 @@ module RubyReactor
|
|
|
82
82
|
input_validations[name] = validator if validator
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# Form 2 — block bound to the value macro (`do |i| ... end`)
|
|
88
|
-
# legacy — single-key schema block (`do required(:name)... end`)
|
|
89
|
-
# Form 1 / 1b — inline scalar or class type
|
|
90
|
-
# Form 0 — declaration only (no validator)
|
|
85
|
+
# The legacy single-key schema block is deprecated on reactor inputs
|
|
86
|
+
# only; the form dispatch itself is shared with step input contracts.
|
|
91
87
|
def build_input_validator_for(name, type, optional, validate, predicates, &block)
|
|
92
|
-
if validate
|
|
93
|
-
|
|
94
|
-
elsif block
|
|
95
|
-
if block.arity.nonzero?
|
|
96
|
-
build_macro_validator(name, optional, &block)
|
|
97
|
-
else
|
|
98
|
-
warn_deprecated_input_block
|
|
99
|
-
create_input_validator(block)
|
|
100
|
-
end
|
|
101
|
-
elsif type || predicates.any?
|
|
102
|
-
build_inline_validator(name, type, optional, predicates)
|
|
103
|
-
end
|
|
88
|
+
warn_deprecated_input_block if !validate && block&.arity&.zero?
|
|
89
|
+
build_declaration_validator(name, type, optional, validate, predicates, &block)
|
|
104
90
|
end
|
|
105
91
|
private :build_input_validator_for
|
|
106
92
|
|
|
@@ -163,6 +149,38 @@ module RubyReactor
|
|
|
163
149
|
step_config
|
|
164
150
|
end
|
|
165
151
|
|
|
152
|
+
# Checks that every required input of every contract-owning step is
|
|
153
|
+
# satisfied, and wires the unwired ones from same-named reactor inputs
|
|
154
|
+
# (never from step results). Runs before the first execution because
|
|
155
|
+
# the reactor's full input list is only known once its body has run;
|
|
156
|
+
# public so an app can call it at boot or in CI.
|
|
157
|
+
def validate_definition!
|
|
158
|
+
steps.each do |step_name, step_config|
|
|
159
|
+
contract = step_config.input_contract if step_config.respond_to?(:input_contract)
|
|
160
|
+
next unless contract
|
|
161
|
+
|
|
162
|
+
contract.declarations.each_value do |declaration|
|
|
163
|
+
wire_by_name!(step_name, step_config, declaration)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def wire_by_name!(step_name, step_config, declaration)
|
|
169
|
+
input_name = declaration.name
|
|
170
|
+
return if step_config.arguments.key?(input_name)
|
|
171
|
+
|
|
172
|
+
if inputs.key?(input_name)
|
|
173
|
+
step_config.arguments[input_name] =
|
|
174
|
+
{ source: RubyReactor::Template::Input.new(input_name), transform: nil, origin: :inferred }
|
|
175
|
+
elsif !declaration.optional
|
|
176
|
+
raise RubyReactor::Error::ValidationError,
|
|
177
|
+
"#{name || inspect} step :#{step_name} requires input :#{input_name}, which is neither wired " \
|
|
178
|
+
"nor a reactor input. Wire it (`argument :#{input_name}, input(:x)` / `result(:step)`) or " \
|
|
179
|
+
"declare `input :#{input_name}` on the reactor."
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
private :wire_by_name!
|
|
183
|
+
|
|
166
184
|
def returns(step_name = nil)
|
|
167
185
|
if step_name
|
|
168
186
|
reject_async_return_step!(step_name)
|