ruby_reactor 0.8.5 → 0.8.7
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 +82 -0
- data/CLAUDE.md +1 -1
- data/README.md +27 -8
- 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/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
- data/lib/ruby_reactor/executor/result_handler.rb +88 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
- data/lib/ruby_reactor/executor/step_executor.rb +23 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- 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 +113 -24
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +55 -37
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +13 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -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-CQbgHtd0.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 +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- 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/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/future_improvements.md +9 -33
- metadata +29 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# Research: Distributed Map Rollback and Bounded Fan-out
|
|
2
|
+
|
|
3
|
+
**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Date**: 2026-09-30, revised
|
|
4
|
+
the same day after `/speckit-analyze` (R-02, R-04, R-05, R-06, R-07, R-09, R-12, R-15, R-16)
|
|
5
|
+
|
|
6
|
+
Each decision gives the choice, why, and what was rejected. File references are to the code as of
|
|
7
|
+
`21a4c59f` (008 merged).
|
|
8
|
+
|
|
9
|
+
## Current behavior (verified in code)
|
|
10
|
+
|
|
11
|
+
| Question | Answer | Where |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| Does a fan-out map's rollback run inline, loading every element? | Yes. `MapStep#compensate` reads the whole element-context index (`LRANGE 0 -1`), loads **every** row, deserializes all of them, sorts by index, then undoes each one serially in the process that detected the failure. `batch_size` plays no part. | `step/map_step.rb` `#compensate`, `#completed_elements` |
|
|
14
|
+
| Where does a fan-out map's failure get rolled back? | In the **collector** job: `Map::Helpers#resume_parent_execution` runs `handle_step_failure` for the map step there, holding only the `map_collect:` lock, never the parent's `async:` lock. | `map/helpers.rb`, `map/collector.rb` |
|
|
15
|
+
| What does `fan_out` without `batch_size` do? | Batch size falls back to `source.size`: one batch, every element enqueued at once. | `MapStep#dispatch_async_map`, `Dispatcher.dispatch_batch` |
|
|
16
|
+
| What is the forward back pressure exactly? | `batch_size` jobs are enqueued per throw. When the element at position `k*B - 1` finishes, the next `B` are enqueued. The trigger depends only on position, so other elements of earlier batches may still be running. A slow element never holds back later batches, and outstanding jobs are not bounded, only each throw is. | `ElementExecutor.trigger_next_batch_if_needed` |
|
|
17
|
+
| Why does a fan-out map inside a composed child hang? | `prepare_async_execution` stores the child under its own id. The collector loads that blob, whose `root_context` is nil, and resumes the child as a standalone run. Nothing ever resumes the root. | `map/helpers.rb` `#resume_parked_aware` (ponytail note) |
|
|
18
|
+
| What does `fail_fast` mean? | On (the default): the first element failure fails the map, stops dispatch, and rolls back every completed element. Off: the map completes with per-element `Result`s, failed elements roll themselves back, succeeded ones are kept. That is atomic versus partial. | `MapStep#execute_inline_map`, `ElementExecutor.handle_result`, `Dispatcher.dispatch_batch` |
|
|
19
|
+
| Does the map sweeper keep `batch_size` / `fail_fast` when it re-dispatches? | No. `requeue_index` reads `map_meta["batch_size"]` and `map_meta["fail_fast"]`, but `initialize_map_operation` never stores them. A re-dispatched element therefore loses `atomic` and stops triggering batches. This is a latent bug, fixed by R-10. | `storage/redis_adapter.rb#initialize_map_operation`, `Dispatcher.requeue_index` |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## R-01: Rollback mirrors execution mode
|
|
24
|
+
|
|
25
|
+
**Decision**: A map rolls back where its elements ran:
|
|
26
|
+
|
|
27
|
+
- A fan-out map dispatches one rollback job per completed element (R-02 to R-09).
|
|
28
|
+
- An inline map rolls back in the executing process, but reads element states in chunks of
|
|
29
|
+
`Map::ROLLBACK_CHUNK` (100) from the tail of the element index, never all at once.
|
|
30
|
+
|
|
31
|
+
**Rationale**:
|
|
32
|
+
|
|
33
|
+
- This is the user's rule ("rollback each map reactor the same way we execute them").
|
|
34
|
+
- An inline map is already bounded by one process's execution, so its rollback is not the scaling
|
|
35
|
+
problem. What makes it grow is the all-at-once read, and chunking removes that.
|
|
36
|
+
|
|
37
|
+
**Alternatives rejected**:
|
|
38
|
+
|
|
39
|
+
- Distribute inline maps' rollback too. That adds a hand-off to runs that never had one (a
|
|
40
|
+
synchronous `Reactor.run` would return a `DispatchResult` from a failure).
|
|
41
|
+
- Leave inline rollback unchanged. That keeps the O(n) load that FR-012 removes.
|
|
42
|
+
|
|
43
|
+
## R-02: Back pressure is the forward batch mechanism, reused
|
|
44
|
+
|
|
45
|
+
**Decision**: Rollback jobs are dispatched exactly as forward element jobs are:
|
|
46
|
+
|
|
47
|
+
- Claim `B` positions with one atomic offset increment and enqueue them.
|
|
48
|
+
- When the job at position `k*B - 1` settles, the next batch is claimed.
|
|
49
|
+
- `B` is the map's effective batch size (R-10).
|
|
50
|
+
|
|
51
|
+
The spec bound (FR-002, FR-018, SC-001, SC-004) is "no throw enqueues more than `B`; the next
|
|
52
|
+
throw fires when the previous throw's last position reports". There is **no** bound on outstanding
|
|
53
|
+
jobs: the trigger fires on position alone, so a lagging element in batch `k` does not stop batches
|
|
54
|
+
`k+1`, `k+2` and so on. An earlier draft claimed "at most two batches outstanding". That only holds
|
|
55
|
+
when jobs finish in queue order, and it was dropped after `/speckit-analyze` (A1). The tests assert
|
|
56
|
+
the per-throw burst (`QueueProbe` `max_burst`), never queue depth.
|
|
57
|
+
|
|
58
|
+
**Rationale**:
|
|
59
|
+
|
|
60
|
+
- "Same way we execute them."
|
|
61
|
+
- One mechanism, one bound to document.
|
|
62
|
+
- The user's framing is per throw ("max amount of elements to fan out in a single throw").
|
|
63
|
+
|
|
64
|
+
**Alternatives rejected**:
|
|
65
|
+
|
|
66
|
+
- **Sliding window** (each settled job enqueues exactly one more; a strict `≤ B` outstanding). For
|
|
67
|
+
a database source the forward path would fetch one record per job (`offset(i).limit(1)`)
|
|
68
|
+
instead of one query per batch. It would also make forward and rollback diverge unless both
|
|
69
|
+
changed, and changing forward is out of scope.
|
|
70
|
+
- **Per-batch settle counter** (next batch only when the whole batch settled). This would bound
|
|
71
|
+
outstanding jobs at `B`, but it costs one more counter per map, stalls throughput on one slow
|
|
72
|
+
element, and diverges from forward. It can be revisited if unbounded lag ever matters in
|
|
73
|
+
practice.
|
|
74
|
+
|
|
75
|
+
## R-03: Map completion resumes the owner run; `MapStep#run` adopts the settled outcome
|
|
76
|
+
|
|
77
|
+
**Decision**: The collector stops writing any context:
|
|
78
|
+
|
|
79
|
+
- **Collector**: once every index has a result slot, it claims `map:<id>:owner_signalled`
|
|
80
|
+
(`SET NX`) and enqueues the **owner's** `Worker`. The owner is the top-level run,
|
|
81
|
+
`context.root_context || context`, whose id and class are now stored in the map metadata
|
|
82
|
+
(`owner_context_id`, `owner_reactor_class_name`).
|
|
83
|
+
- **Owner Worker**: resumes as it does for any hand-off. The map step is not in
|
|
84
|
+
`intermediate_results`, so `MapStep#run` runs again, at any composition depth
|
|
85
|
+
(root → `ComposeStep#run` → child `resume_execution` → `MapStep#run`).
|
|
86
|
+
- **Re-entry**: `context.map_operations[step]` is set, so `MapStep#run` adopts instead of
|
|
87
|
+
dispatching:
|
|
88
|
+
- settled with an atomic failure: return the failing element's `Failure`, carrying its
|
|
89
|
+
`rollback_failures` (moved from `Collector.handle_failure`);
|
|
90
|
+
- settled otherwise: return `Success(collect(ResultEnumerator))` (moved from
|
|
91
|
+
`Collector.apply_collect_block`), and record `started` on the map reference (moved from
|
|
92
|
+
`Helpers#record_elements_started`);
|
|
93
|
+
- not settled (an early or duplicate resume, or a sweeper re-enqueue): return the
|
|
94
|
+
`DispatchResult` again, without dispatching anything.
|
|
95
|
+
- **Failure path**: an adopted `Failure` goes through the executor's normal failure handling.
|
|
96
|
+
`handle_step_failure` compensates the map (R-04 distributes it) and the run rolls back as any
|
|
97
|
+
step failure does.
|
|
98
|
+
- **Deleted**: `Map::Helpers#resume_parent_execution`, `#resume_parked_aware`, `#store_parent`,
|
|
99
|
+
`#record_elements_started`, and the `inline_async_execution = true` workaround.
|
|
100
|
+
- **`prepare_async_execution`**: stores the owner tree (`root_context || context`) under the
|
|
101
|
+
owner id before dispatch, as `handle_background_handoff` already does, so a fast resume finds a
|
|
102
|
+
consistent root. It also keeps storing the child snapshot, which the dispatcher reads to resolve
|
|
103
|
+
the source.
|
|
104
|
+
|
|
105
|
+
**Rationale**:
|
|
106
|
+
|
|
107
|
+
- Fixes "Fan-out map inside a composed child" by the direction `future_improvements.md` already
|
|
108
|
+
records.
|
|
109
|
+
- The same change removes the collector's failure-branch write, which that file lists as a writer
|
|
110
|
+
outside a controlled execution.
|
|
111
|
+
- It gives forward and rollback one mechanism: settle, signal the owner once, and the construct
|
|
112
|
+
adopts on re-entry.
|
|
113
|
+
- Adoption is idempotent, so a spurious resume (the general sweeper re-enqueues any `running`
|
|
114
|
+
context with no `async:` lock) no longer risks dispatching a map twice.
|
|
115
|
+
|
|
116
|
+
**Alternatives rejected**:
|
|
117
|
+
|
|
118
|
+
- **Collector writes the root blob.** This breaks the single-writer rule: the collector never holds
|
|
119
|
+
the root's `async:` lock.
|
|
120
|
+
- **Collector resumes the root inline.** Same lock problem, and the collector keeps a second copy
|
|
121
|
+
of the executor's failure path.
|
|
122
|
+
- **Keep the root-level path, special-case only composed children.** That leaves two completion
|
|
123
|
+
paths and the collector's failure-branch write.
|
|
124
|
+
|
|
125
|
+
**Cost**: One extra job hop (collector → owner Worker) on every fan-out map completion. The owner
|
|
126
|
+
Worker contends on the `async:` lock if the dispatching worker still holds it, and snoozes
|
|
127
|
+
uncapped until it is released (existing behavior for that lock).
|
|
128
|
+
|
|
129
|
+
**Implementation notes** (found while implementing):
|
|
130
|
+
|
|
131
|
+
- `ComposeStep#compensate` / `#undo` now link the stored child to its parent and root, as
|
|
132
|
+
`#run` does. Unlinked, a fan-out map inside the child recorded the child as its owner during
|
|
133
|
+
rollback, and the rollback resumed the child's standalone row instead of the root.
|
|
134
|
+
- The `DispatchResult` a fan-out map returns carries the owner's `execution_id`, not the composed
|
|
135
|
+
child's: it is what the caller of `Reactor.run` holds and looks the run up by.
|
|
136
|
+
- **An interrupt inside a composed child is unsupported on main, fan-out or not**: `ComposeStep`
|
|
137
|
+
fails the step (`NoMethodError` on `InterruptResult#success?`). US2-AS2 (T052(b)) is out of reach
|
|
138
|
+
without that separate capability; its example is `pending` with the reason.
|
|
139
|
+
|
|
140
|
+
## R-04: Rollback can hand off and resume (`Error::RollbackHandedOff`)
|
|
141
|
+
|
|
142
|
+
**Decision**: A construct whose rollback is distributed (`MapStep#compensate` / `#undo` on a
|
|
143
|
+
fan-out map) starts the map rollback (R-06), then raises `Error::RollbackHandedOff`.
|
|
144
|
+
|
|
145
|
+
**How the signal unwinds**:
|
|
146
|
+
|
|
147
|
+
- `CompensationManager#rollback_completed_steps` pops an entry only after its undo returns, so the
|
|
148
|
+
map entry stays on the undo stack. Entries already undone are gone.
|
|
149
|
+
- A **composed child's** executor rescues the signal, records its own `rollback` state (below) on
|
|
150
|
+
the child context, and re-raises. The root embeds the child, so the root's save persists it.
|
|
151
|
+
- **`Executor#undo_all`** does the same at its own level. This is the path manual undo and
|
|
152
|
+
`ComposeStep#compensate` take into a child: they call the child executor's `undo_all`, never its
|
|
153
|
+
`execute` / `resume_execution`.
|
|
154
|
+
- On a hand-off it writes `@context.rollback["failures"]` from its compensation manager and
|
|
155
|
+
re-raises.
|
|
156
|
+
- On entry, when `@context.rollback` is present, it restores those failures first, and clears
|
|
157
|
+
them when it finishes.
|
|
158
|
+
- Without this, a child's rollback failures collected before the hand-off would be lost, because
|
|
159
|
+
the child executor is rebuilt on resume (`/speckit-analyze` G2).
|
|
160
|
+
- The **top-level** executor (`execute` / `resume_execution` / `resume_rollback`) rescues the
|
|
161
|
+
signal before `rescue Error::Rescuable` and `rescue Exception`. It records the run's `rollback`
|
|
162
|
+
state, sets status `rolling_back`, and **saves inside the rescue body, while it still holds the
|
|
163
|
+
context lock** (R-13). It returns a `DispatchResult` (`job_id: "map_rollback:<map_id>"`).
|
|
164
|
+
- **`Reactor#undo`** (manual) rescues it the same way (R-12).
|
|
165
|
+
|
|
166
|
+
**`Error::Rescuable` does not match `RollbackHandedOff`**: it is a control signal, not a failure.
|
|
167
|
+
This lets it pass through the `rescue Error::Rescuable` sites between the raise and the handler:
|
|
168
|
+
|
|
169
|
+
- `CompensationManager#compensate_step_body` and `#undo_step` (they wrap `call_undo`, which is
|
|
170
|
+
where `MapStep#undo` runs);
|
|
171
|
+
- `StepExecutor#safe_execute_step_sync` (a child's rollback inside `ComposeStep#run`);
|
|
172
|
+
- `Executor#aborting_on_interruption`, which must not mark the run `aborted` on it.
|
|
173
|
+
|
|
174
|
+
**`rollback` state on the context** (data-model.md): `trigger` (`failure` | `undo`), `step` (the
|
|
175
|
+
failing step, or nil), `compensated`, `failure` (the triggering `Failure`, serialized), and
|
|
176
|
+
`failures` (rollback failures collected so far).
|
|
177
|
+
|
|
178
|
+
**Resuming**: `Worker#perform` sees `rolling_back` and calls `Executor#resume_rollback`, which runs
|
|
179
|
+
under the context lock:
|
|
180
|
+
|
|
181
|
+
1. Restores `rollback_failures` from the saved state.
|
|
182
|
+
2. If `compensated` is false, compensates the failing step. Only constructs can hand off, and they
|
|
183
|
+
read their state from the context, so arguments are `{}`. A map re-enters and adopts; a compose
|
|
184
|
+
continues its child's rollback.
|
|
185
|
+
3. Runs `rollback_completed_steps`.
|
|
186
|
+
4. Finalizes:
|
|
187
|
+
- `failure` trigger: rebuild the `StepFailureError` from the saved failure and call
|
|
188
|
+
`result_handler.handle_execution_error`. This is the code
|
|
189
|
+
`Helpers#resume_parent_execution` has today, moved into the executor, so the final `Failure`,
|
|
190
|
+
the `CompensationError` rule and the `rollback_failures` shape are unchanged.
|
|
191
|
+
- `undo` trigger: status `cancelled`, reason "Undo triggered".
|
|
192
|
+
- Either way, `rollback` is cleared.
|
|
193
|
+
|
|
194
|
+
**Rationale**:
|
|
195
|
+
|
|
196
|
+
- The undo stack already makes rollback resumable: entries pop only when done (008 R-16). What was
|
|
197
|
+
missing is a signal that stops the loop cleanly, and a place to store the trigger.
|
|
198
|
+
- Raising matches how parks unwind (`ExecutionParked`) through the same stack depth.
|
|
199
|
+
|
|
200
|
+
**Alternatives rejected**:
|
|
201
|
+
|
|
202
|
+
- **Return a sentinel result from `#undo`.** It would have to be plumbed through
|
|
203
|
+
`rollback_completed_steps`, `handle_step_failure`, three `ResultHandler` branches,
|
|
204
|
+
`ComposeStep#compensate` and `Reactor#undo`, and every intermediate caller would have to learn
|
|
205
|
+
it.
|
|
206
|
+
- **Wait in place** (the coordinating job polls until element rollbacks finish). This holds a worker
|
|
207
|
+
for the whole rollback, hits job timeouts and shutdown (`Sidekiq::Shutdown` is an interruption,
|
|
208
|
+
so the run would be marked `aborted`), and still needs resumption after a crash.
|
|
209
|
+
- **Subclass `ExecutionParked` and snooze-poll.** Every poll reloads the parent blob. The trigger
|
|
210
|
+
plus sweeper is how the forward path already works.
|
|
211
|
+
|
|
212
|
+
**Implementation notes** (found while implementing):
|
|
213
|
+
|
|
214
|
+
- **The final Failure is captured at the hand-off, not rebuilt at resume.** The `ResultHandler`
|
|
215
|
+
the signal passes through sets `RollbackHandedOff#failure` to the exact Failure the run will end
|
|
216
|
+
with (`failure_for` of the `StepFailureError` it would raise, minus rollback failures), and the
|
|
217
|
+
executor saves it in `rollback["failure"]`. `resume_rollback` adds the rollback failures. Rebuilding
|
|
218
|
+
a `StepFailureError` from the saved Failure loses `step_arguments`, `validation_errors` and the
|
|
219
|
+
code location; capturing keeps every field, so I-7 holds exactly.
|
|
220
|
+
- `rollback["compensation_error"]`: the failing step's compensate returned a Failure before the
|
|
221
|
+
rollback handed off, so the resume ends with that `CompensationError`, as inline.
|
|
222
|
+
- **A third trigger, `step`.** When a composed child hands off from inside `ComposeStep#run` (the
|
|
223
|
+
child's own step failed), the root has no rollback of its own yet. The root records
|
|
224
|
+
`{trigger: "step"}` and, on resume, runs forward again: `ComposeStep#run` re-enters the child,
|
|
225
|
+
which is `rolling_back` and finishes its own rollback (`resume_rollback` at the child level), and
|
|
226
|
+
the child's Failure then rolls the root back exactly as the inline path does. T057's plan
|
|
227
|
+
(compensate the compose step with `compensated: false`) would report the child's rollback
|
|
228
|
+
failures as a `CompensationError`, which diverges from the inline Failure (I-7).
|
|
229
|
+
- `Executor#resume_execution` delegates to `resume_rollback` for a `rolling_back` context, which
|
|
230
|
+
is how a composed child re-enters; `Worker` routes a `rolling_back` row there directly.
|
|
231
|
+
|
|
232
|
+
## R-05: Exactly-once owner resume; recovery
|
|
233
|
+
|
|
234
|
+
**Decision**:
|
|
235
|
+
|
|
236
|
+
- **Owner resume**: a two-flag handshake, so the owner is enqueued only after its hand-off is
|
|
237
|
+
saved, exactly once, whichever side finishes last.
|
|
238
|
+
- **Hand-off side**: the top-level handler (`Executor` rescue, or `Reactor#undo`) saves the
|
|
239
|
+
context, then sets `map:<id>:rollback:handed_off` (`SET`). It then checks
|
|
240
|
+
`count_map_rollback_outcomes == total`. If the map has settled and it claims
|
|
241
|
+
`map:<id>:rollback:signalled` (`SET NX`), it enqueues the owner's `Worker`.
|
|
242
|
+
- **Job side**: the element rollback job that stores an outcome checks settled. If the map has
|
|
243
|
+
settled **and** `handed_off` is set **and** it claims `signalled`, it enqueues the owner's
|
|
244
|
+
`Worker`.
|
|
245
|
+
- **Why it is exactly once**: whichever side observes both conditions second enqueues, and the
|
|
246
|
+
claim makes it once.
|
|
247
|
+
- **Why the Worker never sees a stale blob**: it is enqueued only after `handed_off`, which is set
|
|
248
|
+
after the save. The owner's `async:` lock still serializes it against the handler's release.
|
|
249
|
+
- **Inline test mode** (`Sidekiq::Testing.inline!`): element jobs run inside dispatch, so every
|
|
250
|
+
outcome is stored before `MapStep#undo` returns and before any `handed_off` flag exists. No job
|
|
251
|
+
enqueues the owner. `#undo` checks settled right after dispatching and aggregates synchronously;
|
|
252
|
+
there is no hand-off. This decides `/speckit-analyze` U3 in the design instead of leaving it to
|
|
253
|
+
implementation.
|
|
254
|
+
- **Recovery**:
|
|
255
|
+
- **General `RubyReactor::Sweeper`**: treats `rolling_back` like `running`. With no `async:` lock
|
|
256
|
+
held, it re-enqueues the owner. On re-entry an unsettled map rollback raises the hand-off again,
|
|
257
|
+
so this is harmless.
|
|
258
|
+
- **`Map::Sweeper`**: gains a rollback pass. For each map with rollback metadata, it
|
|
259
|
+
re-dispatches positions below the claimed offset that have no outcome and no live element lock.
|
|
260
|
+
It also claims positions never dispatched when their batch trigger was lost.
|
|
261
|
+
|
|
262
|
+
**Rationale**: This mirrors the forward map's recovery split, where elements are recovered by
|
|
263
|
+
`Map::Sweeper` and the owner by `RubyReactor::Sweeper`. The owner-side check makes duplicate
|
|
264
|
+
triggers harmless.
|
|
265
|
+
|
|
266
|
+
## R-06: The rollback work list is the element-context index, read by position
|
|
267
|
+
|
|
268
|
+
**Decision**:
|
|
269
|
+
|
|
270
|
+
- **Work items**: positions in the existing element-context list (`map_element_contexts_key`),
|
|
271
|
+
counted from the tail. `total = LLEN` is fixed when the rollback starts
|
|
272
|
+
(`HSETNX rollback:metadata`, so a second start finds the first: FR-027).
|
|
273
|
+
- **Coverage**: the list holds every element that **started**, because `ElementExecutor` registers
|
|
274
|
+
an element before running it. So failed and halted elements get a job too, and it reports
|
|
275
|
+
`not_needed` without undoing anything. Skipped elements never registered and get no job. Spec
|
|
276
|
+
US1-AS2, FR-007 and the edge cases say this since `/speckit-analyze` I1.
|
|
277
|
+
- **Rejected alternative**: a separate forward list of completed ids. It would miss a context
|
|
278
|
+
that completed but crashed before pushing its id, and a sweeper re-dispatch would then leave
|
|
279
|
+
its side effects un-undone. A no-op job per failed element is cheap by comparison.
|
|
280
|
+
- **Dispatch**: claims positions with `INCRBY rollback:offset B` and reads them with one
|
|
281
|
+
`LRANGE` per batch.
|
|
282
|
+
- **Outcomes**: stored in `rollback:results` (a hash, position → outcome), so they are
|
|
283
|
+
idempotent.
|
|
284
|
+
- **Forward registration is deduplicated at the source**: `ElementExecutor` registers the context
|
|
285
|
+
id only for a fresh context, not on a parked or retried re-entry (`serialized_context` present),
|
|
286
|
+
which re-registers the same id today.
|
|
287
|
+
- **Leftover duplicates** (payloads from before the upgrade, or a sweeper re-dispatch that made a
|
|
288
|
+
second context for the same index) are safe:
|
|
289
|
+
- each job takes the element's `map_element:<map>:<index>` lock. If it is contended, the job
|
|
290
|
+
requeues itself instead of reporting (R-07), so two jobs for the same index (a duplicate id,
|
|
291
|
+
or a superseded context) serialize rather than produce a false `element_in_flight`;
|
|
292
|
+
- an element whose undo stack is already empty reports `undone` with nothing to do.
|
|
293
|
+
|
|
294
|
+
**Rationale**: No new forward write. `LRANGE` by position gives bounded reads and atomic claims.
|
|
295
|
+
Superseded contexts from a sweeper re-dispatch stay in the list and are rolled back if they
|
|
296
|
+
completed, as they are today.
|
|
297
|
+
|
|
298
|
+
**Alternatives rejected**:
|
|
299
|
+
|
|
300
|
+
- **Index → id hash written by the forward path.** It loses superseded ids, so a context that
|
|
301
|
+
completed twice (a lost result followed by a re-dispatch) would be rolled back once.
|
|
302
|
+
- **Scanning the forward results hash.** It holds result values, not context ids, and can be large.
|
|
303
|
+
|
|
304
|
+
**Implementation note**: tail positions are anchored at the `total` the rollback started with
|
|
305
|
+
(head index `total - 1 - position`), so an id a late duplicate appends after the start never
|
|
306
|
+
shifts them.
|
|
307
|
+
|
|
308
|
+
## R-07: Element rollback checkpoints after every undone entry
|
|
309
|
+
|
|
310
|
+
**Decision**:
|
|
311
|
+
|
|
312
|
+
- The element rollback job (`Map::ElementRollback`) takes the element's `map_element:` lock
|
|
313
|
+
(`wait: ELEMENT_LOCK_WAIT`).
|
|
314
|
+
- **If the lock is held**, the job does not report yet. It requeues itself with the same
|
|
315
|
+
arguments through `perform_map_element_rollback_in(Worker.snooze_delay(config, nil),
|
|
316
|
+
attempt: attempt + 1, ...)`. The position stays outstanding, so back pressure and settle
|
|
317
|
+
counting are unaffected.
|
|
318
|
+
- **Only after `lock_snooze_max_attempts`** does it report `element_in_flight`. This is today's
|
|
319
|
+
outcome for a genuinely live forward duplicate.
|
|
320
|
+
- **Why**: the lock is shared by every context of one index. Without the requeue, a concurrent
|
|
321
|
+
duplicate id or superseded context in the same batch would report a false rollback failure,
|
|
322
|
+
and a superseded completed context would never be undone (`/speckit-analyze` U2).
|
|
323
|
+
- **Inline path**: in-process, one element at a time, so it never contends with itself. It keeps
|
|
324
|
+
today's single wait-then-report.
|
|
325
|
+
- It then runs `rollback_completed_steps` with a per-entry callback that saves the element context
|
|
326
|
+
after each pop. A redelivery resumes after the last saved entry.
|
|
327
|
+
- The same class serves the inline path (R-01), called in-process.
|
|
328
|
+
|
|
329
|
+
**Rationale**: Spec FR-006 as revised. Exactly-once per step would need idempotent user undos or
|
|
330
|
+
two-phase records. At-least-once for the one entry in flight is the standard background-job
|
|
331
|
+
guarantee, and it is documented.
|
|
332
|
+
|
|
333
|
+
**Implementation notes**: only a job's first attempt waits `ELEMENT_LOCK_WAIT` for the lock (the
|
|
334
|
+
settle gap); a requeued attempt tries once, since its requeue delay is the spacing, so a held lock
|
|
335
|
+
never blocks a worker for `2 s × lock_snooze_max_attempts`. An element saves after each undone entry
|
|
336
|
+
with `Executor#checkpoint!`, which stores the row without publishing a completion signal per entry.
|
|
337
|
+
|
|
338
|
+
## R-08: Dispatch order
|
|
339
|
+
|
|
340
|
+
**Decision**: Positions are counted from the tail of the element index, so the newest-started
|
|
341
|
+
element goes first:
|
|
342
|
+
|
|
343
|
+
- For an inline map this is exactly highest index first, today's order.
|
|
344
|
+
- For a fan-out map it follows start order, which tracks index order batch by batch.
|
|
345
|
+
|
|
346
|
+
Completion order across elements is not guaranteed.
|
|
347
|
+
|
|
348
|
+
**Rationale**: Elements are independent and ran in parallel forward. Strict order across parallel
|
|
349
|
+
jobs would need serialization, which defeats FR-001.
|
|
350
|
+
|
|
351
|
+
## R-09: Reporting unavailable elements at bounded memory
|
|
352
|
+
|
|
353
|
+
**Decision**:
|
|
354
|
+
|
|
355
|
+
- An element rollback job whose context row is gone reports `context_unavailable`. Its index is
|
|
356
|
+
unknown, because the row held it.
|
|
357
|
+
- Every job adds its element index to `rollback:indexes` (a set).
|
|
358
|
+
- At aggregation, the owner checks `0...started` (from the map reference) against that set in
|
|
359
|
+
pipelined `SISMEMBER` chunks of 1,000. It reports each index that was started but never seen,
|
|
360
|
+
which covers an expired index list, as 008's `report_unavailable` does.
|
|
361
|
+
- **Each expired element is reported once, as in 008**:
|
|
362
|
+
- **`started` known**: the named, started-but-unseen entries are the report, and nil-index
|
|
363
|
+
`context_unavailable` outcomes are **not** added. They describe the same elements.
|
|
364
|
+
- **`started` unknown** (a failed fan-out map that skipped indexes): one unnamed entry per nil-index
|
|
365
|
+
outcome.
|
|
366
|
+
|
|
367
|
+
This matches `report_unavailable` (`map_step.rb`), which reports one form or the other, never
|
|
368
|
+
both, and keeps the inline/fan-out oracle (I-7) exact (`/speckit-analyze` I2).
|
|
369
|
+
- Aggregation reads outcomes with `HSCAN` in chunks and keeps only failures.
|
|
370
|
+
- **Shape**: outcomes are stored as JSON, so aggregation re-symbolizes each failure entry: symbol
|
|
371
|
+
keys, and `step`, `kind`, `reason` and `map_step` values symbolized back. Everything else is
|
|
372
|
+
unchanged: `key`, `message`, and `element_index` (an Integer or nil). The final `rollback_failures`
|
|
373
|
+
are then equal to the inline path's (FR-005, `/speckit-analyze` U1).
|
|
374
|
+
|
|
375
|
+
**Rationale**: Parity with 008 (FR-007) without loading every index at once. Pipelined `SISMEMBER`
|
|
376
|
+
works on any Redis. `SMISMEMBER` would need 6.2, and the gem does not manage Redis.
|
|
377
|
+
|
|
378
|
+
**Implementation notes**: an outcome's `failures` are stored serialized with
|
|
379
|
+
`ContextSerializer.serialize_value`, so symbols round-trip exactly and no manual re-symbolizing is
|
|
380
|
+
needed. Outcomes are first-writer-wins (`HSETNX`): a duplicate delivery, which finds the element
|
|
381
|
+
already undone, must not replace a `failed` outcome with its own `undone`, and only the job that
|
|
382
|
+
stored the outcome claims the next throw.
|
|
383
|
+
|
|
384
|
+
## R-10: Default batch size, stored with the map
|
|
385
|
+
|
|
386
|
+
**Decision**:
|
|
387
|
+
|
|
388
|
+
- **Constant**: `RubyReactor::Map::DEFAULT_BATCH_SIZE = 50`. There is no configuration setting;
|
|
389
|
+
the per-map `batch_size` is the override (spec Assumptions).
|
|
390
|
+
- **Applied** in `MapStep#dispatch_async_map` and the `Dispatcher` fallback.
|
|
391
|
+
- **Stored in map metadata**: the effective `batch_size` and `atomic`, which fixes the
|
|
392
|
+
sweeper re-dispatch losing both. The rollback reads `batch_size` from the metadata.
|
|
393
|
+
|
|
394
|
+
**Rationale**: The forward and rollback paths and the sweeper need one source of truth. A constant
|
|
395
|
+
is enough until someone needs a different default everywhere (YAGNI).
|
|
396
|
+
|
|
397
|
+
## R-11: `atomic` replaces `fail_fast`
|
|
398
|
+
|
|
399
|
+
**Decision**:
|
|
400
|
+
|
|
401
|
+
- **DSL**: `MapBuilder#atomic(enabled = true)`, on by default.
|
|
402
|
+
- **`fail_fast(enabled = true)`** sets the same flag and prints one definition-time deprecation
|
|
403
|
+
warning per call site, through the existing `warn_definition` helper. That helper is extracted
|
|
404
|
+
from `StepBuilder` into `Dsl::DefinitionWarnings`, a module both builders include.
|
|
405
|
+
- **Both declared** on one map: `Error::ValidationError` at definition.
|
|
406
|
+
- **Internals** are renamed (`MapStep` input, metadata, job payload key) to `atomic`.
|
|
407
|
+
- **Legacy payloads**: job payloads enqueued before the upgrade carry `fail_fast`. One
|
|
408
|
+
normalization in `Map::Helpers.normalize_arguments`, used by `ElementExecutor`, `Dispatcher`,
|
|
409
|
+
`Collector` and `RetryManager`, maps `fail_fast` to `atomic` (FR-023). It is removed at the
|
|
410
|
+
next MAJOR.
|
|
411
|
+
|
|
412
|
+
**Rationale**:
|
|
413
|
+
|
|
414
|
+
- MINOR: nothing that works today breaks.
|
|
415
|
+
- One normalization point instead of reading both keys everywhere.
|
|
416
|
+
|
|
417
|
+
**Alternatives rejected**:
|
|
418
|
+
|
|
419
|
+
- **Remove `fail_fast` and raise `DeprecatedDslError`**, like map `async`. That is breaking, and
|
|
420
|
+
the semantics did not change, which is what justified the hard removal of `async`.
|
|
421
|
+
- **Keep `fail_fast` internally.** Future readers would see two names for one flag.
|
|
422
|
+
- **`all_or_none` / `all_or_nothing`**: long, and the opt-out (`all_or_none false`) reads awkwardly.
|
|
423
|
+
`atomic` is one word and is the usual saga/transaction term.
|
|
424
|
+
- **`allow_partial`** (inverted, off by default): reads well, but the `fail_fast` alias and the
|
|
425
|
+
legacy payload mapping would have to negate the value, which is easy to get wrong.
|
|
426
|
+
|
|
427
|
+
## R-12: Manual undo holds the run's context lock
|
|
428
|
+
|
|
429
|
+
**Decision**: `Reactor.undo(id)` / `#undo` acquire `async:<root id>` (`wait:` `undo_lock_wait`
|
|
430
|
+
constant, 5s) around the whole undo.
|
|
431
|
+
|
|
432
|
+
- If the lock is held (a live run or rollback), it raises the existing `Lock::AcquisitionError`.
|
|
433
|
+
There is no new error class; the caller retries.
|
|
434
|
+
- A run already in `rolling_back` raises `Error::ValidationError` ("rollback already in
|
|
435
|
+
progress", FR-027).
|
|
436
|
+
- **`Reactor.cancel` / `#cancel` gets the same guard**: on a `rolling_back` run it raises
|
|
437
|
+
`Error::ValidationError` ("rollback in progress; cannot cancel"). `cancelled` is terminal, so the
|
|
438
|
+
owner `Worker` returns early and the sweeper skips the run. The steps before the map would never
|
|
439
|
+
be undone and nothing would recover them (`/speckit-analyze` G1, Constitution II). `cancel`
|
|
440
|
+
otherwise stays as is.
|
|
441
|
+
- If the undo hands off, `#undo` records `rollback: {trigger: "undo"}`, sets `rolling_back`, saves,
|
|
442
|
+
and returns without cancelling. `Reactor.undo` skips its `cancel` call in that case, and
|
|
443
|
+
`resume_rollback` applies `cancelled` when the rollback finishes.
|
|
444
|
+
|
|
445
|
+
**Rationale**:
|
|
446
|
+
|
|
447
|
+
- Without the lock, element jobs can finish and resume the owner before the caller's process has
|
|
448
|
+
saved the undo stack it already popped, and steps after the map would be undone twice.
|
|
449
|
+
- This is a narrow part of `future_improvements.md` §"External actors send requests". It takes
|
|
450
|
+
the lock for undo only. It adds only the `rolling_back` status guard to `cancel`, and leaves
|
|
451
|
+
`continue` alone.
|
|
452
|
+
|
|
453
|
+
## R-13: `resume_execution` saves before releasing the context lock
|
|
454
|
+
|
|
455
|
+
**Decision**: In `Executor#resume_execution`'s `ensure`, `save_context` moves before
|
|
456
|
+
`@acquired_context_lock&.release`. The rollback hand-off also saves in its rescue body (R-04).
|
|
457
|
+
|
|
458
|
+
**Rationale**:
|
|
459
|
+
|
|
460
|
+
- Today the lock is released and then the blob saved. An owner Worker (R-03, R-05) can take the
|
|
461
|
+
lock in that gap, load the pre-save blob and continue from stale state, and the late save then
|
|
462
|
+
overwrites its progress.
|
|
463
|
+
- The collector's current resume has the same window. R-03 routes every map completion through it,
|
|
464
|
+
so the ordering becomes load-bearing.
|
|
465
|
+
- A regression spec pins it.
|
|
466
|
+
|
|
467
|
+
**Out of scope**: the synchronous caller's final save racing a worker (`Reactor.run` with a fan-out
|
|
468
|
+
map never holds `async:`). This is `future_improvements.md` §"Fenced context writes"; unchanged.
|
|
469
|
+
Tracked as the follow-up "A synchronous caller's final save races a worker resume" in
|
|
470
|
+
`specs/future_improvements.md` §"Rollback follow-ups (008)".
|
|
471
|
+
|
|
472
|
+
## R-14: A map step's undo record carries no arguments
|
|
473
|
+
|
|
474
|
+
**Decision**:
|
|
475
|
+
|
|
476
|
+
- `MapStep` declares `rollback_arguments` → `{}`, a `StepConfig` hook with default identity, used
|
|
477
|
+
where `ResultHandler` pushes the undo entry. A map's rollback reads its declaration
|
|
478
|
+
(`element_class`) and the map records, never the resolved `source`.
|
|
479
|
+
- This holds for both modes.
|
|
480
|
+
|
|
481
|
+
**Rationale**: The executor, not the collector, now records a fan-out map's completion (R-03). The
|
|
482
|
+
normal push would store the resolved `source` (a whole array) in the parent blob, which 008 R-03
|
|
483
|
+
deliberately avoided. Inline maps stored it too, needlessly.
|
|
484
|
+
|
|
485
|
+
## R-15: Observability
|
|
486
|
+
|
|
487
|
+
**Decision**:
|
|
488
|
+
|
|
489
|
+
- **Status `rolling_back`** is added in these places:
|
|
490
|
+
- `Worker::TERMINAL_STATUSES` excludes it;
|
|
491
|
+
- `RedisReactorScan` known statuses;
|
|
492
|
+
- the general sweeper;
|
|
493
|
+
- the dashboard, everywhere statuses are listed (`/speckit-analyze` C4):
|
|
494
|
+
- `gui/src/lib/reactors.ts` `STATUS_GROUPS.running`, so it counts and filters with the live
|
|
495
|
+
runs;
|
|
496
|
+
- `gui/src/components/StatusBadge.tsx`: amber with an undo icon;
|
|
497
|
+
- the status filter options in `LiveView.tsx` and `ReactorClassInstances.tsx`;
|
|
498
|
+
- the status colour in `ReactorDetail.tsx`.
|
|
499
|
+
- **Map reference hydration** (`Web::API.hydrate_map_ref`) adds
|
|
500
|
+
`rollback: { total, settled, outstanding, failed }` (`outstanding = total - settled`) from the
|
|
501
|
+
rollback records. The GUI step inspector shows "Rolling back settled/total (outstanding
|
|
502
|
+
outstanding, failed failed)" **only while the run's status is `rolling_back`**. The records
|
|
503
|
+
outlive the run by the durability TTL, so the API field alone would otherwise show a finished
|
|
504
|
+
rollback as in progress (`/speckit-analyze` A3).
|
|
505
|
+
- **Structured log lines**, key=value, with reactor, map step and element index:
|
|
506
|
+
- `event=ruby_reactor.map.rollback.started`, with `total` and `batch_size`;
|
|
507
|
+
- `event=ruby_reactor.map.rollback.element`, with `index`, `outcome` and `failures`;
|
|
508
|
+
- `event=ruby_reactor.map.rollback.completed`, with `total` and `failed`.
|
|
509
|
+
- **Element undo middleware events** (`start_undo` …) fire inside the element job with the
|
|
510
|
+
element's context, as they do in the inline path.
|
|
511
|
+
|
|
512
|
+
**Rationale**: Constitution IV and spec FR-009 / FR-011. There is no new middleware event type; the
|
|
513
|
+
existing per-step undo events already cover each element.
|
|
514
|
+
|
|
515
|
+
## R-16: Testing strategy
|
|
516
|
+
|
|
517
|
+
**Decision**: Real Redis, Sidekiq fake mode plus `drain_async_jobs`.
|
|
518
|
+
|
|
519
|
+
- **One new `inline!` spec** (T041). Its subject is inline-mode support itself: a spec edge case,
|
|
520
|
+
and R-05's claim that no hand-off happens. It cannot be written in fake mode. This deviates from
|
|
521
|
+
Constitution III, so it is justified in plan.md Complexity Tracking.
|
|
522
|
+
- **Existing specs** that already use `inline!` (`map_recovery_spec`, `map_batch_size_spec`,
|
|
523
|
+
`fail_fast_spec`) are extended where needed, but no new `inline!` block is added to them.
|
|
524
|
+
- **Step-wise drain helper**: `pending_async_jobs` is the step-wise helper. Its ActiveJob
|
|
525
|
+
`PendingJob` exposes `job_class` while the Sidekiq one exposes `worker_class`. The shipped
|
|
526
|
+
surface gains `worker_class` as an alias on the ActiveJob struct, so step-wise drains work under
|
|
527
|
+
`for_each_async_backend` (`/speckit-analyze` U4).
|
|
528
|
+
|
|
529
|
+
- **Oracle**: the same reactor run with an inline map and with a fan-out map must end with the same
|
|
530
|
+
final `Failure` (message, step, `rollback_failures` set) and the same set of undone elements.
|
|
531
|
+
Specs share one fixture reactor with a `fan_out` toggle.
|
|
532
|
+
- **Back pressure**: drain one job at a time and assert no single job enqueues more than `B`
|
|
533
|
+
element rollback jobs (`max_burst`). Queue depth is not asserted (R-02).
|
|
534
|
+
- **Fault injection**:
|
|
535
|
+
- raise an interruption from an element's second undo, then redeliver the job: the first undo
|
|
536
|
+
runs once and the second runs again;
|
|
537
|
+
- hold an element's `map_element:` lock while its rollback job runs: the job requeues, then
|
|
538
|
+
completes once the lock is free, with no `element_in_flight` (R-07);
|
|
539
|
+
- drop a rollback job, then run `Map::Sweeper.run_once`;
|
|
540
|
+
- drop the owner resume, then run `RubyReactor::Sweeper.run_once`.
|
|
541
|
+
- **Scale** (`:slow` tag): 10,000 elements, no row loaded twice per job; the peak number of element
|
|
542
|
+
contexts in memory per job is 1.
|
|
543
|
+
- **SC-002** (5× with 10 workers) needs real concurrency. It is measured by a benchmark rake task in
|
|
544
|
+
the demo app against Docker Sidekiq at concurrency 10 (quickstart §5), not in RSpec.
|
|
545
|
+
- **Baseline**: the inline-map rollback of the same elements. That is today's serial algorithm,
|
|
546
|
+
with chunked reads that don't change the per-element work. The spec's SC-002 now names this
|
|
547
|
+
baseline explicitly.
|
|
548
|
+
|
|
549
|
+
## R-17: Documentation impact
|
|
550
|
+
|
|
551
|
+
- **README.md**: the map section (~lines 880–930: `fan_out`, `batch_size`, the `fail_fast`
|
|
552
|
+
mentions, the rollback paragraph, "`batch_size` is optional"); the status list; the Durability
|
|
553
|
+
section's statuses.
|
|
554
|
+
- **documentation/data_pipelines.md**:
|
|
555
|
+
- "`fan_out` Without `batch_size`" (default 50);
|
|
556
|
+
- Back Pressure (applies to rollback);
|
|
557
|
+
- the `fail_fast` section, rewritten as `atomic` with a deprecation note;
|
|
558
|
+
- Rollback (distributed, `rolling_back`, at-least-once window).
|
|
559
|
+
- **documentation/background_and_async.md**: the fan-out maps section (owner resume, rollback
|
|
560
|
+
hand-off, the new worker class, sweeper coverage).
|
|
561
|
+
- **documentation/composition.md**: compose plus fan-out is supported, and how rollback travels.
|
|
562
|
+
- **documentation/core_concepts.md**: the rollback table map row; the `rolling_back` status.
|
|
563
|
+
- **documentation/testing.md**: the `be_rolling_back` matcher.
|
|
564
|
+
- **demo_app/documentation/data_pipelines.md**: kept in sync.
|
|
565
|
+
- **CHANGELOG.md**:
|
|
566
|
+
- Features: distributed map rollback, default batch size, `atomic`;
|
|
567
|
+
- Bug Fixes: composed-child fan-out, sweeper re-dispatch losing `batch_size` / `atomic`;
|
|
568
|
+
- Deprecations: `fail_fast`.
|
|
569
|
+
- **specs/future_improvements.md**:
|
|
570
|
+
- remove "Fan-out map inside a composed child" and "Rollback fan-out for very large maps";
|
|
571
|
+
- mark the collector failure-branch writer as fixed.
|
|
572
|
+
|
|
573
|
+
## R-18: SemVer
|
|
574
|
+
|
|
575
|
+
**Decision**: MINOR. Nothing is marked `!`.
|
|
576
|
+
|
|
577
|
+
The observable changes, each noted in CHANGELOG:
|
|
578
|
+
|
|
579
|
+
- fan-out maps over 50 elements without a declared `batch_size` are now back-pressured;
|
|
580
|
+
- the new status value `rolling_back`;
|
|
581
|
+
- one extra job per fan-out map completion;
|
|
582
|
+
- `fail_fast` warns.
|
|
583
|
+
|
|
584
|
+
**Rationale**: No working reactor needs a code change (spec SC-006). Consumers that filter on
|
|
585
|
+
status strings see one new value, which is additive.
|