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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Implementation Plan: Distributed Map Rollback and Bounded Fan-out
|
|
2
|
+
|
|
3
|
+
**Branch**: `distributed_map_undo` | **Date**: 2026-09-30 | **Spec**: [spec.md](spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/009-distributed-map-undo/spec.md`
|
|
6
|
+
|
|
7
|
+
**Revised 2026-09-30 after `/speckit-analyze`**:
|
|
8
|
+
|
|
9
|
+
- per-throw back-pressure bound;
|
|
10
|
+
- started-element coverage;
|
|
11
|
+
- the handed-off handshake;
|
|
12
|
+
- requeue on a contended element lock;
|
|
13
|
+
- aggregation dedup and symbols;
|
|
14
|
+
- the `cancel` guard;
|
|
15
|
+
- the GUI status surfaces;
|
|
16
|
+
- one reactor per demo file;
|
|
17
|
+
- the justified `inline!` spec.
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
Four changes to `map`, one shared mechanism:
|
|
22
|
+
|
|
23
|
+
1. **Distributed rollback (US1, P1)**. A fan-out map's compensate/undo stops loading every element
|
|
24
|
+
into one process.
|
|
25
|
+
- It starts a map rollback: one job per started element (only completed ones undo anything),
|
|
26
|
+
with the forward back-pressure mechanism. No throw enqueues more than `batch_size` (R-02,
|
|
27
|
+
R-06).
|
|
28
|
+
- The rollback then hands off with `Error::RollbackHandedOff` (R-04). The undo stack is the
|
|
29
|
+
resume cursor, the run becomes `rolling_back`, and it saves under its lock.
|
|
30
|
+
- The owner's `Worker` is resumed once, and only after the hand-off is saved. Whichever of the
|
|
31
|
+
last-settling job and the hand-off handler sees both "settled" and `handed_off` claims the
|
|
32
|
+
signal (R-05). `Executor#resume_rollback` then adopts the settled map, undoes the steps before
|
|
33
|
+
it and finalizes with the same `Failure` the inline path produces (I-7).
|
|
34
|
+
- Inline maps keep rolling back in process, reading element states in chunks of 100 (R-01).
|
|
35
|
+
2. **Fan-out map inside a composed child (US2, P2)**. The collector stops resuming contexts. It
|
|
36
|
+
signals the **owner** run, the top-level `root_context || context`, and `MapStep#run` adopts the
|
|
37
|
+
settled outcome on re-entry at any depth (R-03). This also removes the collector's
|
|
38
|
+
failure-branch write: map failures now roll back through the executor's normal path.
|
|
39
|
+
3. **Default batch size 50 (US3, P2)**. `Map::DEFAULT_BATCH_SIZE`, stored in the map metadata with
|
|
40
|
+
`atomic`. This fixes the sweeper re-dispatch losing both (R-10).
|
|
41
|
+
4. **`atomic` (US4, P3)**. It renames `fail_fast`, which stays as a warned alias. Legacy job
|
|
42
|
+
payloads are normalized in one place (R-11).
|
|
43
|
+
|
|
44
|
+
Supporting changes:
|
|
45
|
+
|
|
46
|
+
- `Reactor#undo` holds the run's context lock, and neither undo nor cancel is accepted on a
|
|
47
|
+
`rolling_back` run (R-12, FR-027).
|
|
48
|
+
- An element rollback job requeues itself on a contended element lock before reporting it in
|
|
49
|
+
flight (R-07).
|
|
50
|
+
- `resume_execution` saves before releasing that lock (R-13).
|
|
51
|
+
- Map undo records carry no arguments (R-14).
|
|
52
|
+
- Observability: `rolling_back` status, map rollback progress in the dashboard, and structured log
|
|
53
|
+
lines (R-15).
|
|
54
|
+
|
|
55
|
+
## Technical Context
|
|
56
|
+
|
|
57
|
+
**Language/Version**: Ruby >= 3.0
|
|
58
|
+
|
|
59
|
+
**Primary Dependencies**: Sidekiq and ActiveJob adapters, Redis, dry-validation. Optional
|
|
60
|
+
OpenTelemetry middleware. GUI: React/Vite (`gui/`, built into `lib/ruby_reactor/web/public`). No
|
|
61
|
+
new dependencies.
|
|
62
|
+
|
|
63
|
+
**Storage**: Redis through `Storage::RedisAdapter`. New data (see [data-model.md](data-model.md)):
|
|
64
|
+
|
|
65
|
+
- Context: the `rollback` hash and the `rolling_back` status.
|
|
66
|
+
- Map metadata: `owner_context_id`, `owner_reactor_class_name`, `batch_size`, `atomic`.
|
|
67
|
+
- `map:<id>:owner_signalled`.
|
|
68
|
+
- The `map:<id>:rollback:{metadata,offset,results,indexes,handed_off,signalled}` records.
|
|
69
|
+
|
|
70
|
+
The element-context index write is deduplicated at the source.
|
|
71
|
+
|
|
72
|
+
**Testing**: RSpec against real Redis (`redis://localhost:6780`), with Sidekiq fake mode plus
|
|
73
|
+
`drain_async_jobs` for every async path. One new `inline!` spec, for the inline-mode settle check;
|
|
74
|
+
it is justified in Complexity Tracking.
|
|
75
|
+
|
|
76
|
+
- **Oracle** (I-7): the same reactor with an inline map and with a fan-out map.
|
|
77
|
+
- **Fault injection**: an interruption during an element undo, a dropped rollback job, a dropped
|
|
78
|
+
owner resume.
|
|
79
|
+
- **Scale**: `:slow` tag, 10k elements.
|
|
80
|
+
- **Demo specs**: shipped matchers only, plus a new `be_rolling_back`.
|
|
81
|
+
- **SC-002**: a demo benchmark rake task on Docker Sidekiq at `-c 10` (quickstart §5).
|
|
82
|
+
|
|
83
|
+
**Target Platform**: Ruby gem (MRI), Sidekiq/ActiveJob workers, Rails demo app in Docker.
|
|
84
|
+
|
|
85
|
+
**Project Type**: Library (gem) with a bundled dashboard.
|
|
86
|
+
|
|
87
|
+
**Performance Goals**:
|
|
88
|
+
|
|
89
|
+
- No rollback job loads more than one element context.
|
|
90
|
+
- The coordinating owner holds no per-element state beyond the failures it reports (R-09).
|
|
91
|
+
- With 10 workers, rolling back 10k elements is at least 5× faster than the serial in-process
|
|
92
|
+
rollback of the same elements, the inline-map path (SC-002).
|
|
93
|
+
- Enqueue burst at most `B` per throw. Outstanding jobs are not bounded: the trigger is
|
|
94
|
+
position-based, as forward (R-02).
|
|
95
|
+
- Forward fan-out gains one job hop per map completion (collector → owner Worker, R-03).
|
|
96
|
+
|
|
97
|
+
**Constraints**:
|
|
98
|
+
|
|
99
|
+
- Single-writer rule (I-1): the collector and element rollback jobs never write a reactor context.
|
|
100
|
+
- A hand-off saves before its lock is released (I-6, R-13).
|
|
101
|
+
- The undo stack pops only after an undo returns (I-5, 008 R-16).
|
|
102
|
+
- `Error::Rescuable` must not match `RollbackHandedOff` (R-04).
|
|
103
|
+
- Legacy in-flight payloads (`fail_fast`, metadata without owner ids) keep working (S-6).
|
|
104
|
+
- An owner resume from a rollback requires `rollback:handed_off`, which is set after the hand-off
|
|
105
|
+
save (I-2, R-05).
|
|
106
|
+
- A `rolling_back` run is never cancelled or undone again (I-8, FR-027).
|
|
107
|
+
|
|
108
|
+
**Scale/Scope**:
|
|
109
|
+
|
|
110
|
+
- About 24 library files touched. New files: `map.rb`, `error/rollback_handed_off.rb`,
|
|
111
|
+
`map/element_rollback.rb`, one `map_element_rollback_worker.rb` per adapter, and
|
|
112
|
+
`dsl/definition_warnings.rb` (extracted).
|
|
113
|
+
- 9 new spec files and about 14 updated, plus 2 new spec support files.
|
|
114
|
+
- Demo:
|
|
115
|
+
- 7 reactor files, one reactor per file: `DistributedRefundDemoReactor`,
|
|
116
|
+
`DistributedRefundElementReactor`, `ComposedFanOutDemoReactor`, `ComposedFanOutChildReactor`,
|
|
117
|
+
`ComposedFanOutItemReactor`, `DefaultBatchFanOutDemoReactor`, `InlineRefundBenchmarkReactor`;
|
|
118
|
+
- 4 rake tasks plus the `map_execution_undo` group;
|
|
119
|
+
- 3 demo specs.
|
|
120
|
+
- 7 documentation files, CHANGELOG, and `future_improvements.md`. The constitution's worker-path
|
|
121
|
+
drift (C5) was already amended to 1.3.1 during analysis remediation.
|
|
122
|
+
- GUI: 5 components plus `lib/reactors.ts`.
|
|
123
|
+
|
|
124
|
+
## Constitution Check
|
|
125
|
+
|
|
126
|
+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
127
|
+
|
|
128
|
+
| Principle | Status | Note |
|
|
129
|
+
| --- | --- | --- |
|
|
130
|
+
| I. Gem-First Design | PASS | Everything is in `lib/` behind the existing DSL. The new workers live in the existing adapter modules; the Sidekiq and ActiveJob routers both gain the method. No host coupling. |
|
|
131
|
+
| II. Saga Pattern Integrity | PASS (the feature strengthens it) | Large-map rollback stops being bounded by one job's time and memory. Step-level reverse order is kept (I-4). Every hand-off has a recovery path: the sweepers for lost jobs and resumes (S-5), and resume from the last saved undo after a crash (R-07). Fixes a hang that left composed runs with no recovery path. |
|
|
132
|
+
| III. Test-First with Real Infrastructure | PASS with one justified deviation | Each story's specs are written first and fail first, against real Redis, including the FR-011 log-shape spec (T020(g)). Async orchestration uses fake mode plus drain. There is one new `inline!` spec (T041), justified in Complexity Tracking. Existing specs that already use `inline!` gain no new `inline!` blocks. |
|
|
133
|
+
| IV. Observability by Default | PASS | `rolling_back` appears in the state model, the scan, and every dashboard status surface (badge, status groups, live and per-class filters, detail colour). Map rollback progress (`total/settled/outstanding/failed`) is shown in the API, and in the GUI while `rolling_back`. Structured key=value log lines cover rollback start, element and completion. Element undo middleware events keep firing. Rollback failures keep `map_step` / `element_index`, and the symbol values are restored. |
|
|
134
|
+
| V. Simplicity & SemVer | PASS with notes | See Complexity Tracking for the two mechanisms added. Code is deleted: `Helpers#resume_parent_execution` and its helpers, and the collector's failure branch. No config knob for the default batch size. `fail_fast` is aliased, not removed. MINOR (R-18), with CHANGELOG notes for the default batch size, `rolling_back`, the extra job hop and the deprecation. |
|
|
135
|
+
| VI. Demo-App Proof of Feature | PASS (planned) | One reactor per file, named after its class, so a fresh Sidekiq process autoloads an element class by name (`eager_load = false`). Demos: `DistributedRefundDemoReactor` (fan-out, a later failure, distributed rollback with progress), `ComposedFanOutDemoReactor` (compose → fan-out, happy and failure paths) and `DefaultBatchFanOutDemoReactor` (default batch size), each with its element and child reactors in their own files. Rake `demo:distributed_map_rollback`, `demo:composed_fan_out` and `demo:default_batch_size`, grouped as `demo:map_execution_undo` in `demo:all`, plus `demo:map_rollback_benchmark`. Matcher-only specs; `be_rolling_back` and the ActiveJob `PendingJob#worker_class` alias are added to `lib/ruby_reactor/rspec/`. `ar_map_reactor_not_fail.rb` migrates to `atomic false`. No new Docker service. |
|
|
136
|
+
|
|
137
|
+
- [x] Documentation impact identified (research R-17):
|
|
138
|
+
- **README.md**: map section (~880–930), statuses, Durability.
|
|
139
|
+
- **documentation/**: `data_pipelines.md`, `background_and_async.md`, `composition.md`,
|
|
140
|
+
`core_concepts.md`, `testing.md`.
|
|
141
|
+
- **demo_app/documentation/data_pipelines.md**: kept in sync.
|
|
142
|
+
- **CHANGELOG.md**.
|
|
143
|
+
- **specs/future_improvements.md**: remove the fixed items.
|
|
144
|
+
- Carried into tasks.md as a required task per user story.
|
|
145
|
+
|
|
146
|
+
**Post-design re-check (after Phase 1)**: PASS.
|
|
147
|
+
|
|
148
|
+
- New public surface:
|
|
149
|
+
- one DSL method (`atomic`);
|
|
150
|
+
- one status;
|
|
151
|
+
- two router methods with their worker per adapter;
|
|
152
|
+
- one matcher and one test-helper alias;
|
|
153
|
+
- the dashboard `rollback` field;
|
|
154
|
+
- the `cancel` / `undo` guard on `rolling_back`.
|
|
155
|
+
- New internals: one control signal and one per-element rollback class, shared by the job and the
|
|
156
|
+
inline path.
|
|
157
|
+
- The complexity is justified below.
|
|
158
|
+
|
|
159
|
+
## Project Structure
|
|
160
|
+
|
|
161
|
+
### Documentation (this feature)
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
specs/009-distributed-map-undo/
|
|
165
|
+
├── plan.md # This file
|
|
166
|
+
├── research.md # Phase 0: R-01..R-18
|
|
167
|
+
├── data-model.md # Phase 1: records, statuses, outcomes
|
|
168
|
+
├── quickstart.md # Phase 1: validation guide
|
|
169
|
+
├── contracts/
|
|
170
|
+
│ ├── api-surface.md # DSL, statuses, undo, routers/workers, dashboard, logs, matcher
|
|
171
|
+
│ └── rollback-protocol.md # Invariants I-1..I-8, sequences S-1..S-6
|
|
172
|
+
├── checklists/requirements.md
|
|
173
|
+
└── tasks.md # Phase 2 (/speckit-tasks)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Source Code (repository root)
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
lib/ruby_reactor/
|
|
180
|
+
├── error/rollback_handed_off.rb # new: control signal carrying map_id (R-04)
|
|
181
|
+
├── error/rescuable.rb # === false for RollbackHandedOff (R-04)
|
|
182
|
+
├── map.rb # new Zeitwerk namespace file: DEFAULT_BATCH_SIZE = 50,
|
|
183
|
+
│ # ROLLBACK_CHUNK = 100 (R-10, R-01)
|
|
184
|
+
├── map/element_rollback.rb # new: roll back one element (lock with requeue on contention,
|
|
185
|
+
│ # per-entry checkpoint, outcome, handed_off-gated owner signal);
|
|
186
|
+
│ # used by the job and the inline path (R-05, R-07)
|
|
187
|
+
├── map/collector.rb # settled → SET NX owner_signalled → enqueue owner Worker;
|
|
188
|
+
│ # no context writes; legacy fallback to parent ids (R-03, S-6)
|
|
189
|
+
├── map/helpers.rb # delete resume_parent_execution & co.; add normalize_arguments
|
|
190
|
+
│ # (fail_fast → atomic) (R-03, R-11)
|
|
191
|
+
├── map/dispatcher.rb # default batch size; atomic; rollback batch dispatch
|
|
192
|
+
│ # (claim positions, LRANGE, enqueue) (R-02, R-06, R-10)
|
|
193
|
+
├── map/element_executor.rb # register the id only for a fresh context; atomic (R-06, R-11)
|
|
194
|
+
├── map/sweeper.rb # rollback pass (missing positions, lost batch trigger);
|
|
195
|
+
│ # owner lock for recollect (R-05, S-5)
|
|
196
|
+
├── step/map_step.rb # run: adopt on re-entry; owner ids + batch_size +
|
|
197
|
+
│ # atomic in metadata; store the owner tree before dispatch.
|
|
198
|
+
│ # compensate/undo: distributed start / settle / aggregate /
|
|
199
|
+
│ # hand-off; inline chunked; aggregation dedups unavailable
|
|
200
|
+
│ # entries and re-symbolizes. rollback_arguments → {} (R-01..R-10, R-14)
|
|
201
|
+
├── step/compose_step.rb # none expected; covered by the specs (S-3)
|
|
202
|
+
├── executor.rb # rescue RollbackHandedOff in execute/resume_execution; handed_off
|
|
203
|
+
│ # handshake; undo_all records/restores its level's failures; new
|
|
204
|
+
│ # resume_rollback; save before releasing the context lock (R-04, R-05, R-13)
|
|
205
|
+
├── executor/compensation_manager.rb # track the pending failing step + compensated flag; per-entry
|
|
206
|
+
│ # callback on rollback_completed_steps (R-04, R-07)
|
|
207
|
+
├── executor/result_handler.rb # undo entry uses step_config.rollback_arguments (R-14)
|
|
208
|
+
├── executor/retry_manager.rb # atomic in element requeue args (R-11)
|
|
209
|
+
├── dsl/map_builder.rb # atomic; fail_fast alias + warning; both → error (R-11)
|
|
210
|
+
├── dsl/definition_warnings.rb # new: warn_definition extracted from StepBuilder (R-11)
|
|
211
|
+
├── dsl/step_builder.rb # include DefinitionWarnings; StepConfig#rollback_arguments (R-11, R-14)
|
|
212
|
+
├── context.rb / context_serializer.rb # `rollback` field (R-04)
|
|
213
|
+
├── reactor.rb # undo: context lock, reject rolling_back, hand-off → handshake,
|
|
214
|
+
│ # skip cancel; cancel rejects rolling_back (R-12, FR-027)
|
|
215
|
+
├── worker.rb # rolling_back → resume_rollback; non-terminal (R-04, R-15)
|
|
216
|
+
├── sweeper.rb # re-enqueue rolling_back without an async: lock (R-05)
|
|
217
|
+
├── storage/redis_adapter.rb # metadata fields; owner signal; rollback records; LRANGE by range
|
|
218
|
+
├── storage/redis_reactor_scan.rb # rolling_back known status (R-15)
|
|
219
|
+
├── adapters/{sidekiq,active_job}/router.rb # perform_map_element_rollback_async / _in
|
|
220
|
+
├── adapters/{sidekiq,active_job}/map_element_rollback_worker.rb # new
|
|
221
|
+
├── web/api.rb # map_ref hydration: rollback {total, settled, outstanding, failed} (R-15)
|
|
222
|
+
└── rspec/{matchers,test_subject,sidekiq_helpers,active_job_helpers}.rb
|
|
223
|
+
# be_rolling_back; drain on rolling_back; register rollback worker;
|
|
224
|
+
# PendingJob#worker_class alias (R-16)
|
|
225
|
+
|
|
226
|
+
gui/src/lib/reactors.ts # STATUS_GROUPS.running += rolling_back
|
|
227
|
+
gui/src/components/StatusBadge.tsx # amber + undo icon for rolling_back
|
|
228
|
+
gui/src/components/{LiveView,ReactorClassInstances}.tsx # rolling_back filter option
|
|
229
|
+
gui/src/components/ReactorDetail.tsx # rolling_back colour
|
|
230
|
+
gui/src/components/StepInspector.tsx # rollback progress while rolling_back
|
|
231
|
+
lib/ruby_reactor/web/public/ # rebuilt bundle
|
|
232
|
+
|
|
233
|
+
spec/
|
|
234
|
+
├── support/{map_rollback_fixtures,queue_probe}.rb # new helpers
|
|
235
|
+
├── ruby_reactor/executor/context_lock_save_order_spec.rb # new: R-13 regression
|
|
236
|
+
├── ruby_reactor/executor/resume_rollback_spec.rb # new: hand-off + resume (stub construct)
|
|
237
|
+
├── ruby_reactor/rollback/distributed_map_rollback_spec.rb # new: US1 incl. oracle, back pressure, undo,
|
|
238
|
+
│ # cancel guard, log shape, inline mode (T041)
|
|
239
|
+
├── ruby_reactor/rollback/map_rollback_recovery_spec.rb # new: fault injection, contention requeue, sweepers
|
|
240
|
+
├── ruby_reactor/rspec/be_rolling_back_spec.rb # new
|
|
241
|
+
├── map/map_owner_resume_spec.rb # new: R-03 at root level
|
|
242
|
+
├── map/map_legacy_payload_spec.rb # new: fail_fast payloads (FR-023)
|
|
243
|
+
├── map/map_compose_fan_out_spec.rb # new: US2 incl. rollback through the root
|
|
244
|
+
├── map/map_atomic_spec.rb # new: US4
|
|
245
|
+
├── ruby_reactor/rollback/map_rollback_spec.rb # updated: both paths, chunked inline, nested map
|
|
246
|
+
├── ruby_reactor/rollback/map_scale_spec.rb # updated: one element per job
|
|
247
|
+
├── ruby_reactor/map/{sweeper,dispatcher}_spec.rb # updated: rollback pass, owner lock, rollback batches
|
|
248
|
+
├── ruby_reactor/storage/redis_adapter_spec.rb # updated: metadata, owner signal, rollback records
|
|
249
|
+
├── ruby_reactor/sweeper_spec.rb # updated: rolling_back
|
|
250
|
+
├── map/map_batch_size_spec.rb # updated: default 50, metadata
|
|
251
|
+
├── map/map_recovery_spec.rb # updated: re-dispatch keeps batch_size/atomic
|
|
252
|
+
└── map/{fail_fast→atomic_inline, map_fail_fast→map_atomic_async, map_retry, map_async_retry}_spec.rb
|
|
253
|
+
|
|
254
|
+
demo_app/
|
|
255
|
+
├── app/reactors/distributed_refund_demo_reactor.rb # one reactor per file (Constitution VI.1)
|
|
256
|
+
├── app/reactors/distributed_refund_element_reactor.rb # + its charge step class
|
|
257
|
+
├── app/reactors/inline_refund_benchmark_reactor.rb # same element, fan_out false (SC-002 baseline)
|
|
258
|
+
├── app/reactors/composed_fan_out_demo_reactor.rb
|
|
259
|
+
├── app/reactors/composed_fan_out_child_reactor.rb
|
|
260
|
+
├── app/reactors/composed_fan_out_item_reactor.rb
|
|
261
|
+
├── app/reactors/default_batch_fan_out_demo_reactor.rb
|
|
262
|
+
├── app/reactors/ar_map_reactor_not_fail.rb # fail_fast false → atomic false
|
|
263
|
+
├── lib/tasks/demo_reactors.rake # demo:distributed_map_rollback, :composed_fan_out, :default_batch_size,
|
|
264
|
+
│ # :map_execution_undo (in demo:all), :map_rollback_benchmark
|
|
265
|
+
└── spec/reactors/{distributed_refund,composed_fan_out,default_batch_fan_out}_demo_reactor_spec.rb
|
|
266
|
+
|
|
267
|
+
README.md, documentation/*.md, demo_app/documentation/data_pipelines.md, CHANGELOG.md,
|
|
268
|
+
specs/future_improvements.md # per R-17
|
|
269
|
+
.specify/memory/constitution.md # amended to 1.3.1 during analysis remediation (C5): worker path
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Structure Decision**: single gem project, existing `lib/ruby_reactor` layout. No new directories
|
|
273
|
+
under `lib/`. The new rollback specs sit next to 008's in `spec/ruby_reactor/rollback/`, and the
|
|
274
|
+
map DSL/dispatch specs in `spec/map/`.
|
|
275
|
+
|
|
276
|
+
## Delivery Order
|
|
277
|
+
|
|
278
|
+
The stories are independently testable. tasks.md is the authoritative order. The owner-resume
|
|
279
|
+
mechanism is foundational, because a failed fan-out map's rollback must run through the executor
|
|
280
|
+
(not the collector) before US1 can hand it off:
|
|
281
|
+
|
|
282
|
+
1. **Foundation** (blocks every story):
|
|
283
|
+
- map metadata owner ids, `batch_size`, `atomic` (R-10);
|
|
284
|
+
- `Map::DEFAULT_BATCH_SIZE`;
|
|
285
|
+
- save before releasing the context lock (R-13), with its regression spec;
|
|
286
|
+
- `StepConfig#rollback_arguments` (R-14);
|
|
287
|
+
- `normalize_arguments` (R-11 internals);
|
|
288
|
+
- **owner resume + adopt at root level (R-03)**: the collector rewrite and `MapStep#run`
|
|
289
|
+
re-entry, deleting `resume_parent_execution`.
|
|
290
|
+
2. **US1: distributed rollback**:
|
|
291
|
+
- a. `RollbackHandedOff`, `Rescuable` exclusion, the `rollback` context field, `rolling_back`
|
|
292
|
+
status, executor rescue plus `resume_rollback`, `Worker` branch. Spec: a stub construct that
|
|
293
|
+
hands off once, resumed by `Worker`.
|
|
294
|
+
- b. `Map::ElementRollback`, with per-entry checkpoints (R-07).
|
|
295
|
+
- c. Storage rollback records and dispatcher rollback batches; router methods and workers.
|
|
296
|
+
- d. `MapStep#compensate` / `#undo`, both distributed and inline chunked. Oracle and
|
|
297
|
+
back-pressure specs.
|
|
298
|
+
- e. `Reactor#undo` lock, hand-off and the `cancel` guard (R-12).
|
|
299
|
+
- f. Handshake-gated owner signal and contention requeue (R-05, R-07).
|
|
300
|
+
- g. Recovery: the sweepers (S-5) and fault-injection specs.
|
|
301
|
+
- h. Observability: logs, API field, every GUI status surface, matcher.
|
|
302
|
+
3. **US2: composed child**:
|
|
303
|
+
- store the owner tree before dispatch;
|
|
304
|
+
- compose specs (happy, interrupt, nested, rollback through the root, manual undo from the root);
|
|
305
|
+
- child `undo_all` hand-off (S-3; needs US1 for the distributed variant).
|
|
306
|
+
4. **US3: default batch size**:
|
|
307
|
+
- the fallback in `MapStep` and `Dispatcher`;
|
|
308
|
+
- specs for 500, 20 and explicit elements.
|
|
309
|
+
5. **US4: `atomic`**:
|
|
310
|
+
- the DSL, the warning, the both-declared error;
|
|
311
|
+
- migrate internal specs and the demo;
|
|
312
|
+
- a legacy payload spec.
|
|
313
|
+
6. **Demo, docs, CHANGELOG** per story, closing with the Docker acceptance run and the SC-002
|
|
314
|
+
benchmark.
|
|
315
|
+
|
|
316
|
+
## Complexity Tracking
|
|
317
|
+
|
|
318
|
+
| Addition | Why needed | Simpler alternative rejected because |
|
|
319
|
+
| --- | --- | --- |
|
|
320
|
+
| Resumable rollback (`RollbackHandedOff`, context `rollback` state, `rolling_back`, `Executor#resume_rollback`) | FR-001 and FR-004: element rollbacks run in other jobs, and the steps before the map must wait for them without holding a worker. | **Waiting in place** holds a worker for the whole rollback, hits job timeouts, and turns a shutdown into `aborted`. **A sentinel return value** needs plumbing through six call sites. **Snooze-polling** reloads the parent blob on every poll. The undo stack already gives the resume cursor, so the addition is the signal plus about five persisted fields (R-04). |
|
|
321
|
+
| Owner resume + adopt-on-re-entry (collector signals the owner; `MapStep#run` adopts) | FR-013 to FR-015 (the composed-child hang), and the same resume path US1 needs. | **Collector writes the root** breaks the single-writer rule. **Special-casing composed children** leaves two completion paths and the collector's failure-branch write. This change deletes more than it adds (R-03). |
|
|
322
|
+
| Map rollback records (6 keys, including `handed_off`) | Per-position idempotent outcomes, atomic batch claims, an owner signal that is exactly-once and never early (the handshake), and naming unavailable indexes at bounded memory. | **Reusing the forward results hash** would mix forward and rollback outcomes for the same index, and the forward slots are still read by the `ResultEnumerator` of a map that completed. **Relying on the `async:` lock alone** to order the owner resume fails in inline job mode, where the lock is skipped and the owner would run nested on a stale blob. |
|
|
323
|
+
| One new `Sidekiq::Testing.inline!` spec (T041), a deviation from Constitution III | The subject is inline-mode support itself: R-05's claim that a map rollback settling synchronously never hands off and never enqueues a nested owner resume. It is a spec edge case (Edge Cases, "Inline job-testing mode"). | **Fake mode plus drain** cannot express it: jobs never run inside dispatch there, so the path under test never executes. It is scoped to one example. The existing `inline!` specs gain no new `inline!` blocks. |
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# PR description draft: 009 Distributed Map Rollback and Bounded Fan-out
|
|
2
|
+
|
|
3
|
+
## Regression bar (T003, before any change, at `c9711cc1`)
|
|
4
|
+
|
|
5
|
+
- `bundle exec rspec spec/map spec/ruby_reactor/rollback spec/ruby_reactor/map spec/compose_spec.rb spec/single_worker_map_spec.rb`:
|
|
6
|
+
**192 examples, 0 failures**.
|
|
7
|
+
- `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tail -1`:
|
|
8
|
+
**64 scenarios, 64 match, 0 mismatch**.
|
|
9
|
+
|
|
10
|
+
## `RollbackHandedOff` rescue audit (T078)
|
|
11
|
+
|
|
12
|
+
`RollbackHandedOff` is an `Error::Base` (a `StandardError`), so every broad rescue between
|
|
13
|
+
`MapStep#compensate`/`#undo` and the handlers (`Executor`, `Reactor#undo`) was checked:
|
|
14
|
+
|
|
15
|
+
- `rescue Error::Rescuable` in `CompensationManager#compensate_step_body` / `#undo_step`,
|
|
16
|
+
`StepExecutor#safe_execute_step_sync`, `StepCoordination`, `StepBuilder#resolve_arguments`,
|
|
17
|
+
`MapStep#collect_results`: pass it through, since `Rescuable.===` excludes it.
|
|
18
|
+
- `Executor#aborting_on_interruption`: re-raises it without marking the run `aborted`.
|
|
19
|
+
- `StepExecutor#execute_step`'s `rescue Exception`: emits its step event and re-raises;
|
|
20
|
+
`track_interrupted_construct` ignores it.
|
|
21
|
+
- `rescue StandardError` in `StepCoordination` (lock/semaphore release, ordered-lock advance,
|
|
22
|
+
`chain_failed?`), `OrderedLockSupport`, `MiddlewareRunner#on`, `Executor#publish_completion_signal`
|
|
23
|
+
/ `#release_one`, `Worker` (retries-exhausted and deserialization helpers), `Reactor` reloads and
|
|
24
|
+
`Map::Sweeper`: none wraps a rollback call, so none can see it.
|
|
25
|
+
|
|
26
|
+
No explicit re-raise was needed.
|
|
27
|
+
|
|
28
|
+
## Results
|
|
29
|
+
|
|
30
|
+
- Gem suite: `bundle exec rspec` → **1479 examples, 0 failures, 2 pending** (the pending pair is
|
|
31
|
+
US2-AS2, an interrupt inside a composed child, unsupported on main; see research R-03).
|
|
32
|
+
`bundle exec rspec --tag slow` → 2 examples, 0 failures (10,000-element inline and fan-out
|
|
33
|
+
rollbacks).
|
|
34
|
+
- T003 regression set: 192 → now part of the above, all green.
|
|
35
|
+
- 007 evidence harness: **64 scenarios, 64 match**. S-map-12's expected order changed (009 R-08:
|
|
36
|
+
element undos run in their own jobs, after the elements' independent `async_step` units);
|
|
37
|
+
the harness drains `MapElementRollbackWorker` too.
|
|
38
|
+
- `bundle exec rubocop`: no offenses.
|
|
39
|
+
- GUI: `npm test` 68 passed; bundle rebuilt into `lib/ruby_reactor/web/public/`.
|
|
40
|
+
- Demo specs (`REDIS_URL=redis://localhost:6780/5`): 145 examples, 0 failures.
|
|
41
|
+
- Docker acceptance (isolated project `rr_009`, real Sidekiq): `demo:map_execution_undo`
|
|
42
|
+
prints SUCCESS for all four scenarios (distributed refund with a `rolling_back` progress line,
|
|
43
|
+
composed fan-out happy and failure paths, default batch size 50 with 120 collected).
|
|
44
|
+
|
|
45
|
+
## Constitution re-check (T086)
|
|
46
|
+
|
|
47
|
+
- Docs (R-17): README, `data_pipelines.md`, `background_and_async.md`, `composition.md`,
|
|
48
|
+
`core_concepts.md`, `testing.md`, `demo_app/documentation/data_pipelines.md`,
|
|
49
|
+
`specs/future_improvements.md`.
|
|
50
|
+
- CHANGELOG: Features (default batch size, distributed rollback, `rolling_back`, router methods,
|
|
51
|
+
matcher, undo lock), Bug Fixes (composed fan-out, owner resume, sweeper re-dispatch, save before
|
|
52
|
+
release, undo record, `exception_class`), Deprecations (`fail_fast` → `atomic`).
|
|
53
|
+
- Demo per story, one reactor per file: US1 `DistributedRefundDemoReactor` +
|
|
54
|
+
`DistributedRefundElementReactor` (+ `InlineRefundBenchmarkReactor`), US2
|
|
55
|
+
`ComposedFanOutDemoReactor` + `ComposedFanOutChildReactor` + `ComposedFanOutItemReactor`, US3
|
|
56
|
+
`DefaultBatchFanOutDemoReactor` + `DefaultBatchNumberReactor`, US4 `ar_map_reactor_not_fail.rb`
|
|
57
|
+
on `atomic false`. Rake: `demo:distributed_map_rollback`, `demo:composed_fan_out`,
|
|
58
|
+
`demo:default_batch_size`, grouped as `demo:map_execution_undo` (in `demo:all`), plus
|
|
59
|
+
`demo:map_rollback_benchmark`. Specs use the shipped surface (`be_rolling_back` added).
|
|
60
|
+
- `git diff main -- spec | grep '^+.*inline!'`: none besides T041's
|
|
61
|
+
`Sidekiq::Testing.inline! { reactor.run(items) }` in `distributed_map_rollback_spec.rb`.
|
|
62
|
+
|
|
63
|
+
## SC-002 benchmark (T084) — not demonstrated
|
|
64
|
+
|
|
65
|
+
Run in the isolated `rr_009` stack, `demo-sidekiq -c 10`, `demo:map_rollback_benchmark[10000]`
|
|
66
|
+
(each side timed from `:notify`'s failure to the end of the run):
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
serial (inline map): 14.57s, 17286 refunds
|
|
70
|
+
distributed (fan-out, batch_size 10): 118.60s, 22714 refunds, failed
|
|
71
|
+
ratio: 0.1x
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Invalid: both sides refunded more than 10,000 elements. `demo:flush_redis` restarts the recovery
|
|
75
|
+
sweeper, which (a) re-enqueued the in-process inline run into a worker (known follow-up "Sweeper
|
|
76
|
+
re-enqueues in-progress inline runs"), and (b) re-dispatched not-yet-thrown element jobs of the
|
|
77
|
+
fan-out map (review finding F1), so elements ran and were refunded twice and the distributed run
|
|
78
|
+
shared Sidekiq with that duplicate work. Rerun after fixing F1, with the sweeper disabled.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Quickstart: Validate Distributed Map Rollback and Bounded Fan-out
|
|
2
|
+
|
|
3
|
+
A guide to proving each user story end to end. The behavior is specified in
|
|
4
|
+
[contracts/](contracts/), and the records in [data-model.md](data-model.md).
|
|
5
|
+
|
|
6
|
+
## 0. Prerequisites
|
|
7
|
+
|
|
8
|
+
- Ruby >= 3.0 and `bundle install`.
|
|
9
|
+
- Test Redis reachable at `redis://localhost:6780`. The suite refuses to start without it.
|
|
10
|
+
- Docker, for the demo acceptance runs and the SC-002 benchmark only.
|
|
11
|
+
- If other suites share the test Redis, run the new specs alone first (known flake under load).
|
|
12
|
+
|
|
13
|
+
## 1. User Story 1: distributed rollback (P1)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle exec rspec spec/ruby_reactor/rollback/distributed_map_rollback_spec.rb
|
|
17
|
+
bundle exec rspec spec/ruby_reactor/rollback/map_rollback_recovery_spec.rb
|
|
18
|
+
bundle exec rspec spec/ruby_reactor/rollback/map_rollback_spec.rb # 008 coverage, now via both paths
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Expected:
|
|
22
|
+
|
|
23
|
+
| Scenario | Observable outcome |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| Fan-out map of 20 elements, `batch_size 5`, a later step fails. This is the spec's 1,000/50 scenario scaled down; §1 Scale covers 10,000/50. | Mid-drain (once rollback jobs are pending): `be_rolling_back`. While draining: no job enqueues more than 5 rollback jobs (`max_burst`); queue depth is not bounded (R-02). After draining: `be_failure`; every element undone once; the steps before the map undone after the last element. |
|
|
26
|
+
| Same reactor with an inline map (oracle, I-7) | Same final `Failure` message, step and `rollback_failures` set as the fan-out run. |
|
|
27
|
+
| Element 7 fails under atomic | Every started element gets a rollback job. Element 7's job reports `not_needed` and undoes nothing. Skipped indexes get no job. |
|
|
28
|
+
| An element's lock is held while its rollback job runs | The job requeues itself, then completes once the lock is free, with no `element_in_flight` failure. After `lock_snooze_max_attempts` requeues it reports `element_in_flight`. |
|
|
29
|
+
| An element context expired (row deleted) | Exactly one `context_unavailable` entry for that index, as with the inline map. |
|
|
30
|
+
| An element reactor containing an inline (nested) map | The nested map is rolled back inline inside the outer element's rollback job; the log shows nested undos before the outer element's earlier steps. |
|
|
31
|
+
| One element's undo raises | The run still undoes the steps before the map. `rollback_failures` holds that element's entry with `map_step` and `element_index`. |
|
|
32
|
+
| Interruption raised in an element's second undo, then the job is redelivered | The first undo ran once. The second may run twice. The run finishes its rollback. |
|
|
33
|
+
| Rollback job dropped, then `Map::Sweeper.run_once` | The position is re-dispatched and the run finishes. |
|
|
34
|
+
| Owner resume dropped, then `RubyReactor::Sweeper.run_once` | The owner is re-enqueued and the run finishes. |
|
|
35
|
+
| `Reactor.undo(id)` on a completed run with a fan-out map | Returns with the run `rolling_back`; after draining, `cancelled`. A second `Reactor.undo` while rolling back raises `ValidationError`, and so does `Reactor.cancel`. |
|
|
36
|
+
| Inline map rollback | Elements are read 100 at a time, highest index first. Same outcome as before. |
|
|
37
|
+
|
|
38
|
+
Scale (slow):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bundle exec rspec spec/ruby_reactor/rollback/map_scale_spec.rb --tag slow
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Expected: 10,000 elements roll back, and each rollback job loads exactly one element context.
|
|
45
|
+
|
|
46
|
+
## 2. User Story 2: fan-out map inside a composed child (P2)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle exec rspec spec/map/map_compose_fan_out_spec.rb
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Expected (drain all jobs):
|
|
53
|
+
|
|
54
|
+
- Root composes Child; Child has `map … fan_out batch_size: 1`. The root is `be_success` with the
|
|
55
|
+
expected result. Before this change it stayed `running`.
|
|
56
|
+
- The same with an interrupt after the map in Child: `be_paused_at(...)`, then `resume`, then
|
|
57
|
+
`be_success`.
|
|
58
|
+
- A root step after the compose fails: elements rolled back (distributed), then Child's earlier
|
|
59
|
+
steps, then Root's. The root is `be_failure`.
|
|
60
|
+
- A Child element fails under atomic: the root (not only Child's record) is `be_failure`.
|
|
61
|
+
- Compose nested two levels deep: the top-level root is `be_success`.
|
|
62
|
+
- `Reactor.undo(root id)` on a completed root whose composed child ran a fan-out map: the root is
|
|
63
|
+
`rolling_back`, then `cancelled`. Every element and every earlier step is undone exactly once.
|
|
64
|
+
- A root step after the compose fails, and the child step after the map has an undo that fails.
|
|
65
|
+
That failure is recorded before the child hands off, and it still appears in the root's final
|
|
66
|
+
`rollback_failures` after the resume.
|
|
67
|
+
- The same child step with a failing undo, reached by a manual undo instead: the failure is in the
|
|
68
|
+
trace, as today for manual undo.
|
|
69
|
+
|
|
70
|
+
## 3. User Story 3: default batch size (P2)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bundle exec rspec spec/map/map_batch_size_spec.rb
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Expected:
|
|
77
|
+
|
|
78
|
+
- `fan_out` with no batch size, 500 elements: no job enqueues more than 50 element jobs
|
|
79
|
+
(`max_burst`), and all 500 outcomes are collected.
|
|
80
|
+
- 20 elements: all 20 enqueued at once.
|
|
81
|
+
- `batch_size: 200`: 200 per throw.
|
|
82
|
+
- Map metadata stores `batch_size` and `atomic`. `Map::Sweeper` re-dispatch keeps both.
|
|
83
|
+
|
|
84
|
+
## 4. User Story 4: `atomic` (P3)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bundle exec rspec spec/map/map_atomic_spec.rb spec/map/map_atomic_async_spec.rb spec/map/atomic_inline_spec.rb
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Expected:
|
|
91
|
+
|
|
92
|
+
- No declaration: one element failure fails the map and rolls back the completed elements.
|
|
93
|
+
- `atomic false`: the map completes with one `Failure` among the results.
|
|
94
|
+
- `fail_fast false`: the same behavior, plus exactly one stderr line starting
|
|
95
|
+
`[RubyReactor] DEPRECATION:` that names `atomic`.
|
|
96
|
+
- Both declared: `ValidationError` at class definition.
|
|
97
|
+
- An element job payload carrying `"fail_fast" => true` (legacy) is processed as atomic.
|
|
98
|
+
|
|
99
|
+
## 5. Demo app acceptance (Constitution VI)
|
|
100
|
+
|
|
101
|
+
Locally, without Docker (test Redis DB 5; run `bin/rails db:prepare` once first):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd demo_app
|
|
105
|
+
REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec \
|
|
106
|
+
spec/reactors/distributed_refund_demo_reactor_spec.rb \
|
|
107
|
+
spec/reactors/composed_fan_out_demo_reactor_spec.rb \
|
|
108
|
+
spec/reactors/default_batch_fan_out_demo_reactor_spec.rb spec/reactors/ar_map_reactor_not_fail_spec.rb
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Real Sidekiq, in an isolated compose project. `docker-compose.yml` hard-codes container names, so a
|
|
112
|
+
second worktree needs its own project plus an override that gives unique `container_name`s and
|
|
113
|
+
`ports: !reset []`:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
docker compose -p rr_009 -f docker-compose.yml -f override.yml up -d --build demo-redis demo-sidekiq
|
|
117
|
+
docker compose -p rr_009 -f docker-compose.yml -f override.yml run --rm --no-deps demo-app \
|
|
118
|
+
bash -c "bin/rails db:prepare && bin/rails demo:map_execution_undo"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Expected output, in order:
|
|
122
|
+
|
|
123
|
+
- `DistributedRefundDemoReactor`: dispatch, then a `rolling_back` line with progress, then
|
|
124
|
+
`failed`, with refunds equal to charges.
|
|
125
|
+
- `ComposedFanOutDemoReactor`: `completed` on the happy path, and `failed` with everything
|
|
126
|
+
unshipped on the failure path.
|
|
127
|
+
- `DefaultBatchFanOutDemoReactor`: map metadata `batch_size 50`, and 120 elements collected.
|
|
128
|
+
|
|
129
|
+
### SC-002 benchmark (manual)
|
|
130
|
+
|
|
131
|
+
Run `demo-sidekiq` with `command: bundle exec sidekiq -c 10` in the override, then:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
docker compose -p rr_009 -f docker-compose.yml -f override.yml run --rm --no-deps demo-app \
|
|
135
|
+
bin/rails "demo:map_rollback_benchmark[10000]"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The task prints two times and their ratio, each timed from the moment `:notify` fails (the
|
|
139
|
+
rollback's start) to the end of the run, so the forward run is not counted:
|
|
140
|
+
|
|
141
|
+
- the serial baseline: the inline-map rollback of the same 10,000 elements, through
|
|
142
|
+
`InlineRefundBenchmarkReactor`, which is today's serial algorithm;
|
|
143
|
+
- the distributed rollback time.
|
|
144
|
+
|
|
145
|
+
Pass: the distributed time is 5× faster or better (SC-002).
|
|
146
|
+
|
|
147
|
+
## 6. Full checks before PR
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bundle exec rspec
|
|
151
|
+
bundle exec rubocop
|
|
152
|
+
cd gui && npm test && npm run build # rolling_back in badge/groups/filters + map rollback progress
|
|
153
|
+
grep -rn "fail_fast" lib gui/src documentation README.md demo_app/app demo_app/documentation # only the deprecated alias, its warning, and legacy normalization remain
|
|
154
|
+
```
|