ruby_reactor 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- 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 +13 -5
- 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/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- 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 +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- 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/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- 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 +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,152 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### ⚠ BREAKING CHANGES
|
|
6
|
+
|
|
7
|
+
* **`RubyReactor::Step` is now a base class, not a mixin.** `include RubyReactor::Step` on a
|
|
8
|
+
plain class with `def self.run(arguments, context)` is gone — no compatibility shim, no dual
|
|
9
|
+
authoring style. A step subclasses `RubyReactor::Step` and writes `run` (and optionally
|
|
10
|
+
`compensate`/`undo`) as **instance** methods reading the validated arguments and the context
|
|
11
|
+
through `inputs`/`context` accessors instead of parameters. Every instance is built fresh for
|
|
12
|
+
its one action (`run`, `undo`, or `compensate`) from the stored arguments/result/reason alone —
|
|
13
|
+
nothing an author sets in `run` is visible in a later `undo`/`compensate`, which is exactly what
|
|
14
|
+
makes rollback behave identically whether it lands in the same process as `run` or, as with an
|
|
15
|
+
`async_step`, in a separate later one. `inputs` holds the same values in all three actions: the
|
|
16
|
+
arguments with the contract's defaults applied. Only `run` enforces the contract; `undo` and
|
|
17
|
+
`compensate` never do, so rollback cannot fail on the inputs that caused the failure.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Before
|
|
21
|
+
class ChargeStep
|
|
22
|
+
include RubyReactor::Step
|
|
23
|
+
|
|
24
|
+
input :amount, :integer, gteq?: 1
|
|
25
|
+
|
|
26
|
+
def self.run(arguments, context)
|
|
27
|
+
Success(charge!(arguments[:amount]))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.undo(result, arguments, context)
|
|
31
|
+
refund!(result[:charge_id])
|
|
32
|
+
Success()
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# After
|
|
37
|
+
class ChargeStep < RubyReactor::Step
|
|
38
|
+
input :amount, :integer, gteq?: 1
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
Success(charge!(inputs[:amount]))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def undo
|
|
45
|
+
refund!(result[:charge_id])
|
|
46
|
+
Success()
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Migration:** for every class step, replace `include RubyReactor::Step` with
|
|
52
|
+
`< RubyReactor::Step`, turn `def self.run(args, ctx)` into `def run` reading `inputs`/`context`,
|
|
53
|
+
and likewise for `def self.undo(result, args, ctx)` / `def self.compensate(reason, args, ctx)` →
|
|
54
|
+
`def undo` / `def compensate` reading `result`/`reason`/`inputs`/`context`. The class-level
|
|
55
|
+
`MyStep.run(arguments, context)` / `.call` / `.undo(result, arguments, context)` /
|
|
56
|
+
`.compensate(reason, arguments, context)` entry points every caller (executor, worker, a direct
|
|
57
|
+
unit-test call) already used are unchanged. Inline `step { run { |args, ctx| ... } }` blocks are
|
|
58
|
+
untouched by this change.
|
|
59
|
+
|
|
60
|
+
* **A class step's signal helpers (`success!`/`fail!`/`skip!`/`halt!`) now translate correctly on
|
|
61
|
+
every execution path, including the `async_step`/`background` worker.** Previously the worker had
|
|
62
|
+
no `catch` of its own, so a signal thrown from a class step running there escaped as an
|
|
63
|
+
`UncaughtThrowError` instead of the intended `Success`/`Failure`/`Skipped`/`Halt` — the base
|
|
64
|
+
class's class-level `run`/`undo`/`compensate` now own that translation, so every caller gets it
|
|
65
|
+
for free with no worker change. Known remaining gap, unchanged by this release: an **inline**
|
|
66
|
+
`run`/`compensate`/`undo` block's signals are still uncaught on the worker path.
|
|
67
|
+
|
|
68
|
+
* **A step's own input-validation failure is now guaranteed non-retryable on every path.**
|
|
69
|
+
`result.retryable?` is `false` whether the violation happened synchronously, inside an
|
|
70
|
+
`async_step`/`background` worker, or inside a `compose`d child's own step — previously only the
|
|
71
|
+
async worker path got this right; the synchronous path and a validation failure surfacing
|
|
72
|
+
through `compose` both defaulted to `retryable? == true`.
|
|
73
|
+
|
|
74
|
+
* **`Failure(...)` takes the same arguments everywhere.** Inside a class step and inside an inline
|
|
75
|
+
`run`/`compensate`/`undo` block, `Failure` now forwards every argument to `RubyReactor.Failure`,
|
|
76
|
+
so options such as `Failure("declined", retryable: false)` work instead of raising
|
|
77
|
+
`ArgumentError`. A bare `Failure()` with no error is no longer accepted, matching
|
|
78
|
+
`RubyReactor.Failure`, and a hash error needs braces, `Failure({ code: 1 })`, because a braceless
|
|
79
|
+
`Failure(code: 1)` is now read as options.
|
|
80
|
+
|
|
81
|
+
### Features
|
|
82
|
+
|
|
83
|
+
* **Step input contracts.** A step class declares its own inputs with `input :name, :type, **predicates`
|
|
84
|
+
(plus `optional:`, `default:`, `redact:`, the `do |i| ... end` macro block and `validate:`) and
|
|
85
|
+
cross-field rules with `validate_inputs`. The contract is enforced before `run` on every path
|
|
86
|
+
(inline, retries, `async_step` and `background` workers, resume, `map`, and a direct
|
|
87
|
+
`Step.run(args, context)` call), and a violation fails with `validation_errors` and the step's
|
|
88
|
+
name after completed steps are rolled back. Subclasses inherit and extend the contract.
|
|
89
|
+
Introspection: `input_contract`, `declared_inputs`, `required_input_names`, `declares_inputs?`.
|
|
90
|
+
* **Inline step contracts.** `inputs do ... end` inside a `step` block takes the same `input` /
|
|
91
|
+
`validate_inputs` lines as a step class and is enforced the same way.
|
|
92
|
+
* **Wiring by name.** A declared input with no `argument` resolves from the reactor input of the
|
|
93
|
+
same name (never from a step result). A required input that is neither wired nor a reactor input
|
|
94
|
+
raises `Error::ValidationError` before any step runs. `Reactor.validate_definition!` runs that
|
|
95
|
+
check on demand, e.g. from an initializer or CI.
|
|
96
|
+
* For a step that owns a contract, a type or predicate on `argument`, `validate_args`, or an
|
|
97
|
+
`argument` for an undeclared input raises `Error::ValidationError` when the `step` line is
|
|
98
|
+
evaluated.
|
|
99
|
+
* `have_validation_error` now also matches validation failures raised at a step, not only
|
|
100
|
+
reactor-input failures.
|
|
101
|
+
* `Failure#to_h` includes `retryable`, so a non-retryable failure stays non-retryable after it
|
|
102
|
+
crosses a worker boundary.
|
|
103
|
+
* A step that returns another unit's validation failure (e.g. an `async_step` reader propagating
|
|
104
|
+
the worker's `Failure`) keeps its `validation_errors` on the reactor's final failure.
|
|
105
|
+
|
|
106
|
+
### Deprecations
|
|
107
|
+
|
|
108
|
+
* Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
|
|
109
|
+
for steps without a contract, and print one deprecation notice per declaration site. Move them
|
|
110
|
+
to `input` / `validate_inputs` on the step class, or into an `inputs do ... end` block for an
|
|
111
|
+
inline step, and keep `argument :x, src` for wiring. Removal is no earlier than the next major
|
|
112
|
+
version. See "Step Input Contracts" in the README for the migration.
|
|
113
|
+
|
|
114
|
+
### Bug Fixes
|
|
115
|
+
|
|
116
|
+
* A supplied `false` reactor input or step result no longer resolves to `nil`.
|
|
117
|
+
`Context#get_input`, `Context#get_result` and `Template::Result#fetch` now check whether the key
|
|
118
|
+
exists instead of whether the value is truthy. Code that relied on `false` arriving as `nil`
|
|
119
|
+
will now see `false`.
|
|
120
|
+
* An input-validation `Failure` stays non-retryable across serialization. `Failure`'s
|
|
121
|
+
hash extractor and the reactor's stored `failure_reason` both dropped a `retryable: false`
|
|
122
|
+
(`||` swallowed the `false`, and the reactor never stored the flag at all), so a failure
|
|
123
|
+
rebuilt from JSON or reloaded with `Reactor.find` reported `retryable? == true`.
|
|
124
|
+
* A step-contract violation inside `compose` now reaches the parent with its `validation_errors`
|
|
125
|
+
and retryability intact, instead of being rebuilt from the child's error message alone.
|
|
126
|
+
* A reactor reopened after its first run is re-checked: `validate_definition!` no longer memoizes,
|
|
127
|
+
so a step declared later can no longer reach execution with a required input unwired.
|
|
128
|
+
* A class step that calls `halt!` under `async_step` is recorded as a halt rather than an
|
|
129
|
+
ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
|
|
130
|
+
already hands over a `Failure`.
|
|
131
|
+
|
|
132
|
+
## [0.8.0](https://github.com/arturictus/ruby_reactor/compare/v0.7.1...v0.8.0) (2026-09-21)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
### ⚠ BREAKING CHANGES
|
|
136
|
+
|
|
137
|
+
* Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51))
|
|
138
|
+
|
|
139
|
+
### Features
|
|
140
|
+
|
|
141
|
+
* Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51)) ([4a20583](https://github.com/arturictus/ruby_reactor/commit/4a2058399d73e9b3bf767659dd5ba080537dc291))
|
|
142
|
+
|
|
143
|
+
## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
### Features
|
|
147
|
+
|
|
148
|
+
* reactor signal semantics ([#52](https://github.com/arturictus/ruby_reactor/issues/52)) ([9147d06](https://github.com/arturictus/ruby_reactor/commit/9147d066da603c0afc1af6536b6afac7e165a034))
|
|
149
|
+
|
|
3
150
|
## [0.7.0](https://github.com/arturictus/ruby_reactor/compare/v0.6.0...v0.7.0) (2026-09-08)
|
|
4
151
|
|
|
5
152
|
|
|
@@ -120,6 +267,10 @@ was dispatched precisely so the parent would not depend on it. A later step that
|
|
|
120
267
|
reads the result and returns `Failure` triggers compensation normally, so no
|
|
121
268
|
failure is unrecoverable, just not automatic.
|
|
122
269
|
|
|
270
|
+
* Reactor signal semantics: `Skipped` is renamed to `Halt` (the existing clean-stop behaviour, unchanged otherwise), and `Skipped` is reused with new meaning — marking a single step skipped while the reactor continues, with its value flowing to dependants exactly like `Success`. One-line outcome helpers `success!`, `fail!`, `skip!`, and `halt!` end a step immediately from any call depth. `Failure` (and `fail!`) accept a `retry:` spelling alongside the existing `retryable:`. `compensate`/`undo` now default to `Skipped` instead of `Success`, so the execution trace distinguishes rollback logic that ran from rollback logic that was never written.
|
|
271
|
+
|
|
272
|
+
**Migration**: `Skipped(reason: "...")` (the old halt) is now `Halt(reason: "...")`; `result.skipped?` for a clean halt is now `result.halted?`; the `be_skipped` matcher for a clean halt is now `be_halted`. The old call shape raises `ArgumentError` naming `Halt` — there is no silent compatibility path. Run status `:skipped` is renamed `:halted`; contexts persisted by a pre-upgrade version with status `"skipped"` are still read back correctly as halted.
|
|
273
|
+
|
|
123
274
|
## [0.5.4](https://github.com/arturictus/ruby_reactor/compare/v0.5.3...v0.5.4) (2026-06-18)
|
|
124
275
|
|
|
125
276
|
|
data/CLAUDE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- SPECKIT START -->
|
|
2
2
|
For additional context about technologies to be used, project structure,
|
|
3
|
-
shell commands, and other important information, read the current plan
|
|
4
|
-
|
|
3
|
+
shell commands, and other important information, read the current plan:
|
|
4
|
+
`specs/004-inheritable-step-class/plan.md`
|
|
5
5
|
<!-- SPECKIT END -->
|
data/README.md
CHANGED
|
@@ -230,29 +230,33 @@ RubyReactor supports two ways to define step logic:
|
|
|
230
230
|
| **Class steps** (preferred) | Real business logic, compensation/undo, shared steps, testability |
|
|
231
231
|
| **Inline blocks** | Quick prototypes, trivial one-liners, documentation examples |
|
|
232
232
|
|
|
233
|
-
Whichever style you use, a step's `run` returns one of
|
|
233
|
+
Whichever style you use, a step's `run` returns one of four signals — all exposed as bare helpers in both class steps and inline blocks:
|
|
234
234
|
|
|
235
235
|
- **`Success(value)`** — step succeeded; `value` flows to dependent steps.
|
|
236
236
|
- **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
|
|
237
|
-
- **`
|
|
237
|
+
- **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
|
|
238
|
+
- **`Skipped(value)`** — mark this one step skipped; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
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
|
+
|
|
242
|
+
**Class steps** subclass `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`, as instance methods reading `inputs` and `context`:
|
|
240
243
|
|
|
241
244
|
```ruby
|
|
242
|
-
class ReserveInventoryStep
|
|
243
|
-
|
|
245
|
+
class ReserveInventoryStep < RubyReactor::Step
|
|
246
|
+
# The step's input contract: enforced before `run` on every execution path.
|
|
247
|
+
input :order, :hash
|
|
244
248
|
|
|
245
|
-
def
|
|
246
|
-
reservation_id = InventoryService.reserve(
|
|
249
|
+
def run
|
|
250
|
+
reservation_id = InventoryService.reserve(inputs[:order][:items])
|
|
247
251
|
Success(reservation_id: reservation_id)
|
|
248
252
|
end
|
|
249
253
|
|
|
250
|
-
def
|
|
251
|
-
InventoryService.release_partial(
|
|
254
|
+
def compensate
|
|
255
|
+
InventoryService.release_partial(inputs[:order][:items])
|
|
252
256
|
Success()
|
|
253
257
|
end
|
|
254
258
|
|
|
255
|
-
def
|
|
259
|
+
def undo
|
|
256
260
|
InventoryService.release(result[:reservation_id])
|
|
257
261
|
Success()
|
|
258
262
|
end
|
|
@@ -319,29 +323,25 @@ RubyReactor allows you to define complex workflows as "reactors" with steps that
|
|
|
319
323
|
```ruby
|
|
320
324
|
require 'ruby_reactor'
|
|
321
325
|
|
|
322
|
-
class ValidateEmailStep
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def self.run(arguments, _context)
|
|
326
|
-
email = arguments[:email]
|
|
326
|
+
class ValidateEmailStep < RubyReactor::Step
|
|
327
|
+
def run
|
|
328
|
+
email = inputs[:email]
|
|
327
329
|
email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
|
|
328
330
|
end
|
|
329
331
|
end
|
|
330
332
|
|
|
331
|
-
class CreateUserStep
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def self.run(arguments, _context)
|
|
333
|
+
class CreateUserStep < RubyReactor::Step
|
|
334
|
+
def run
|
|
335
335
|
Success(
|
|
336
336
|
id: rand(10000),
|
|
337
|
-
email:
|
|
338
|
-
password_hash:
|
|
337
|
+
email: inputs[:email],
|
|
338
|
+
password_hash: inputs[:password_hash],
|
|
339
339
|
created_at: Time.now
|
|
340
340
|
)
|
|
341
341
|
end
|
|
342
342
|
|
|
343
|
-
def
|
|
344
|
-
Notify.to(
|
|
343
|
+
def compensate
|
|
344
|
+
Notify.to(inputs[:email])
|
|
345
345
|
Success()
|
|
346
346
|
end
|
|
347
347
|
end
|
|
@@ -726,7 +726,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
|
|
|
726
726
|
input :org_id
|
|
727
727
|
|
|
728
728
|
# Run at most once per UTC month per org. Subsequent calls in the same month
|
|
729
|
-
# return RubyReactor::
|
|
729
|
+
# return RubyReactor::Halt without executing any step. Pair with
|
|
730
730
|
# with_lock for strict at-most-one even under concurrent racers.
|
|
731
731
|
with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
|
|
732
732
|
|
|
@@ -801,26 +801,38 @@ On contention:
|
|
|
801
801
|
- **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
|
|
802
802
|
- **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).
|
|
803
803
|
|
|
804
|
-
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::
|
|
804
|
+
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
|
|
805
805
|
|
|
806
806
|
```ruby
|
|
807
807
|
result = MonthlyBillingReactor.run(org_id: 42)
|
|
808
|
-
result.success? # true (
|
|
809
|
-
result.
|
|
808
|
+
result.success? # true (Halt is a Success subclass)
|
|
809
|
+
result.halted? # true on dedup hit, false otherwise
|
|
810
810
|
```
|
|
811
811
|
|
|
812
|
-
A step's `run` block can also return `
|
|
812
|
+
A step's `run` block can also return `Halt(reason: "...")` to stop 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"). `Halt` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Halt(...)`).
|
|
813
813
|
|
|
814
814
|
```ruby
|
|
815
815
|
step :ensure_active do
|
|
816
816
|
argument :user, result(:fetch_user)
|
|
817
817
|
run do |args, _ctx|
|
|
818
|
-
next
|
|
818
|
+
next Halt(reason: "user_opted_out") if args[:user].opted_out?
|
|
819
819
|
Success(args[:user])
|
|
820
820
|
end
|
|
821
821
|
end
|
|
822
822
|
```
|
|
823
823
|
|
|
824
|
+
To skip a *single* step while the reactor continues — the step did nothing, but the rest of the workflow should still run — return `Skipped(value)` instead. The value flows to dependants exactly like a `Success` value, and the step is not enrolled for rollback:
|
|
825
|
+
|
|
826
|
+
```ruby
|
|
827
|
+
step :maybe_sync do
|
|
828
|
+
argument :user, result(:fetch_user)
|
|
829
|
+
run do |args, _ctx|
|
|
830
|
+
next Skipped(args[:user]) if args[:user].already_synced?
|
|
831
|
+
Success(sync!(args[:user]))
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
```
|
|
835
|
+
|
|
824
836
|
See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, and operational notes.
|
|
825
837
|
|
|
826
838
|
### Map & Parallel Execution
|
|
@@ -1003,26 +1015,123 @@ result.error # => RubyReactor::Error::InputValidationError
|
|
|
1003
1015
|
result.error.field_errors[:name] # => "size cannot be less than 2"
|
|
1004
1016
|
```
|
|
1005
1017
|
|
|
1006
|
-
### Step
|
|
1018
|
+
### Step Input Contracts
|
|
1007
1019
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1020
|
+
A step declares the inputs it accepts; the reactor only says where each value
|
|
1021
|
+
comes from. Rules live with the unit of work, so a step reused by three
|
|
1022
|
+
reactors is validated the same way in all three.
|
|
1010
1023
|
|
|
1011
1024
|
```ruby
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1025
|
+
class ChargeStep < RubyReactor::Step
|
|
1026
|
+
input :amount, :integer, gteq?: 1
|
|
1027
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1028
|
+
input :user, User # type? instance check
|
|
1029
|
+
input :note, :string, optional: true, default: "", max_size?: 100
|
|
1030
|
+
input :token, :string, redact: true # "[REDACTED]" in failures and traces
|
|
1031
|
+
|
|
1032
|
+
# Cross-field rules over the whole argument hash, applied last
|
|
1033
|
+
validate_inputs do
|
|
1034
|
+
required(:amount).filled(:integer, lt?: 10_000)
|
|
1035
|
+
end
|
|
1016
1036
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
required(:amount).filled(:decimal, lt?: 10_000)
|
|
1037
|
+
def run
|
|
1038
|
+
Success(charge!(inputs))
|
|
1020
1039
|
end
|
|
1040
|
+
end
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
`input` takes the same forms as a reactor `input` (inline type and predicates,
|
|
1044
|
+
`do |i| ... end` macro block, `validate: Schema`). The contract is enforced before
|
|
1045
|
+
`run` on every path: inline execution, each retry attempt, `async_step` and
|
|
1046
|
+
`background` workers, resume, every `map` element, and a direct
|
|
1047
|
+
`ChargeStep.run(args, context)` call. A violation raises
|
|
1048
|
+
`RubyReactor::Error::InputValidationError`; inside a reactor that becomes a
|
|
1049
|
+
`Failure` with `validation_errors` and the step's name, after completed steps are
|
|
1050
|
+
rolled back, and `have_validation_error(:amount)` matches it.
|
|
1051
|
+
|
|
1052
|
+
**Wiring.** `argument` only maps values:
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
class ChargeReactor < RubyReactor::Reactor
|
|
1056
|
+
input :amount
|
|
1057
|
+
input :currency
|
|
1058
|
+
input :user
|
|
1059
|
+
|
|
1060
|
+
step :charge, ChargeStep do
|
|
1061
|
+
argument :user, result(:load_user) # explicit wiring always wins
|
|
1062
|
+
end # :amount and :currency resolve by name
|
|
1063
|
+
end
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
A declared input with no `argument` resolves from the reactor input of the same
|
|
1067
|
+
name. Step results are never used for this. A required input that is neither
|
|
1068
|
+
wired nor a reactor input raises `RubyReactor::Error::ValidationError` before any
|
|
1069
|
+
step runs, naming the reactor, the step, the input, and both fixes.
|
|
1070
|
+
`ChargeReactor.validate_definition!` runs the same check without running the
|
|
1071
|
+
reactor, e.g. in an initializer or a CI task.
|
|
1072
|
+
|
|
1073
|
+
For a step that owns a contract, these raise `RubyReactor::Error::ValidationError`
|
|
1074
|
+
when the `step` line is evaluated:
|
|
1075
|
+
|
|
1076
|
+
- a type or predicate on `argument` (`argument :amount, input(:amount), :integer`)
|
|
1077
|
+
- `validate_args`
|
|
1078
|
+
- an `argument` naming an input the step does not declare
|
|
1079
|
+
|
|
1080
|
+
There are no per-reactor overrides. If two reactors need different bounds for the
|
|
1081
|
+
same value, write two steps (or a parameterized step), or relax the contract.
|
|
1082
|
+
|
|
1083
|
+
**Inline steps** declare the same lines inside `inputs do ... end`. Inside the
|
|
1084
|
+
block `input` declares; outside it, `input(:x)` is still the template reference:
|
|
1085
|
+
|
|
1086
|
+
```ruby
|
|
1087
|
+
step :charge do
|
|
1088
|
+
inputs do
|
|
1089
|
+
input :amount, :integer, gteq?: 1
|
|
1090
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
argument :amount, input(:amount)
|
|
1094
|
+
argument :currency, input(:currency)
|
|
1021
1095
|
|
|
1022
1096
|
run { |args, _| charge!(args) }
|
|
1023
1097
|
end
|
|
1024
1098
|
```
|
|
1025
1099
|
|
|
1100
|
+
Moving an inline step into a class is deleting the `inputs do` wrapper.
|
|
1101
|
+
|
|
1102
|
+
**Presence.** A value is provided when its key exists, not when it is truthy:
|
|
1103
|
+
|
|
1104
|
+
| Supplied | Required input | Optional input with `default:` |
|
|
1105
|
+
|---|---|---|
|
|
1106
|
+
| `false`, `0`, `""`, `[]` | passes, value kept | value kept |
|
|
1107
|
+
| `nil` | fails ("must be filled") | default applied |
|
|
1108
|
+
| key absent | fails ("is missing") | default applied |
|
|
1109
|
+
|
|
1110
|
+
**Migrating from rules on `argument`.** Rules on `argument` and `validate_args`
|
|
1111
|
+
still work for steps with no contract, and print a one-time deprecation notice per
|
|
1112
|
+
declaration site. Removal is no earlier than the next major version.
|
|
1113
|
+
|
|
1114
|
+
```ruby
|
|
1115
|
+
# Before
|
|
1116
|
+
step :charge, ChargeStep do
|
|
1117
|
+
argument :amount, input(:amount), :integer, gteq?: 1
|
|
1118
|
+
validate_args { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# After: the rules move into the step
|
|
1122
|
+
class ChargeStep < RubyReactor::Step
|
|
1123
|
+
input :amount, :integer, gteq?: 1
|
|
1124
|
+
validate_inputs { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
step :charge, ChargeStep # or keep `argument :amount, input(:amount)` for wiring
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
For an inline step, put the same `input` / `validate_inputs` lines inside
|
|
1131
|
+
`inputs do ... end`.
|
|
1132
|
+
|
|
1133
|
+
### Step Output Validation
|
|
1134
|
+
|
|
1026
1135
|
Output validation is scalar-aware — pass a type/predicates for a single value,
|
|
1027
1136
|
or a block for a hash output:
|
|
1028
1137
|
|
|
@@ -1337,7 +1446,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
|
|
|
1337
1446
|
|
|
1338
1447
|
### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
|
|
1339
1448
|
|
|
1340
|
-
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 `
|
|
1449
|
+
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.
|
|
1341
1450
|
|
|
1342
1451
|
### [Middlewares & OpenTelemetry](documentation/middlewares.md)
|
|
1343
1452
|
|
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
|
|
@@ -103,6 +103,13 @@ module RubyReactor
|
|
|
103
103
|
@current_step = old_step
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# Appends an execution-trace event stamped with whether this process is the
|
|
107
|
+
# worker (`inline_async_execution`). Traces written before this field
|
|
108
|
+
# existed stay unlabeled on deserialize.
|
|
109
|
+
def append_execution_trace(entry)
|
|
110
|
+
@execution_trace << entry.merge(background: !!@inline_async_execution)
|
|
111
|
+
end
|
|
112
|
+
|
|
106
113
|
def to_h
|
|
107
114
|
{
|
|
108
115
|
inputs: @inputs,
|
|
@@ -36,6 +36,11 @@ module RubyReactor
|
|
|
36
36
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
37
37
|
def serialize_value(value)
|
|
38
38
|
case value
|
|
39
|
+
when RubyReactor::Halt
|
|
40
|
+
{ "_type" => "Halt", "reason" => value.reason, "period_key" => value.period_key,
|
|
41
|
+
"step_name" => value.step_name }
|
|
42
|
+
when RubyReactor::Skipped
|
|
43
|
+
{ "_type" => "Skipped", "value" => serialize_value(value.value), "reason" => value.reason }
|
|
39
44
|
when RubyReactor::Success
|
|
40
45
|
{ "_type" => "Success", "value" => serialize_value(value.value) }
|
|
41
46
|
when RubyReactor::Failure
|
|
@@ -112,6 +117,14 @@ module RubyReactor
|
|
|
112
117
|
if value.key?("_type")
|
|
113
118
|
# Special serialized types (Time, BigDecimal, etc.)
|
|
114
119
|
case value["_type"]
|
|
120
|
+
when "Halt"
|
|
121
|
+
RubyReactor::Halt.new(
|
|
122
|
+
reason: value["reason"],
|
|
123
|
+
period_key: value["period_key"],
|
|
124
|
+
step_name: value["step_name"]
|
|
125
|
+
)
|
|
126
|
+
when "Skipped"
|
|
127
|
+
RubyReactor::Skipped.new(deserialize_value(value["value"]), reason: value["reason"])
|
|
115
128
|
when "Success"
|
|
116
129
|
RubyReactor::Success(deserialize_value(value["value"]))
|
|
117
130
|
when "Failure"
|
|
@@ -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
|
|
@@ -53,7 +53,7 @@ module RubyReactor
|
|
|
53
53
|
|
|
54
54
|
# Configure a calendar-aligned dedup window for this reactor. The
|
|
55
55
|
# reactor will run at most once per bucket per key; subsequent calls
|
|
56
|
-
# in the same bucket return `RubyReactor::
|
|
56
|
+
# in the same bucket return `RubyReactor::Halt` without executing
|
|
57
57
|
# any steps.
|
|
58
58
|
#
|
|
59
59
|
# Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
|
|
@@ -93,7 +93,7 @@ module RubyReactor
|
|
|
93
93
|
# assign. Only fully-drained sequences GC themselves.
|
|
94
94
|
# @param strict [Boolean] When true (default), if any nonce in the
|
|
95
95
|
# sequence terminates with a `Failure`, all subsequent nonces are
|
|
96
|
-
# short-circuited with `
|
|
96
|
+
# short-circuited with `Halt(reason: :ordered_lock_chain_failed)`
|
|
97
97
|
# instead of executing. This models "stop the line on the first
|
|
98
98
|
# problem" pipelines (e.g. ledger transactions). When false, the
|
|
99
99
|
# sequence keeps executing every nonce in order regardless of prior
|
|
@@ -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)
|