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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +150 -3
  8. data/CLAUDE.md +1 -1
  9. data/README.md +171 -63
  10. data/lib/ruby_reactor/adapters/active_job/router.rb +2 -2
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +2 -2
  12. data/lib/ruby_reactor/context.rb +2 -2
  13. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +31 -5
  15. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  16. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  17. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  18. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  19. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  20. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  21. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  22. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  23. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  24. data/lib/ruby_reactor/executor.rb +3 -0
  25. data/lib/ruby_reactor/map/helpers.rb +9 -0
  26. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  27. data/lib/ruby_reactor/reactor.rb +9 -12
  28. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  29. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  30. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  31. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  32. data/lib/ruby_reactor/step/map_step.rb +182 -218
  33. data/lib/ruby_reactor/step.rb +116 -21
  34. data/lib/ruby_reactor/step_signals.rb +6 -2
  35. data/lib/ruby_reactor/step_worker.rb +25 -10
  36. data/lib/ruby_reactor/template/result.rb +9 -2
  37. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  38. data/lib/ruby_reactor/version.rb +1 -1
  39. data/lib/ruby_reactor.rb +5 -2
  40. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  41. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  42. data/specs/002-step-input-contracts/data-model.md +115 -0
  43. data/specs/002-step-input-contracts/plan.md +165 -0
  44. data/specs/002-step-input-contracts/quickstart.md +170 -0
  45. data/specs/002-step-input-contracts/research.md +233 -0
  46. data/specs/002-step-input-contracts/spec.md +359 -0
  47. data/specs/002-step-input-contracts/tasks.md +367 -0
  48. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  49. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  50. data/specs/004-inheritable-step-class/data-model.md +116 -0
  51. data/specs/004-inheritable-step-class/plan.md +174 -0
  52. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  53. data/specs/004-inheritable-step-class/research.md +308 -0
  54. data/specs/004-inheritable-step-class/spec.md +316 -0
  55. data/specs/004-inheritable-step-class/tasks.md +258 -0
  56. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  57. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  58. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  59. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  60. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  61. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  62. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  63. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  64. data/specs/possible_feature.md +22 -0
  65. 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 asynchronous execution and **Redis** for state persistence.
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
  ![Payment workflow reactor](documentation/images/payment_workflow.png)
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
- - **Async Execution**: Steps can be executed asynchronously in the background using Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
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. Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
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
- | Async (Sidekiq/AJ) | Yes | No | Limited | Yes |
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
- - [Async Execution](#async-execution)
61
- - [Full Reactor Async](#full-reactor-async)
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 async worker. Default: :default.
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
- ## Async router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
184
- ## built-in ActiveJob adapter (see "Async Execution" below), or point at any
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** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
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
- include RubyReactor::Step
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 self.run(arguments, context)
249
- reservation_id = InventoryService.reserve(arguments[:order][:items])
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 self.compensate(error, arguments, context)
254
- InventoryService.release_partial(arguments[:order][:items])
256
+ def compensate
257
+ InventoryService.release_partial(inputs[:order][:items])
255
258
  Success()
256
259
  end
257
260
 
258
- def self.undo(result, arguments, context)
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
- include RubyReactor::Step
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
- include RubyReactor::Step
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: arguments[:email],
341
- password_hash: arguments[: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 self.compensate(_error, arguments, _context)
347
- Notify.to(arguments[:email])
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
- ### Async Execution
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 (full-reactor async, step-level
417
- async, durability, retries, snoozing) works identically on either backend.
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 Reactor Async
419
+ #### Full Background
420
420
 
421
421
  ```ruby
422
- class AsyncReactor < RubyReactor::Reactor
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 = AsyncReactor.run(params)
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 Reactors](documentation/async_reactors.md) for the full
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
- Async reactors are durable: state lives in Redis, not in the job payload. Before
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
- # Async workers snooze for exactly retry_after seconds instead of
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
- async
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 async snooze behavior is identical.
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
- - **Async** (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).
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
- # Enable async execution with batching
854
- async true, batch_size: 50
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
- By using `async true` 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).
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
- `batch_size` is optional: with `async true` 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 [Async Without `batch_size`](documentation/data_pipelines.md#async-without-batch_size).
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
- async true, batch_size: 100
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 Argument & Output Validation
1032
+ ### Step Input Contracts
1022
1033
 
1023
- Arguments can be validated inline using the same forms as `input`. Inline rules
1024
- compose with a `validate_args` block (used for cross-field rules):
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
- step :charge do
1028
- argument :amount, input(:amount), :decimal, gt?: 0
1029
- argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
1030
- argument :user, input(:user), User # type? instance check
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
- # Optional cross-field block (composes with the inline rules above)
1033
- validate_args do
1034
- required(:amount).filled(:decimal, lt?: 10_000)
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 Reactors](documentation/async_reactors.md)
1336
- Explore the ways to move work off the calling process: Full Reactor Async, the `background` hand-off, `async_step`, and `async_reactor`. Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
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 async retries work without blocking workers.
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-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
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, async 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 async/retry boundaries, and custom exporters.
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 Async adapters
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
- execution_id: context_id)
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
- execution_id: context_id)
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
- execution_id: context_id)
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
- execution_id: context_id)
19
+ execution_id: context_id)
20
20
  end
21
21
 
22
22
  # One `async_step`'s work, dispatched as its own independent unit. The
@@ -64,7 +64,7 @@ module RubyReactor
64
64
  end
65
65
 
66
66
  def get_input(name, path = nil)
67
- value = @inputs[name.to_sym] || @inputs[name.to_s]
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[step_name.to_sym] || @intermediate_results[step_name.to_s]
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
- @async = false
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
- def async(async = true, batch_size: nil)
44
- @async = async
45
- @batch_size = batch_size if batch_size
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
- async: { source: RubyReactor::Template::Value.new(@async) }
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
- # Dispatch across the layered `input` forms:
86
- # Form 3 — pre-built schema / contract (`validate:`)
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
- create_input_validator(validate)
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)