ruby_reactor 0.8.4 → 0.8.6

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 325464f0f2b9ee374b1267a4c7243121c9b564814d425bfa5ecce59891979468
4
- data.tar.gz: 996363b210d54be36ac7065532aac6c0349334b18d8d14816896debd4e8993c4
3
+ metadata.gz: c59b0af2a9d34b620e3400cff78e0df3b2e580d1138b37ed5aee1b9e5a78f548
4
+ data.tar.gz: 4e3c2b92b6ae58797beae470b60c69015e15b2b8d6855ce710e71a999d0ae62f
5
5
  SHA512:
6
- metadata.gz: 3fcbdbe30cce51beec8dd715c543c8db15140de630d94ec3ae01a7ba803d28cb88f44e0ce1ee97c232cbed34863d81b8c4d0ed979f44acf52a27cfbc07f31b04
7
- data.tar.gz: 946f4082ec39691d47e35983b9466a128246159f0b191aca061d6c984e2340cabd09da8e111e92b4799ec97a8f4e9753826163e97df48424b3a1583abe40afbc
6
+ metadata.gz: a3ac367241909b09a5f0451b9278489776e7bedc56b506043c786892b86247a646b297a7c6192be6e5935b5e9a861f36e66e2f370a7c3e0741820cbcaa1ee409
7
+ data.tar.gz: 1fd3ff1f692008747021d11d5356613163a8c7777e33ead22260da4b3959bc5f6ec7fff3cb5928444815298011ff4b0e1e2e10e34c55a9781b98d9ab80893e40
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.4"
2
+ ".": "0.8.6"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/006-step-retry-declarations"
2
+ "feature_directory": "specs/009-distributed-map-undo"
3
3
  }
@@ -1,27 +1,28 @@
1
1
  <!--
2
2
  SYNC IMPACT REPORT
3
3
  ==================
4
- Version change: 1.2.1 → 1.3.0 (MINOR: Development Workflow now REQUIRES every generated
5
- plan.md and tasks.md to carry an explicit documentation-update task/gate, with the
6
- requirement propagated into both templates — a new enforcement mechanism, not just
7
- wording)
4
+ Version change: 1.3.0 → 1.3.1 (PATCH: factual correction in Technical Constraints — the worker
5
+ path named a directory that no longer exists; no principle or rule changed)
8
6
 
9
- Modified principles: none (Development Workflow section, not a Core Principle)
7
+ Modified principles: none
8
+
9
+ Modified sections: Technical Constraints — the "Sidekiq" bullet now points at
10
+ `lib/ruby_reactor/adapters/{sidekiq,active_job}/`, where the queue-backend workers actually
11
+ live, and names ActiveJob as the alternative adapter
10
12
 
11
13
  Added sections: none
12
14
 
13
15
  Removed sections: none
14
16
 
15
17
  Templates checked:
16
- - .specify/templates/plan-template.md ✅ Added Constitution Check checklist item:
17
- identify README.md/./documentation impact
18
- - .specify/templates/spec-template.md ✅ No principle-specific content — no edit required
19
- - .specify/templates/tasks-template.md ✅ Polish-phase doc task now explicit and marked
20
- REQUIRED, citing README.md + ./documentation
18
+ - .specify/templates/plan-template.md ✅ No reference to the worker path — no edit required
19
+ - .specify/templates/spec-template.md ✅ No reference — no edit required
20
+ - .specify/templates/tasks-template.md ✅ No reference — no edit required
21
21
  - .specify/templates/checklist-template.md ✅ Generic — no edit required
22
22
  - .specify/extensions.yml ✅ No before/after_constitution hooks registered
23
- - README.md ✅ No principle-specific claims to update
24
- - documentation/*.md ✅ No principle-specific claims to update
23
+ - README.md, documentation/*.md ✅ No reference to the old path
24
+
25
+ Found by /speckit-analyze on specs/009-distributed-map-undo (finding C5), 2026-09-30.
25
26
 
26
27
  Deferred TODOs: none
27
28
  -->
@@ -151,7 +152,9 @@ gaps in the matcher library surface as work instead of as workarounds.
151
152
  ## Technical Constraints
152
153
 
153
154
  - **Ruby**: >= 3.0.0 required. No polyfills for older Rubies.
154
- - **Sidekiq**: Core async dependency. Workers live in `lib/ruby_reactor/sidekiq_workers/`.
155
+ - **Sidekiq**: Core async dependency. ActiveJob is the supported alternative. Queue-backend
156
+ workers and routers live in `lib/ruby_reactor/adapters/{sidekiq,active_job}/`; the
157
+ backend-agnostic worker logic lives in `lib/ruby_reactor/worker.rb` and `lib/ruby_reactor/map/`.
155
158
  - **Redis**: Required for state persistence, locks, semaphores, rate limits, and
156
159
  periods. The gem does NOT manage Redis connections — callers provide them.
157
160
  - **dry-validation**: Input validation DSL. Schema definitions stay inside the
@@ -208,4 +211,4 @@ justified in the `Complexity Tracking` table of the plan.
208
211
  Compliance review: at each MINOR or MAJOR gem release, confirm this constitution
209
212
  still accurately reflects the codebase and update as needed.
210
213
 
211
- **Version**: 1.3.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-11
214
+ **Version**: 1.3.1 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-30
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,157 @@
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
+ * **`fan_out` without `batch_size` is back-pressured: at most 50 element jobs per throw**
245
+ (`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback. Before, every element was
246
+ enqueued at once. A map of more than 50 elements with no declared `batch_size` now runs in
247
+ throws of 50, which can lower its peak throughput: declare a larger `batch_size` to keep more
248
+ element jobs in flight.
249
+ * **Distributed rollback of fan-out maps.** A `fan_out` map that must be rolled back (it failed,
250
+ a later step failed, or `Reactor.undo`) now rolls back the way it ran: one
251
+ `MapElementRollbackWorker` job per started element, enqueued `batch_size` per throw with the
252
+ forward run's back pressure, each loading one element's state. Before, one process loaded every
253
+ element and undid them one after another. The run ends with the same `Failure` as an inline map's
254
+ rollback. An element's rollback saves after every undone step, so a worker killed mid-rollback
255
+ resumes after the last one (the undo that was cut off runs again: keep undos idempotent). Both
256
+ sweepers recover lost rollback jobs and resumes. Inline maps keep rolling back in process, now
257
+ reading element states 100 at a time.
258
+ * **`rolling_back` execution status.** A run whose rollback handed off at a fan-out map, until the
259
+ last element reports; then a worker undoes the steps before the map and the run ends `failed`
260
+ (`cancelled` for an undo). `Reactor.cancel` and `Reactor.undo` raise
261
+ `RubyReactor::Error::ValidationError` on it. The dashboard shows it (amber) in every status
262
+ surface, with the map's rollback progress (`total`, `settled`, `outstanding`, `failed`).
263
+ * **Router methods `perform_map_element_rollback_async` / `perform_map_element_rollback_in`** on both
264
+ shipped routers, with a `MapElementRollbackWorker` per adapter, on the queue `MapElementWorker`
265
+ uses. A custom router needs both methods for fan-out map rollback.
266
+ * **`be_rolling_back` matcher.** `pending_async_jobs` / `drain_async_jobs` work on the ActiveJob
267
+ test adapter too, and `worker_class` names a pending job's class on both backends.
268
+ * **`Reactor.undo` holds the run's context lock**, and raises `RubyReactor::Lock::AcquisitionError`
269
+ while a live run or rollback holds it.
270
+
271
+ * **`aborted` execution status.** A run in the caller's process that an interruption
272
+ (`SignalException` including `Interrupt`, `SystemExit`, `NoMemoryError`, or an enclosing
273
+ `Timeout.timeout`) cuts short runs no rollback code: the exception reaches the caller unchanged,
274
+ and the run is stored as `aborted` with only the steps not yet undone still outstanding (an
275
+ interruption during a rollback keeps exactly the rest, whichever failure started that rollback).
276
+ Workers and the sweeper never resume it; `Reactor.undo(id)` rolls it back, including the steps a
277
+ composed child or the elements of an inline `map` completed when the interruption hit inside
278
+ them. The dashboard and web API show and filter it, next to `failed`. A worker run is unchanged
279
+ (its job is redelivered).
83
280
  * **Step-scoped coordination.** Steps can declare `with_lock`, `with_semaphore`, `with_rate_limit`,
84
281
  `with_period`, and `with_ordered_lock` — the same macros as the reactor form, keyed on the step's
85
282
  own resolved arguments instead of the reactor's inputs — so one step of a workflow can be
@@ -132,6 +329,11 @@
132
329
 
133
330
  ### Deprecations
134
331
 
332
+ * **`fail_fast` on a `map` is now `atomic`** (same meaning: every element succeeds, or none is
333
+ kept). `fail_fast` keeps working and prints one deprecation line per declaration site naming
334
+ `atomic`; it will be removed no earlier than the next major version. Declaring both on one map
335
+ raises `RubyReactor::Error::ValidationError`. Element jobs enqueued before the upgrade, which
336
+ carry `fail_fast`, keep the policy they were enqueued with.
135
337
  * Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
136
338
  for steps without a contract, and print one deprecation notice per declaration site. Move them
137
339
  to `input` / `validate_inputs` on the step class, or into an `inputs do ... end` block for an
@@ -140,6 +342,44 @@
140
342
 
141
343
  ### Bug Fixes
142
344
 
345
+ * **A fan-out map inside a composed reactor no longer leaves the root running forever.** The map's
346
+ completion resumed the child as a run of its own, and nothing resumed the root. Now the root
347
+ resumes and finishes, and a failure of the map or of any later step rolls back through the root.
348
+ A map that was already running inside a composed child when you upgraded keeps the old
349
+ behavior: its metadata names no owner run.
350
+ * A fan-out map's completion now resumes the run through its own Worker, which adopts the map's
351
+ outcome, instead of the collector job resuming the run itself. This costs one extra job per
352
+ fan-out map completion, and removes the collector's write to the run's context.
353
+ * A map element re-dispatched by `RubyReactor::Map::Sweeper` kept neither `batch_size` nor
354
+ `fail_fast`: the map metadata never stored them, so the element stopped triggering later
355
+ batches and was no longer atomic. Both are stored now.
356
+ * `Executor#resume_execution` saves the context before it releases the run's context lock. Released
357
+ first, a worker resuming the same run could load the pre-save state and have its progress
358
+ overwritten by the late save.
359
+ * A map's undo record no longer stores the map's resolved source in the parent context.
360
+ * A step that returns another unit's `Failure` (a map adopting a failed element, a composed child)
361
+ keeps that Failure's `exception_class` on the run's final Failure, also when the step's retries
362
+ are exhausted. Before, an inline map whose element raised reported no `exception_class`.
363
+
364
+ * `Reactor.continue` accepts a resume only while the reactor is paused at an interrupt. A resume
365
+ that arrives while the reactor is executing or rolling back, or after it finished or was
366
+ aborted, raises `RubyReactor::Error::ValidationError` and changes nothing. Before, it resumed the
367
+ run from its stored state, which could run a rolled-back or aborted run forward again. An
368
+ accepted resume marks the run `running` before executing, so a concurrent second resume fails.
369
+ A resume that cannot take the reactor's lock or semaphore raises its `AcquisitionError` and
370
+ leaves the run paused, so the caller can retry it.
371
+ * A `background after:` step that returns `Halt` no longer hands the rest of the run to a worker:
372
+ the run halts, as `Halt` promises. Before, the remaining steps ran in a worker.
373
+ * A stored `Failure` keeps at most 100 backtrace frames, plus a `"... N more frames"` line. A stack
374
+ overflow's backtrace no longer inflates the stored context (about 2.4 MB to 23 KB).
375
+ * Every error after a completed step now rolls back, and the failure names its step. An `argument`
376
+ source, `transform` or result path that raises fails the step with
377
+ `RubyReactor::Error::ArgumentResolutionError`: completed steps are undone, the step is neither
378
+ compensated (its body never started) nor retried, and the Failure carries `step_name`,
379
+ `reactor_name` and the original `exception_class`. Before, an argument error rolled nothing back
380
+ and carried no step. The same holds in a worker (an `async_step` unit, a `background` hand-off).
381
+ Any other exception raised outside a step body ("Execution failed: …") now rolls back completed
382
+ steps too, and a failure whose compensation raised ("Execution error: …") carries `step_name`.
143
383
  * A supplied `false` reactor input or step result no longer resolves to `nil`.
144
384
  `Context#get_input`, `Context#get_result` and `Template::Result#fetch` now check whether the key
145
385
  exists instead of whether the value is truthy. Code that relied on `false` arriving as `nil`
@@ -204,6 +444,20 @@
204
444
  * Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
205
445
  within the lock's `ttl`; a lapsed lock is acquired again.
206
446
 
447
+ ## [0.8.6](https://github.com/arturictus/ruby_reactor/compare/v0.8.5...v0.8.6) (2026-10-08)
448
+
449
+
450
+ ### Miscellaneous Chores
451
+
452
+ * release commit ([#68](https://github.com/arturictus/ruby_reactor/issues/68)) ([9fcb14c](https://github.com/arturictus/ruby_reactor/commit/9fcb14cb8aa6a2c9e48d3327cb8eb279022f59c0))
453
+
454
+ ## [0.8.5](https://github.com/arturictus/ruby_reactor/compare/v0.8.4...v0.8.5) (2026-09-30)
455
+
456
+
457
+ ### Miscellaneous Chores
458
+
459
+ * Execution flows improvements and consolidation ([#65](https://github.com/arturictus/ruby_reactor/issues/65)) ([21a4c59](https://github.com/arturictus/ruby_reactor/commit/21a4c59fa13c0e7e66c8e08ccb5f20ea43875353))
460
+
207
461
  ## [0.8.4](https://github.com/arturictus/ruby_reactor/compare/v0.8.3...v0.8.4) (2026-09-26)
208
462
 
209
463
 
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/009-distributed-map-undo/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
 
@@ -630,7 +634,9 @@ That's all that's required: `start_sweeper!` is idempotent (safe to call on ever
630
634
  boot — duplicate kicks collapse to one chain), runs both the top-level reactor
631
635
  sweeper and the map sweeper every `config.sweeper_interval` seconds, and stops if
632
636
  you set `config.sweeper_enabled = false`. The interval is your recovery-latency
633
- bound.
637
+ bound. Both cover a fan-out map's distributed rollback too: the map sweeper
638
+ re-dispatches a lost element rollback job, and the reactor sweeper re-enqueues a
639
+ `rolling_back` run (like a `running` one) whose resume was lost.
634
640
 
635
641
  > **Sidekiq Enterprise `super_fetch` compatibility:** the chain is safe under
636
642
  > reliable fetch. `super_fetch` re-runs a job whose worker died mid-execution, so
@@ -850,7 +856,7 @@ step :ensure_active do
850
856
  end
851
857
  ```
852
858
 
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:
859
+ 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
860
 
855
861
  ```ruby
856
862
  step :maybe_sync do
@@ -889,11 +895,23 @@ class DataProcessingReactor < RubyReactor::Reactor
889
895
  end
890
896
  ```
891
897
 
892
- A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`).
898
+ A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`), and inside a `compose`d child, where the top-level run is the one that resumes.
893
899
 
894
900
  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
901
 
896
- `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).
902
+ **Rollback.** A map rolls back like a composed reactor, with no map-level rollback DSL: the `undo`s
903
+ already declared on the element reactor's steps are each element's rollback. When the map fails
904
+ (an element fails in an `atomic` map, the default, or `collect` raises), and when a later step fails or the run is
905
+ undone manually, every element that completed is rolled back, newest-started first, and the run ends
906
+ with the same `Failure` in inline and fan-out mode alike. A map rolls back the way it ran: an inline
907
+ map in process, reading element states 100 at a time; a fan-out map with **one rollback job per
908
+ started element**, `batch_size` per throw, the same back pressure as its forward run. Until the last
909
+ element reports, the run is `rolling_back`, and only then are the steps before the map undone. A
910
+ failing atomic fan-out map lets elements already in flight finish before it reports the failure. Make
911
+ element `undo`s idempotent: one cut off by a killed worker runs again. See
912
+ [Rollback](documentation/data_pipelines.md#rollback).
913
+
914
+ `batch_size` is optional: with `fan_out` alone, no throw enqueues more than 50 element jobs (`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback, and the outcomes are aggregated into a `ResultEnumerator`. Set `batch_size` to change the throw size. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
897
915
 
898
916
  > **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`).
899
917
 
@@ -1306,7 +1324,7 @@ end
1306
1324
 
1307
1325
  ### Error Handling and Compensation
1308
1326
 
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:
1327
+ 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
1328
 
1311
1329
  ```ruby
1312
1330
  class TransactionReactor < RubyReactor::Reactor
@@ -1408,9 +1426,42 @@ result.rollback_failures
1408
1426
  ```
1409
1427
 
1410
1428
  `reason` is `:coordination_unavailable`, `:returned_failure`, or `:raised`; `kind`
1411
- is `:undo` or `:compensate`. See
1429
+ is `:undo` or `:compensate`. An entry from a map element's rollback also carries
1430
+ `map_step:` and `element_index:`, and a map reports an element it could not roll
1431
+ back with `reason: :context_unavailable` (its stored context, or the map's element index, expired) or
1432
+ `:element_in_flight` (a duplicate of it was still running). See
1412
1433
  [Step Rollback](documentation/locks_and_semaphores.md#step-rollback).
1413
1434
 
1435
+ Every error after a completed step rolls back, not only a step body's failure.
1436
+ An `argument` source, `transform` or result path that raises fails that step
1437
+ with `RubyReactor::Error::ArgumentResolutionError`. The step is not compensated
1438
+ (its body never started) nor retried; completed steps are undone. The Failure names the
1439
+ step, and `exception_class` reports the original error's class:
1440
+
1441
+ ```ruby
1442
+ result.step_name # => :charge
1443
+ result.exception_class # => "ArgumentError"
1444
+ ```
1445
+
1446
+ That holds for every exception, not only a `StandardError`: a step class that
1447
+ never implemented `run` (`NotImplementedError`) or a custom `Exception` subclass
1448
+ fails its step and rolls back the same way, and `exception_class` names it.
1449
+
1450
+ Only an interruption (a signal such as `Interrupt`, `SystemExit`, `NoMemoryError`,
1451
+ or an enclosing `Timeout.timeout`) reaches the caller unchanged and runs no
1452
+ rollback code. A run in the caller's process is stored with status `aborted`,
1453
+ the steps not yet undone still outstanding; `MyReactor.undo(id)` rolls them
1454
+ back. A run in a worker is redelivered instead.
1455
+
1456
+ `MyReactor.undo(id)` holds the run's context lock while it undoes, and raises
1457
+ `RubyReactor::Lock::AcquisitionError` if a live run or rollback holds it (retry
1458
+ later). When the undo reaches a completed fan-out map, it undoes the steps after
1459
+ the map, dispatches the map's element rollbacks and returns with the run
1460
+ `rolling_back`; a worker finishes the rest and marks the run `cancelled`. While a
1461
+ run is `rolling_back`, both `undo` and `cancel` raise
1462
+ `RubyReactor::Error::ValidationError`: cancelling would mark it finished and
1463
+ strand the steps before the map.
1464
+
1414
1465
  ### Using Pre-defined Schemas
1415
1466
 
1416
1467
  You can use existing dry-validation schemas:
@@ -1482,7 +1533,7 @@ Explore the ways to move work off the calling process: background execution (`ba
1482
1533
  Discover how to build complex, modular workflows by composing reactors within other reactors. This guide covers inline composition, class-based composition, and how to manage dependencies between composed workflows.
1483
1534
 
1484
1535
  ### [Data Pipelines](documentation/data_pipelines.md)
1485
- Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
1536
+ Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like atomic maps vs. partial result collection.
1486
1537
 
1487
1538
  ### [Retry Configuration](documentation/retry_configuration.md)
1488
1539
  Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to declare retries on a step class or inline step, and how background retries work without blocking workers.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ # Rolls back one element of a fan-out map (009 R-07).
9
+ class MapElementRollbackWorker < ::ActiveJob::Base
10
+ extend Compat
11
+
12
+ queue_as { RubyReactor.configuration.queue_name }
13
+
14
+ def perform(arguments)
15
+ RubyReactor::Map::ElementRollback.perform(arguments)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -56,7 +56,7 @@ module RubyReactor
56
56
  # rubocop:disable Metrics/ParameterLists
57
57
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
58
58
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
59
- step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
59
+ step_name:, batch_size: nil, serialized_context: nil, atomic: nil)
60
60
  job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
61
61
  {
62
62
  "map_id" => map_id,
@@ -70,7 +70,7 @@ module RubyReactor
70
70
  "step_name" => step_name,
71
71
  "batch_size" => batch_size,
72
72
  "serialized_context" => serialized_context,
73
- "fail_fast" => fail_fast
73
+ "atomic" => atomic
74
74
  }
75
75
  )
76
76
  RubyReactor::DispatchResult.new(job_id: job_id)
@@ -79,7 +79,7 @@ module RubyReactor
79
79
  def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
80
80
  reactor_class_info:, strict_ordering:, parent_context_id:,
81
81
  parent_reactor_class_name:, step_name:, batch_size: nil,
82
- serialized_context: nil, fail_fast: nil)
82
+ serialized_context: nil, atomic: nil)
83
83
  job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
84
84
  delay,
85
85
  {
@@ -94,7 +94,7 @@ module RubyReactor
94
94
  "step_name" => step_name,
95
95
  "batch_size" => batch_size,
96
96
  "serialized_context" => serialized_context,
97
- "fail_fast" => fail_fast
97
+ "atomic" => atomic
98
98
  }
99
99
  )
100
100
  # Return an DispatchResult so RetryManager#handle_async_retry recognises the
@@ -103,6 +103,34 @@ module RubyReactor
103
103
  end
104
104
  # rubocop:enable Metrics/ParameterLists
105
105
 
106
+ # One element's rollback in a fan-out map's distributed rollback (009
107
+ # R-07); the `_in` form is its requeue on a contended element lock.
108
+ def self.perform_map_element_rollback_async(**args)
109
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementRollbackWorker.perform_async(map_element_rollback_payload(**args))
110
+ RubyReactor::DispatchResult.new(job_id: job_id)
111
+ end
112
+
113
+ def self.perform_map_element_rollback_in(delay, **args)
114
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementRollbackWorker.perform_in(
115
+ delay, map_element_rollback_payload(**args)
116
+ )
117
+ RubyReactor::DispatchResult.new(job_id: job_id)
118
+ end
119
+
120
+ # rubocop:disable Metrics/ParameterLists
121
+ def self.map_element_rollback_payload(map_id:, position:, element_context_id:, reactor_class_info:,
122
+ parent_reactor_class_name:, step_name:, batch_size:, owner_context_id:,
123
+ owner_reactor_class_name:, attempt: 0)
124
+ {
125
+ "map_id" => map_id, "position" => position, "element_context_id" => element_context_id,
126
+ "reactor_class_info" => reactor_class_info, "parent_reactor_class_name" => parent_reactor_class_name,
127
+ "step_name" => step_name, "batch_size" => batch_size, "owner_context_id" => owner_context_id,
128
+ "owner_reactor_class_name" => owner_reactor_class_name, "attempt" => attempt
129
+ }
130
+ end
131
+ # rubocop:enable Metrics/ParameterLists
132
+ private_class_method :map_element_rollback_payload
133
+
106
134
  # rubocop:disable Metrics/ParameterLists
107
135
  def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
108
136
  strict_ordering:, timeout:)