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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +254 -0
- data/CLAUDE.md +1 -1
- data/README.md +66 -15
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +33 -0
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
- data/lib/ruby_reactor/executor/result_handler.rb +85 -63
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +61 -49
- data/lib/ruby_reactor/executor.rb +221 -7
- data/lib/ruby_reactor/map/collector.rb +31 -89
- data/lib/ruby_reactor/map/dispatcher.rb +65 -8
- data/lib/ruby_reactor/map/element_executor.rb +21 -11
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -125
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +65 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +59 -24
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +10 -1
- data/lib/ruby_reactor/step/map_step.rb +144 -36
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +18 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +7 -3
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/future_improvements.md +64 -28
- metadata +54 -3
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c59b0af2a9d34b620e3400cff78e0df3b2e580d1138b37ed5aee1b9e5a78f548
|
|
4
|
+
data.tar.gz: 4e3c2b92b6ae58797beae470b60c69015e15b2b8d6855ce710e71a999d0ae62f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3ac367241909b09a5f0451b9278489776e7bedc56b506043c786892b86247a646b297a7c6192be6e5935b5e9a861f36e66e2f370a7c3e0741820cbcaa1ee409
|
|
7
|
+
data.tar.gz: 1fd3ff1f692008747021d11d5356613163a8c7777e33ead22260da4b3959bc5f6ec7fff3cb5928444815298011ff4b0e1e2e10e34c55a9781b98d9ab80893e40
|
data/.specify/feature.json
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
SYNC IMPACT REPORT
|
|
3
3
|
==================
|
|
4
|
-
Version change: 1.
|
|
5
|
-
|
|
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
|
|
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 ✅
|
|
17
|
-
|
|
18
|
-
- .specify/templates/
|
|
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
|
|
24
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
|
25
|
+
- **Compensation**: Automatic rollback of completed steps when any error occurs after them, including a raising argument transform or an exception that is not a `StandardError`.
|
|
26
26
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
27
27
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
28
28
|
- **Distributed Locks, Semaphores, Rate Limits, Periods & Ordered Locks**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), `with_period` to dedup reactors to once per calendar bucket, and `with_ordered_lock` for strict transaction ordering via a monotonically increasing nonce assigned at enqueue. Background jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
|
|
@@ -32,7 +32,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
32
32
|
| Feature | Ruby Reactor | dry-transaction | Trailblazer | Custom Sidekiq Jobs |
|
|
33
33
|
|--------------------------|--------------|-----------------|-------------|---------------------|
|
|
34
34
|
| DAG/Parallel execution | Yes | No | Limited | Manual |
|
|
35
|
-
| Auto compensation/undo | Yes
|
|
35
|
+
| Auto compensation/undo | Yes (steps, compose, map; async units roll back on their own) | No | Manual | Manual |
|
|
36
36
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
37
37
|
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
38
38
|
| Built-in web dashboard | Yes | No | No | No |
|
|
@@ -237,7 +237,7 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
|
|
|
237
237
|
- **`Success(value)`** — step succeeded; `value` flows to dependent steps.
|
|
238
238
|
- **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
|
|
239
239
|
- **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
|
|
240
|
-
- **`Skipped(value)`** — mark this one step skipped
|
|
240
|
+
- **`Skipped(value)`** — mark this one step skipped. The run continues exactly as for `Success` — `value` flows to dependants — and the step is never undone; the execution trace marks it skipped. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
|
|
241
241
|
|
|
242
242
|
One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
|
|
243
243
|
|
|
@@ -544,10 +544,14 @@ time. On success the reader gets the raw value; on failure it gets the
|
|
|
544
544
|
|
|
545
545
|
**Compensation is opt-in.** If a dispatched step fails and nothing reads its
|
|
546
546
|
result, the reactor is not compensated — it was dispatched precisely so the
|
|
547
|
-
reactor would not depend on it.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
547
|
+
reactor would not depend on it. The unit's own `compensate` does run: once, in
|
|
548
|
+
the unit's job, after its final attempt fails, recorded on the unit's record as
|
|
549
|
+
`compensation`. A reader that returns `Failure` compensates itself and undoes the
|
|
550
|
+
reactor's completed steps; the unit is not compensated again. The independence
|
|
551
|
+
cuts both ways: async dispatches never enter the parent's undo stack, so a
|
|
552
|
+
parent rolling back for its own reasons never "undoes" a unit that runs (and may
|
|
553
|
+
still succeed) elsewhere. An inline `undo` on an `async_step` would never run, so
|
|
554
|
+
it raises at class-definition time (a step class's `undo` is warned about).
|
|
551
555
|
|
|
552
556
|
#### `async_reactor`: a whole nested reactor, running independently
|
|
553
557
|
|
|
@@ -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
|
|
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
|
-
|
|
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`.
|
|
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
|
|
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,
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
"
|
|
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:)
|