ruby_reactor 0.8.4 → 0.8.5
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/CHANGELOG.md +196 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -11
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +18 -16
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/step/compose_step.rb +7 -1
- data/lib/ruby_reactor/step/map_step.rb +109 -4
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- metadata +35 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4187fd51277447b11381c872a01b46d8e7663323b5b470316d8600ead76906e
|
|
4
|
+
data.tar.gz: 540b0c6b94d007a020f3bfbb3ff035eb47469691bf138cd41588e5a6a28ba468
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59c5faa4c755625a2172a5a7613941a82970aa3b52ac170f6c2f12015f41b289700d320d0195b51d4b13e1cf1f9b86e209bc8c59d5dcc84830a2e65c1c49a48d
|
|
7
|
+
data.tar.gz: 0be05ffe9539a5ae4c012a8f8e3f26394d27853a86219a7c02d82d9685f7f018bd760c26f749cf34c4899f0c9346c6a54a91ee64e0e274b2f2ad2d12048ee818
|
data/.specify/feature.json
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@
|
|
|
4
4
|
|
|
5
5
|
### ⚠ BREAKING CHANGES
|
|
6
6
|
|
|
7
|
+
* **`retries` on a `compose` or an `async_reactor` raises `RubyReactor::Error::DeprecatedDslError`
|
|
8
|
+
at class definition.** A parent never retries a nested reactor as a whole: the child retries its
|
|
9
|
+
own steps. Before, a compose-level retry resumed a child whose earlier steps had already been
|
|
10
|
+
undone. Declare `retries` on the child's steps instead.
|
|
11
|
+
See *Migration notes: reliable rollback* below.
|
|
12
|
+
* **`where` and `guard` are removed.** Declaring either on a `step`, `async_step` or `interrupt`
|
|
13
|
+
raises `RubyReactor::Error::DeprecatedDslError` at class definition. A step that should not run
|
|
14
|
+
returns `Skipped(value)` (or calls `skip!(value)`) from its body.
|
|
15
|
+
See *Migration notes: reliable rollback* below.
|
|
16
|
+
* **`Skipped` no longer changes execution.** It is an instrumentation mark (the trace records it;
|
|
17
|
+
`skipped?` is true): a `background after:` step that returns `Skipped` now hands the rest of
|
|
18
|
+
the run off like any completed step (before, the rest ran in the calling process), and a
|
|
19
|
+
`with_period` step whose body returns `Skipped` marks its bucket. A skipped step is still never
|
|
20
|
+
undone.
|
|
21
|
+
* **An exception that is not a `StandardError` fails the step and rolls back.** A
|
|
22
|
+
`NotImplementedError`, a `LoadError`, a `SystemStackError` or a custom `Exception` subclass
|
|
23
|
+
raised by reactor code (a step body, an argument transform, a `compensate`/`undo`, a key proc, a
|
|
24
|
+
`collect` block) is now that step's failure: the step is compensated if its body ran, completed
|
|
25
|
+
steps are undone, and `Reactor.run` returns the `Failure` instead of raising. Only interruptions
|
|
26
|
+
(`SignalException` including `Interrupt`, `SystemExit`, `NoMemoryError`, and an enclosing
|
|
27
|
+
`Timeout.timeout`'s interruption) still skip rollback and propagate.
|
|
28
|
+
See *Migration notes: reliable rollback* below.
|
|
29
|
+
* **An `async_step`'s `compensate` runs, in the unit's own job, when its final attempt fails.**
|
|
30
|
+
Before, `compensate`/`undo` blocks on an `async_step` were accepted and never ran. Now the unit
|
|
31
|
+
compensates itself once, after its last retry, whether or not any step reads its result —
|
|
32
|
+
never for a retried attempt, a halt, or a body that never started. The outcome is recorded on
|
|
33
|
+
the unit's Step Result Record as `compensation: { status, rollback_failures, completed_at }`,
|
|
34
|
+
and the compensation middleware events fire in the unit's job. A reader that surfaces the
|
|
35
|
+
failure no longer leads to a second compensation of the unit.
|
|
36
|
+
See *Migration notes: reliable rollback* below.
|
|
37
|
+
|
|
38
|
+
* **An inline `undo` inside `async_step` raises `RubyReactor::Error::ValidationError` at class
|
|
39
|
+
definition.** An independent async unit is never undone, so the block could never run. A step
|
|
40
|
+
class that defines `undo` and is used with `async_step` prints a definition-time warning
|
|
41
|
+
instead (the same class may be reused by ordinary steps, where its `undo` runs).
|
|
42
|
+
See *Migration notes: reliable rollback* below.
|
|
43
|
+
* **A map rolls back the elements that completed.** When a map fails (an element fails under
|
|
44
|
+
`fail_fast`, or `collect` raises), and when a later step fails or the run is undone manually,
|
|
45
|
+
every completed element is rolled back by replaying its own step `undo`s, highest index first —
|
|
46
|
+
in inline and fan-out mode, whatever order the element jobs ran in. Before, completed elements
|
|
47
|
+
were never rolled back. A fail-fast fan-out map now waits for elements already in flight before
|
|
48
|
+
it reports its failure, and settles the elements it never started as skipped (which also stops
|
|
49
|
+
the map sweeper re-dispatching them). An element rollback that does not complete is listed in
|
|
50
|
+
`Failure#rollback_failures` with `map_step:` and `element_index:`; an element whose context
|
|
51
|
+
(or the map's element index) expired is reported with `reason: :context_unavailable`, and a
|
|
52
|
+
still-running duplicate with `reason: :element_in_flight`. A fan-out map whose rollback is
|
|
53
|
+
incomplete fails with the same `CompensationError` shape as an inline map.
|
|
54
|
+
See *Migration notes: reliable rollback* below.
|
|
7
55
|
* **`RubyReactor::Step` is now a base class, not a mixin.** `include RubyReactor::Step` on a
|
|
8
56
|
plain class with `def self.run(arguments, context)` is gone — no compatibility shim, no dual
|
|
9
57
|
authoring style. A step subclasses `RubyReactor::Step` and writes `run` (and optionally
|
|
@@ -78,8 +126,130 @@
|
|
|
78
126
|
`RubyReactor.Failure`, and a hash error needs braces, `Failure({ code: 1 })`, because a braceless
|
|
79
127
|
`Failure(code: 1)` is now read as options.
|
|
80
128
|
|
|
129
|
+
|
|
130
|
+
### Migration notes: reliable rollback
|
|
131
|
+
|
|
132
|
+
Every breaking or shape-changing item of the rollback work, with what to change.
|
|
133
|
+
|
|
134
|
+
1. **Map element `undo`s now run** (breaking, behavior). They run when the map fails, when a later
|
|
135
|
+
step fails, and on a manual `Reactor.undo(id)`. Make them idempotent.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# Before: this undo never ran for a map element; charges stayed on a failure.
|
|
139
|
+
# After: it refunds each charged element, highest index first. Guard against a double refund.
|
|
140
|
+
class ChargeStep < RubyReactor::Step
|
|
141
|
+
def undo
|
|
142
|
+
Payments.refund(result[:charge_id]) unless Payments.refunded?(result[:charge_id])
|
|
143
|
+
Success()
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
2. **An `async_step`'s `compensate` now runs in the unit's job** (breaking, behavior), once, after
|
|
149
|
+
its final attempt fails, whether or not a reader exists. Move reader-only cleanup into the
|
|
150
|
+
reader.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Before: never ran.
|
|
154
|
+
async_step :notify, NotifyStep do
|
|
155
|
+
compensate { |error, inputs, _ctx| Audit.undelivered(inputs.user_id, error) }
|
|
156
|
+
end
|
|
157
|
+
# After: runs in the unit's job after the last retry; the outcome is on the unit's record
|
|
158
|
+
# as `compensation`. Cleanup that should run only when a reader fails the reactor:
|
|
159
|
+
step :confirm do
|
|
160
|
+
argument :delivery, result(:notify)
|
|
161
|
+
run { |inputs, _ctx| inputs.delivery.is_a?(RubyReactor::Failure) ? Failure("undelivered") : Success() }
|
|
162
|
+
compensate { |_error, _inputs, _ctx| Support.open_ticket }
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
3. **An inline `undo` inside `async_step` raises at class definition** (breaking, API).
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# Before: accepted, never ran.
|
|
170
|
+
async_step(:notify) { run { ... }; undo { ... } }
|
|
171
|
+
# After: raises RubyReactor::Error::ValidationError. Use the unit's `compensate`, a reader's
|
|
172
|
+
# `compensate`, or an `async_reactor` child whose steps declare `undo`.
|
|
173
|
+
async_reactor :notify, NotifyReactor # NotifyReactor's steps declare `undo`
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
4. **`retries` on `compose` / `async_reactor` raises at class definition** (breaking, API). Move
|
|
177
|
+
the retries onto the child step that can fail transiently. The child retries it itself; the
|
|
178
|
+
other child steps run once.
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
# Before: retried the whole child from the parent.
|
|
182
|
+
compose(:booking, BookingReactor) { retries max_attempts: 2 }
|
|
183
|
+
# After: raises RubyReactor::Error::DeprecatedDslError. Declare it on the child's step:
|
|
184
|
+
class ConfirmBookingStep < RubyReactor::Step
|
|
185
|
+
retries max_attempts: 2
|
|
186
|
+
end
|
|
187
|
+
compose :booking, BookingReactor
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
5. **`where` / `guard` are removed** (breaking, API). Skip from the step body. Unlike `where`,
|
|
191
|
+
the body decides after the step started: its arguments are resolved and validated (a step
|
|
192
|
+
that relied on `where` to avoid invalid arguments now fails on them), and its lock, semaphore
|
|
193
|
+
and rate-limit slot are taken first. A `background before:` hand-off at that step now always
|
|
194
|
+
fires; the body decides in the worker.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# Before
|
|
198
|
+
step :sync_user do
|
|
199
|
+
where { |ctx| ctx.get_input(:enabled) }
|
|
200
|
+
run { |inputs, _ctx| Success(sync!(inputs.user)) }
|
|
201
|
+
end
|
|
202
|
+
# After
|
|
203
|
+
step :sync_user do
|
|
204
|
+
run do |inputs, ctx|
|
|
205
|
+
next Skipped(nil) unless ctx.get_input(:enabled)
|
|
206
|
+
Success(sync!(inputs.user))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
6. **Non-`StandardError` exceptions from reactor code roll back** (breaking, behavior). They no
|
|
212
|
+
longer propagate out of `Reactor.run`; check the returned `Failure` instead. A test assertion
|
|
213
|
+
error raised inside a step body (an RSpec expectation, a strict double) now surfaces as the
|
|
214
|
+
step's `Failure`, so assert on the result.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# Before: NotImplementedError propagated; nothing was undone; the run was stored `aborted`.
|
|
218
|
+
# After:
|
|
219
|
+
result = MyReactor.run(inputs)
|
|
220
|
+
result.failure? # => true, completed steps undone
|
|
221
|
+
result.exception_class # => "NotImplementedError"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
7. **Argument and unknown errors roll back and carry `step_name`** (fix). The Failure's
|
|
225
|
+
shape changes on these paths.
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# Before: "Execution failed: invalid value for Float(): 'abc'", step_name nil, nothing undone.
|
|
229
|
+
# After: "Step 'charge' failed: Step 'charge' could not resolve its arguments: …",
|
|
230
|
+
# step_name :charge, exception_class "ArgumentError", completed steps undone.
|
|
231
|
+
result.step_name # => :charge
|
|
232
|
+
result.exception_class # => "ArgumentError"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
8. **New `aborted` status** (additive), only for runs in the caller's process cut short by an
|
|
236
|
+
interruption. Dashboards and status filters gain a value; an `aborted` run needs
|
|
237
|
+
`MyReactor.undo(id)` to roll back.
|
|
238
|
+
|
|
239
|
+
9. **`rollback_failures` entries may carry `map_step:` / `element_index:`** and the reasons
|
|
240
|
+
`:context_unavailable` / `:element_in_flight` (additive).
|
|
241
|
+
|
|
81
242
|
### Features
|
|
82
243
|
|
|
244
|
+
* **`aborted` execution status.** A run in the caller's process that an interruption
|
|
245
|
+
(`SignalException` including `Interrupt`, `SystemExit`, `NoMemoryError`, or an enclosing
|
|
246
|
+
`Timeout.timeout`) cuts short runs no rollback code: the exception reaches the caller unchanged,
|
|
247
|
+
and the run is stored as `aborted` with only the steps not yet undone still outstanding (an
|
|
248
|
+
interruption during a rollback keeps exactly the rest, whichever failure started that rollback).
|
|
249
|
+
Workers and the sweeper never resume it; `Reactor.undo(id)` rolls it back, including the steps a
|
|
250
|
+
composed child or the elements of an inline `map` completed when the interruption hit inside
|
|
251
|
+
them. The dashboard and web API show and filter it, next to `failed`. A worker run is unchanged
|
|
252
|
+
(its job is redelivered).
|
|
83
253
|
* **Step-scoped coordination.** Steps can declare `with_lock`, `with_semaphore`, `with_rate_limit`,
|
|
84
254
|
`with_period`, and `with_ordered_lock` — the same macros as the reactor form, keyed on the step's
|
|
85
255
|
own resolved arguments instead of the reactor's inputs — so one step of a workflow can be
|
|
@@ -140,6 +310,25 @@
|
|
|
140
310
|
|
|
141
311
|
### Bug Fixes
|
|
142
312
|
|
|
313
|
+
* `Reactor.continue` accepts a resume only while the reactor is paused at an interrupt. A resume
|
|
314
|
+
that arrives while the reactor is executing or rolling back, or after it finished or was
|
|
315
|
+
aborted, raises `RubyReactor::Error::ValidationError` and changes nothing. Before, it resumed the
|
|
316
|
+
run from its stored state, which could run a rolled-back or aborted run forward again. An
|
|
317
|
+
accepted resume marks the run `running` before executing, so a concurrent second resume fails.
|
|
318
|
+
A resume that cannot take the reactor's lock or semaphore raises its `AcquisitionError` and
|
|
319
|
+
leaves the run paused, so the caller can retry it.
|
|
320
|
+
* A `background after:` step that returns `Halt` no longer hands the rest of the run to a worker:
|
|
321
|
+
the run halts, as `Halt` promises. Before, the remaining steps ran in a worker.
|
|
322
|
+
* A stored `Failure` keeps at most 100 backtrace frames, plus a `"... N more frames"` line. A stack
|
|
323
|
+
overflow's backtrace no longer inflates the stored context (about 2.4 MB to 23 KB).
|
|
324
|
+
* Every error after a completed step now rolls back, and the failure names its step. An `argument`
|
|
325
|
+
source, `transform` or result path that raises fails the step with
|
|
326
|
+
`RubyReactor::Error::ArgumentResolutionError`: completed steps are undone, the step is neither
|
|
327
|
+
compensated (its body never started) nor retried, and the Failure carries `step_name`,
|
|
328
|
+
`reactor_name` and the original `exception_class`. Before, an argument error rolled nothing back
|
|
329
|
+
and carried no step. The same holds in a worker (an `async_step` unit, a `background` hand-off).
|
|
330
|
+
Any other exception raised outside a step body ("Execution failed: …") now rolls back completed
|
|
331
|
+
steps too, and a failure whose compensation raised ("Execution error: …") carries `step_name`.
|
|
143
332
|
* A supplied `false` reactor input or step result no longer resolves to `nil`.
|
|
144
333
|
`Context#get_input`, `Context#get_result` and `Template::Result#fetch` now check whether the key
|
|
145
334
|
exists instead of whether the value is truthy. Code that relied on `false` arriving as `nil`
|
|
@@ -204,6 +393,13 @@
|
|
|
204
393
|
* Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
|
|
205
394
|
within the lock's `ttl`; a lapsed lock is acquired again.
|
|
206
395
|
|
|
396
|
+
## [0.8.5](https://github.com/arturictus/ruby_reactor/compare/v0.8.4...v0.8.5) (2026-09-30)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
### Miscellaneous Chores
|
|
400
|
+
|
|
401
|
+
* Execution flows improvements and consolidation ([#65](https://github.com/arturictus/ruby_reactor/issues/65)) ([21a4c59](https://github.com/arturictus/ruby_reactor/commit/21a4c59fa13c0e7e66c8e08ccb5f20ea43875353))
|
|
402
|
+
|
|
207
403
|
## [0.8.4](https://github.com/arturictus/ruby_reactor/compare/v0.8.3...v0.8.4) (2026-09-26)
|
|
208
404
|
|
|
209
405
|
|
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
3
|
shell commands, and other important information, read the current plan
|
|
4
|
-
at specs/
|
|
4
|
+
at specs/008-rollback-reliability/plan.md
|
|
5
5
|
<!-- SPECKIT END -->
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
|
|
|
13
13
|
|
|
14
14
|
Building complex business transactions often results in spaghetti code or brittle "god classes." Ruby Reactor solves this by implementing the **Saga Pattern** in a lightweight, developer-friendly package. It lets you define workflows as clear, dependency-driven steps without the boilerplate of heavy enterprise frameworks.
|
|
15
15
|
|
|
16
|
-
The key value is **Reliability**: if any part of your workflow fails, Ruby Reactor automatically triggers compensation logic to undo previous steps, ensuring your system never ends up in a corrupted half-state. Whether you're coordinating microservices or monolith modules, you get atomic-like consistency with background processing built-in.
|
|
16
|
+
The key value is **Reliability**: if any part of your workflow fails with an error, Ruby Reactor automatically triggers compensation logic to undo previous steps, ensuring your system never ends up in a corrupted half-state. Only an interruption (a signal, an exit, out of memory) runs no rollback code; the run is recorded as `aborted` so a manual `undo` can roll it back. Whether you're coordinating microservices or monolith modules, you get atomic-like consistency with background processing built-in.
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
22
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.
|
|
23
23
|
- **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
|
|
24
24
|
- **Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.
|
|
25
|
-
- **Compensation**: Automatic rollback of completed steps when a
|
|
25
|
+
- **Compensation**: Automatic rollback of completed steps when any error occurs after them, including a raising argument transform or an exception that is not a `StandardError`.
|
|
26
26
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
27
27
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
28
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.
|
|
@@ -32,7 +32,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
32
32
|
| Feature | Ruby Reactor | dry-transaction | Trailblazer | Custom Sidekiq Jobs |
|
|
33
33
|
|--------------------------|--------------|-----------------|-------------|---------------------|
|
|
34
34
|
| DAG/Parallel execution | Yes | No | Limited | Manual |
|
|
35
|
-
| Auto compensation/undo | Yes
|
|
35
|
+
| Auto compensation/undo | Yes (steps, compose, map; async units roll back on their own) | No | Manual | Manual |
|
|
36
36
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
37
37
|
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
38
38
|
| Built-in web dashboard | Yes | No | No | No |
|
|
@@ -237,7 +237,7 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
|
|
|
237
237
|
- **`Success(value)`** — step succeeded; `value` flows to dependent steps.
|
|
238
238
|
- **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
|
|
239
239
|
- **`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).
|
|
240
|
-
- **`Skipped(value)`** — mark this one step skipped
|
|
240
|
+
- **`Skipped(value)`** — mark this one step skipped. The run continues exactly as for `Success` — `value` flows to dependants — and the step is never undone; the execution trace marks it skipped. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
|
|
241
241
|
|
|
242
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.
|
|
243
243
|
|
|
@@ -544,10 +544,14 @@ time. On success the reader gets the raw value; on failure it gets the
|
|
|
544
544
|
|
|
545
545
|
**Compensation is opt-in.** If a dispatched step fails and nothing reads its
|
|
546
546
|
result, the reactor is not compensated — it was dispatched precisely so the
|
|
547
|
-
reactor would not depend on it.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
547
|
+
reactor would not depend on it. The unit's own `compensate` does run: once, in
|
|
548
|
+
the unit's job, after its final attempt fails, recorded on the unit's record as
|
|
549
|
+
`compensation`. A reader that returns `Failure` compensates itself and undoes the
|
|
550
|
+
reactor's completed steps; the unit is not compensated again. The independence
|
|
551
|
+
cuts both ways: async dispatches never enter the parent's undo stack, so a
|
|
552
|
+
parent rolling back for its own reasons never "undoes" a unit that runs (and may
|
|
553
|
+
still succeed) elsewhere. An inline `undo` on an `async_step` would never run, so
|
|
554
|
+
it raises at class-definition time (a step class's `undo` is warned about).
|
|
551
555
|
|
|
552
556
|
#### `async_reactor`: a whole nested reactor, running independently
|
|
553
557
|
|
|
@@ -850,7 +854,7 @@ step :ensure_active do
|
|
|
850
854
|
end
|
|
851
855
|
```
|
|
852
856
|
|
|
853
|
-
To
|
|
857
|
+
To mark a *single* step as having had nothing to do — the rest of the workflow runs as usual — return `Skipped(value)` instead. `Skipped` is only an instrumentation mark, so an engineer reviewing the execution can see the step did not need to run. It never changes how the run executes: the value flows to dependants, and a `background` hand-off and a `with_period` bucket treat it as a completed step. A skipped step had nothing to do, so it is never undone. (`where`/`guard` were removed; skipping from the body is the only way.)
|
|
854
858
|
|
|
855
859
|
```ruby
|
|
856
860
|
step :maybe_sync do
|
|
@@ -893,6 +897,14 @@ A `fan_out` map is a **hand-off point**: the reactor stops at the map (the calle
|
|
|
893
897
|
|
|
894
898
|
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).
|
|
895
899
|
|
|
900
|
+
**Rollback.** A map rolls back like a composed reactor, with no map-level rollback DSL: the `undo`s
|
|
901
|
+
already declared on the element reactor's steps are each element's rollback. When the map fails
|
|
902
|
+
(an element fails under `fail_fast`, or `collect` raises), and when a later step fails or the run is
|
|
903
|
+
undone manually, every element that completed is rolled back, highest index first, in inline and
|
|
904
|
+
fan-out mode alike. A fail-fast fan-out map lets elements already in flight finish before it reports
|
|
905
|
+
the failure. Make element `undo`s idempotent. See
|
|
906
|
+
[Rollback](documentation/data_pipelines.md#rollback).
|
|
907
|
+
|
|
896
908
|
`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).
|
|
897
909
|
|
|
898
910
|
> **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`).
|
|
@@ -1306,7 +1318,7 @@ end
|
|
|
1306
1318
|
|
|
1307
1319
|
### Error Handling and Compensation
|
|
1308
1320
|
|
|
1309
|
-
When a step fails, RubyReactor automatically undoes completed steps in reverse order, compensate only runs in the failing step and backwalks the executed steps undo blocks:
|
|
1321
|
+
When a step fails, RubyReactor automatically undoes completed steps in reverse order, compensate only runs in the failing step and backwalks the executed steps undo blocks. Composed reactors and maps are completed steps too: undoing one replays its child's (or each completed element's) own step `undo`s:
|
|
1310
1322
|
|
|
1311
1323
|
```ruby
|
|
1312
1324
|
class TransactionReactor < RubyReactor::Reactor
|
|
@@ -1408,9 +1420,33 @@ result.rollback_failures
|
|
|
1408
1420
|
```
|
|
1409
1421
|
|
|
1410
1422
|
`reason` is `:coordination_unavailable`, `:returned_failure`, or `:raised`; `kind`
|
|
1411
|
-
is `:undo` or `:compensate`.
|
|
1423
|
+
is `:undo` or `:compensate`. An entry from a map element's rollback also carries
|
|
1424
|
+
`map_step:` and `element_index:`, and a map reports an element it could not roll
|
|
1425
|
+
back with `reason: :context_unavailable` (its stored context, or the map's element index, expired) or
|
|
1426
|
+
`:element_in_flight` (a duplicate of it was still running). See
|
|
1412
1427
|
[Step Rollback](documentation/locks_and_semaphores.md#step-rollback).
|
|
1413
1428
|
|
|
1429
|
+
Every error after a completed step rolls back, not only a step body's failure.
|
|
1430
|
+
An `argument` source, `transform` or result path that raises fails that step
|
|
1431
|
+
with `RubyReactor::Error::ArgumentResolutionError`. The step is not compensated
|
|
1432
|
+
(its body never started) nor retried; completed steps are undone. The Failure names the
|
|
1433
|
+
step, and `exception_class` reports the original error's class:
|
|
1434
|
+
|
|
1435
|
+
```ruby
|
|
1436
|
+
result.step_name # => :charge
|
|
1437
|
+
result.exception_class # => "ArgumentError"
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
That holds for every exception, not only a `StandardError`: a step class that
|
|
1441
|
+
never implemented `run` (`NotImplementedError`) or a custom `Exception` subclass
|
|
1442
|
+
fails its step and rolls back the same way, and `exception_class` names it.
|
|
1443
|
+
|
|
1444
|
+
Only an interruption (a signal such as `Interrupt`, `SystemExit`, `NoMemoryError`,
|
|
1445
|
+
or an enclosing `Timeout.timeout`) reaches the caller unchanged and runs no
|
|
1446
|
+
rollback code. A run in the caller's process is stored with status `aborted`,
|
|
1447
|
+
the steps not yet undone still outstanding; `MyReactor.undo(id)` rolls them
|
|
1448
|
+
back. A run in a worker is redelivered instead.
|
|
1449
|
+
|
|
1414
1450
|
### Using Pre-defined Schemas
|
|
1415
1451
|
|
|
1416
1452
|
You can use existing dry-validation schemas:
|
|
@@ -137,9 +137,38 @@ module RubyReactor
|
|
|
137
137
|
builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
|
|
138
138
|
builder.instance_eval(&block) if block_given?
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
config = builder.build(async_dispatch: :step)
|
|
141
|
+
check_async_step_undo!(builder, config, caller_locations(1, 1).first)
|
|
142
|
+
steps[name] = config
|
|
141
143
|
end
|
|
142
144
|
|
|
145
|
+
# An async_step is never undone: its parent does not track it for undo
|
|
146
|
+
# (008 R-09/R-10), so an `undo` on it would be dead code. An inline one
|
|
147
|
+
# is rejected; a step class's is only warned about, because the same
|
|
148
|
+
# class is legitimately reused by ordinary steps, where it does run.
|
|
149
|
+
def check_async_step_undo!(builder, config, site)
|
|
150
|
+
if config.undo_block
|
|
151
|
+
raise RubyReactor::Error::ValidationError,
|
|
152
|
+
"`undo` on async_step :#{config.name} would never run: the parent never undoes an " \
|
|
153
|
+
"independent async unit. Put failure cleanup in the unit's `compensate` (it runs in the " \
|
|
154
|
+
"unit's job when its last attempt fails) or in the reading step's `compensate`; for cleanup " \
|
|
155
|
+
"that must run when the parent rolls back, use a `step`/`compose`/`map` (tracked for undo) " \
|
|
156
|
+
"or an `async_reactor` child whose steps declare `undo`."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return unless defines_undo?(config.impl)
|
|
160
|
+
|
|
161
|
+
builder.send(:warn_definition, site, nil,
|
|
162
|
+
"async_step :#{config.name} uses #{config.impl}; its `undo` will not run for this async " \
|
|
163
|
+
"use (async units are never undone).")
|
|
164
|
+
end
|
|
165
|
+
private :check_async_step_undo!
|
|
166
|
+
|
|
167
|
+
def defines_undo?(impl)
|
|
168
|
+
impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step
|
|
169
|
+
end
|
|
170
|
+
private :defines_undo?
|
|
171
|
+
|
|
143
172
|
# Dispatch a whole nested reactor to run INDEPENDENTLY — linked
|
|
144
173
|
# to this one by execution id for traceability, but excluded from its
|
|
145
174
|
# compensation graph. Fire-and-forget unless a later step reads
|
|
@@ -10,7 +10,6 @@ module RubyReactor
|
|
|
10
10
|
# ordering nonce.
|
|
11
11
|
class AsyncReactorBuilder
|
|
12
12
|
include RubyReactor::Dsl::TemplateHelpers
|
|
13
|
-
include RubyReactor::Dsl::Retryable
|
|
14
13
|
|
|
15
14
|
attr_accessor :name, :child_reactor_class, :argument_mappings
|
|
16
15
|
|
|
@@ -19,13 +18,23 @@ module RubyReactor
|
|
|
19
18
|
@child_reactor_class = child_reactor_class
|
|
20
19
|
@reactor = reactor
|
|
21
20
|
@argument_mappings = {}
|
|
22
|
-
@retry_config = nil
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
def argument(child_input_name, source)
|
|
26
24
|
@argument_mappings[child_input_name] = source
|
|
27
25
|
end
|
|
28
26
|
|
|
27
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
28
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
29
|
+
def retries(*)
|
|
30
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
31
|
+
"`retries` on an `async_reactor` has been removed: a parent never retries a nested " \
|
|
32
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
33
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
34
|
+
step: @name
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def build
|
|
30
39
|
RubyReactor::Dsl::StepConfig.new(
|
|
31
40
|
async_dispatch: :reactor,
|
|
@@ -41,12 +50,9 @@ module RubyReactor
|
|
|
41
50
|
# that reads `result(:name)` and decides to fail.
|
|
42
51
|
compensate_block: nil,
|
|
43
52
|
undo_block: nil,
|
|
44
|
-
conditions: [],
|
|
45
|
-
guards: [],
|
|
46
53
|
dependencies: dependencies_from_mappings,
|
|
47
54
|
args_validator: nil,
|
|
48
|
-
output_validator: nil
|
|
49
|
-
retry_config: @retry_config
|
|
55
|
+
output_validator: nil
|
|
50
56
|
)
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -4,7 +4,6 @@ module RubyReactor
|
|
|
4
4
|
module Dsl
|
|
5
5
|
class ComposeBuilder
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
|
-
include RubyReactor::Dsl::Retryable
|
|
8
7
|
|
|
9
8
|
attr_accessor :name, :composed_reactor_class, :argument_mappings
|
|
10
9
|
|
|
@@ -21,7 +20,6 @@ module RubyReactor
|
|
|
21
20
|
end
|
|
22
21
|
@reactor = reactor
|
|
23
22
|
@argument_mappings = {}
|
|
24
|
-
@retry_config = nil
|
|
25
23
|
end
|
|
26
24
|
|
|
27
25
|
def argument(composed_input_name, source)
|
|
@@ -45,6 +43,17 @@ module RubyReactor
|
|
|
45
43
|
)
|
|
46
44
|
end
|
|
47
45
|
|
|
46
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
47
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
48
|
+
def retries(*)
|
|
49
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
50
|
+
"`retries` on a `compose` has been removed: a parent never retries a nested " \
|
|
51
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
52
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
53
|
+
step: @name
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
def build
|
|
49
58
|
warn_if_child_has_ordered_lock!
|
|
50
59
|
dependencies = extract_dependencies_from_mappings
|
|
@@ -59,12 +68,9 @@ module RubyReactor
|
|
|
59
68
|
run_block: nil,
|
|
60
69
|
compensate_block: nil,
|
|
61
70
|
undo_block: nil,
|
|
62
|
-
conditions: [],
|
|
63
|
-
guards: [],
|
|
64
71
|
dependencies: dependencies,
|
|
65
72
|
args_validator: nil,
|
|
66
|
-
output_validator: nil
|
|
67
|
-
retry_config: @retry_config
|
|
73
|
+
output_validator: nil
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -73,9 +73,7 @@ module RubyReactor
|
|
|
73
73
|
validation_schema: @validation_schema,
|
|
74
74
|
max_attempts: @max_attempts,
|
|
75
75
|
resume_mode: @resume_mode,
|
|
76
|
-
dependencies: @dependencies
|
|
77
|
-
conditions: @conditions,
|
|
78
|
-
guards: @guards
|
|
76
|
+
dependencies: @dependencies
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
RubyReactor::Dsl::InterruptStepConfig.new(step_config)
|