ruby_reactor 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -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
+ ```