ruby_reactor 0.8.3 → 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 (89) 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 +208 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +140 -102
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
  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/reactor.rb +8 -11
  13. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  14. data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
  15. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  16. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  17. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  19. data/lib/ruby_reactor/executor/result_handler.rb +21 -18
  20. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  21. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  22. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  23. data/lib/ruby_reactor/executor.rb +38 -4
  24. data/lib/ruby_reactor/map/collector.rb +21 -11
  25. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  26. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  27. data/lib/ruby_reactor/map/helpers.rb +32 -2
  28. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  29. data/lib/ruby_reactor/reactor.rb +24 -0
  30. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  31. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  32. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  33. data/lib/ruby_reactor/step/compose_step.rb +14 -4
  34. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  35. data/lib/ruby_reactor/step/inputs.rb +57 -0
  36. data/lib/ruby_reactor/step/map_step.rb +140 -25
  37. data/lib/ruby_reactor/step.rb +20 -7
  38. data/lib/ruby_reactor/step_worker.rb +46 -22
  39. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  40. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  41. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  42. data/lib/ruby_reactor/version.rb +1 -1
  43. data/lib/ruby_reactor/web/api.rb +1 -1
  44. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  45. data/lib/ruby_reactor/web/public/index.html +1 -1
  46. data/lib/ruby_reactor/worker.rb +3 -1
  47. data/lib/ruby_reactor.rb +20 -7
  48. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  49. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  50. data/specs/006-step-retry-declarations/data-model.md +58 -0
  51. data/specs/006-step-retry-declarations/plan.md +187 -0
  52. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  53. data/specs/006-step-retry-declarations/research.md +194 -0
  54. data/specs/006-step-retry-declarations/spec.md +453 -0
  55. data/specs/006-step-retry-declarations/tasks.md +382 -0
  56. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  57. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  58. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  59. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  60. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  61. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  62. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  63. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  64. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  65. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  66. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  67. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  68. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  69. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  72. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  73. data/specs/007-execution-flow-analysis/plan.md +127 -0
  74. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  75. data/specs/007-execution-flow-analysis/research.md +202 -0
  76. data/specs/007-execution-flow-analysis/spec.md +270 -0
  77. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  78. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  79. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  80. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  81. data/specs/008-rollback-reliability/data-model.md +139 -0
  82. data/specs/008-rollback-reliability/plan.md +233 -0
  83. data/specs/008-rollback-reliability/quickstart.md +105 -0
  84. data/specs/008-rollback-reliability/research.md +653 -0
  85. data/specs/008-rollback-reliability/spec.md +561 -0
  86. data/specs/008-rollback-reliability/tasks.md +1110 -0
  87. data/specs/future_improvements.md +48 -0
  88. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  89. metadata +47 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac772fc307621dcf2fba69d726aeb670a34201a0c06e17e1e9213b4779f4cd03
4
- data.tar.gz: 5ed6d5e4251643fc16fafaf48ecde75aa31f95cc09f9b1fe9d0b879b190fa61b
3
+ metadata.gz: a4187fd51277447b11381c872a01b46d8e7663323b5b470316d8600ead76906e
4
+ data.tar.gz: 540b0c6b94d007a020f3bfbb3ff035eb47469691bf138cd41588e5a6a28ba468
5
5
  SHA512:
6
- metadata.gz: c61a1ec89ce310154e5bd9d2b09c3dc997e682a83d1bb5b18a87d47128f792133e27e7c8648db699cbfa5de8830522dad9bdfbb7b953addf89273fa3eb3042e0
7
- data.tar.gz: b78511087cfccec3eb6912000c3d744fa0f1cabca1f87eed9a500d223eb2aa4e6d1e4219a85eb2c6774f54e14493179d3c86ba836e4012cf9a86ec3a553955c1
6
+ metadata.gz: 59c5faa4c755625a2172a5a7613941a82970aa3b52ac170f6c2f12015f41b289700d320d0195b51d4b13e1cf1f9b86e209bc8c59d5dcc84830a2e65c1c49a48d
7
+ data.tar.gz: 0be05ffe9539a5ae4c012a8f8e3f26394d27853a86219a7c02d82d9685f7f018bd760c26f749cf34c4899f0c9346c6a54a91ee64e0e274b2f2ad2d12048ee818
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.3"
2
+ ".": "0.8.5"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/005-step-coordination-remediation"
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,25 @@
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
+
403
+ ## [0.8.4](https://github.com/arturictus/ruby_reactor/compare/v0.8.3...v0.8.4) (2026-09-26)
404
+
405
+
406
+ ### ⚠ BREAKING CHANGES
407
+
408
+ * `retry_defaults` on a reactor raises RubyReactor::Error::DeprecatedDslError. Migration: move the values onto each step that should retry (`retries max_attempts: 3, backoff: :exponential, base_delay: 2` inside the step block, or on the step class once supported). A step without `retries` runs once. `max_attempts: 0` is not valid: use `max_attempts: 1` (or omit `retries`) for a step that must never retry.
409
+
410
+ ### Features
411
+
412
+ * implement step-scoped retry declarations ([#61](https://github.com/arturictus/ruby_reactor/issues/61)) ([75d9c4e](https://github.com/arturictus/ruby_reactor/commit/75d9c4edca1a6ec53183d56c9c6261e792c98cc2))
413
+ * Inputs protection ([#63](https://github.com/arturictus/ruby_reactor/issues/63)) ([faf90e8](https://github.com/arturictus/ruby_reactor/commit/faf90e8dbfff87f6492af863b90e8247c84bd673))
414
+
207
415
  ## [0.8.3](https://github.com/arturictus/ruby_reactor/compare/v0.8.2...v0.8.3) (2026-09-25)
208
416
 
209
417
 
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/005-step-coordination-remediation/plan.md
4
+ at specs/008-rollback-reliability/plan.md
5
5
  <!-- SPECKIT END -->