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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +196 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +47 -11
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
  13. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  14. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  16. data/lib/ruby_reactor/executor/result_handler.rb +18 -16
  17. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  18. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  19. data/lib/ruby_reactor/executor.rb +38 -4
  20. data/lib/ruby_reactor/map/collector.rb +21 -11
  21. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  22. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  23. data/lib/ruby_reactor/map/helpers.rb +32 -2
  24. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  25. data/lib/ruby_reactor/reactor.rb +24 -0
  26. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  27. data/lib/ruby_reactor/step/compose_step.rb +7 -1
  28. data/lib/ruby_reactor/step/map_step.rb +109 -4
  29. data/lib/ruby_reactor/step.rb +7 -0
  30. data/lib/ruby_reactor/step_worker.rb +46 -22
  31. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  33. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +1 -1
  36. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  37. data/lib/ruby_reactor/web/public/index.html +1 -1
  38. data/lib/ruby_reactor/worker.rb +3 -1
  39. data/lib/ruby_reactor.rb +17 -6
  40. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  41. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  42. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  43. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  44. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  45. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  46. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  47. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  48. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  49. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  50. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  51. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  53. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  54. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  55. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  56. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  57. data/specs/007-execution-flow-analysis/plan.md +127 -0
  58. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  59. data/specs/007-execution-flow-analysis/research.md +202 -0
  60. data/specs/007-execution-flow-analysis/spec.md +270 -0
  61. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  62. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  63. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  64. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  65. data/specs/008-rollback-reliability/data-model.md +139 -0
  66. data/specs/008-rollback-reliability/plan.md +233 -0
  67. data/specs/008-rollback-reliability/quickstart.md +105 -0
  68. data/specs/008-rollback-reliability/research.md +653 -0
  69. data/specs/008-rollback-reliability/spec.md +561 -0
  70. data/specs/008-rollback-reliability/tasks.md +1110 -0
  71. data/specs/future_improvements.md +48 -0
  72. metadata +35 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 325464f0f2b9ee374b1267a4c7243121c9b564814d425bfa5ecce59891979468
4
- data.tar.gz: 996363b210d54be36ac7065532aac6c0349334b18d8d14816896debd4e8993c4
3
+ metadata.gz: a4187fd51277447b11381c872a01b46d8e7663323b5b470316d8600ead76906e
4
+ data.tar.gz: 540b0c6b94d007a020f3bfbb3ff035eb47469691bf138cd41588e5a6a28ba468
5
5
  SHA512:
6
- metadata.gz: 3fcbdbe30cce51beec8dd715c543c8db15140de630d94ec3ae01a7ba803d28cb88f44e0ce1ee97c232cbed34863d81b8c4d0ed979f44acf52a27cfbc07f31b04
7
- data.tar.gz: 946f4082ec39691d47e35983b9466a128246159f0b191aca061d6c984e2340cabd09da8e111e92b4799ec97a8f4e9753826163e97df48424b3a1583abe40afbc
6
+ metadata.gz: 59c5faa4c755625a2172a5a7613941a82970aa3b52ac170f6c2f12015f41b289700d320d0195b51d4b13e1cf1f9b86e209bc8c59d5dcc84830a2e65c1c49a48d
7
+ data.tar.gz: 0be05ffe9539a5ae4c012a8f8e3f26394d27853a86219a7c02d82d9685f7f018bd760c26f749cf34c4899f0c9346c6a54a91ee64e0e274b2f2ad2d12048ee818
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.4"
2
+ ".": "0.8.5"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/006-step-retry-declarations"
2
+ "feature_directory": "specs/008-rollback-reliability"
3
3
  }
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/006-step-retry-declarations/plan.md
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 failure occurs.
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 | No | Manual | Manual |
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; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
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. A reader that returns `Failure` triggers
548
- compensation normally. The independence cuts both ways: async dispatches never
549
- enter the parent's undo stack, so a parent rolling back for its own reasons
550
- never "undoes" a unit that runs (and may still succeed) elsewhere.
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 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:
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`. See
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
- steps[name] = builder.build(async_dispatch: :step)
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)
@@ -129,8 +129,6 @@ module RubyReactor
129
129
  run_block: nil,
130
130
  compensate_block: nil,
131
131
  undo_block: nil,
132
- conditions: [],
133
- guards: [],
134
132
  dependencies: dependencies.uniq,
135
133
  args_validator: nil,
136
134
  output_validator: nil