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,53 @@
1
+ # Specification Quality Checklist: Distributed Map Rollback and Bounded Fan-out
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-09-30
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - The product is a developer library, so its stakeholders are developers: DSL names (`fan_out`,
35
+ `batch_size`, `fail_fast`, `atomic`) are the user-facing surface and are kept on purpose.
36
+ The spec names no internal classes, storage keys or job backends.
37
+ - No clarification markers. Defaults taken instead, recorded in Assumptions:
38
+ - `fail_fast` becomes a deprecated alias, not removed (MINOR release);
39
+ - 50 is a fixed library default with no global setting (per-map `batch_size` overrides it);
40
+ - rollback mirrors execution mode (inline maps stay in process, with bounded reads);
41
+ - no ordering guarantee across elements' rollbacks.
42
+ - FR-025/FR-026 (docs, CHANGELOG, demo app) are there because the constitution requires them for
43
+ every feature, not as implementation detail.
44
+ - 2026-09-30, after /speckit-plan: FR-002, FR-006 and FR-008 and SC-001, SC-003 and SC-004
45
+ reworded to match research R-02 (the back-pressure bound per throw), R-07 (the at-least-once
46
+ window for the undo that was cut off) and R-08 (start order). Re-validated: all items still pass.
47
+ - 2026-09-30, after /speckit-analyze. All 27 findings were addressed:
48
+ - spec: US1-AS2, FR-002, FR-003, FR-007, FR-009, FR-018, SC-001, SC-002 and SC-004, the Edge
49
+ Cases and Key Entities were revised, and FR-027 (a single rollback per run) was added;
50
+ - plan, research, data-model, contracts, quickstart and tasks were updated to match;
51
+ - the constitution was amended to 1.3.1 (the worker path).
52
+
53
+ Re-validated: all items still pass, and there are no clarification markers.
@@ -0,0 +1,125 @@
1
+ # Contract: Public API Surface
2
+
3
+ What users of the gem see change. Internal classes are listed only where a custom setup (router,
4
+ worker registration, dashboards) touches them.
5
+
6
+ ## Map DSL
7
+
8
+ ```ruby
9
+ map :charge_orders, ChargeOrderReactor do
10
+ source result(:orders)
11
+ argument :order, element(:charge_orders)
12
+
13
+ fan_out batch_size: 200 # batch_size optional; 50 when omitted
14
+ atomic # default; `atomic false` returns per-element outcomes
15
+ collect { |results| ... }
16
+ end
17
+ ```
18
+
19
+ | Declaration | Behavior | Errors / warnings |
20
+ | --- | --- | --- |
21
+ | `atomic` / `atomic true` | Default. Any element failure fails the map, no new element starts, and every completed element is rolled back. | — |
22
+ | `atomic false` | The map completes with every element's outcome (a `ResultEnumerator` of `Success` / `Failure`). Failed elements roll themselves back; succeeded ones are kept. | — |
23
+ | `fail_fast(x)` | Same as `atomic(x)`. | One line on stderr per call site: `[RubyReactor] DEPRECATION: <file:line> <Reactor> map :<name> declares fail_fast. Use atomic (same meaning). Removal no earlier than the next MAJOR.` |
24
+ | `fail_fast` and `atomic` on one map | — | `RubyReactor::Error::ValidationError` at class definition: "map :<name> declares both fail_fast and atomic; declare only atomic". |
25
+ | `fan_out` (no `batch_size`) | No throw enqueues more than 50 element jobs; the next throw fires when the previous throw's last element finishes. | — |
26
+ | `fan_out batch_size: n` | As today; `n` must be a positive Integer. | `ValidationError` as today. |
27
+ | `batch_size n` on an inline map | No effect, as today. | — |
28
+
29
+ ## Rollback behavior (observable)
30
+
31
+ | Situation | Before | After |
32
+ | --- | --- | --- |
33
+ | A fan-out map fails (atomic), or a later step fails, or `Reactor.undo` reaches a completed fan-out map | Every completed element is undone serially, in one process, with all element states loaded at once. | One background job per started element; only completed elements undo anything, the others report `not_needed`. No throw enqueues more than `batch_size`. The run is `rolling_back` until every element has reported, then the steps before the map are undone and the run ends `failed` (or `cancelled` for `undo`). |
34
+ | An inline map is rolled back | In process, all element states loaded at once. | In process, element states read 100 at a time, highest index first. |
35
+ | The final `Failure` | Carries `rollback_failures` with `map_step` / `element_index`. | Identical shape and content. The oracle is the same run with an inline map. |
36
+ | A worker dies mid-element-rollback | Not applicable (inline). | The element resumes after its last recorded undo. The undo that was cut off may run again, so undos must stay idempotent, as the docs already require. |
37
+
38
+ ## Statuses
39
+
40
+ | Status | New? | Terminal? | Meaning |
41
+ | --- | --- | --- | --- |
42
+ | `rolling_back` | **new** | no | A rollback handed off at a fan-out map and is waiting for element rollback jobs. |
43
+
44
+ Dashboards and code that filter status strings see this one new value.
45
+
46
+ ## `Reactor.undo(id)` / `Reactor#undo` / `Reactor.cancel`
47
+
48
+ | Case | Behavior |
49
+ | --- | --- |
50
+ | No fan-out map on the undo path | As today: undone in the caller's process, then `cancel(reason: "Undo triggered")`. |
51
+ | A completed fan-out map on the undo path | The steps after the map are undone in the caller's process. Element rollbacks are dispatched, the run becomes `rolling_back`, and the call returns. A worker finishes the rollback and applies `cancelled`. |
52
+ | The run's `async:` lock is held (a live run or rollback) | Raises `RubyReactor::Lock::AcquisitionError` after waiting up to 5 s. The caller retries. |
53
+ | The run is already `rolling_back` | Raises `RubyReactor::Error::ValidationError`: "rollback already in progress" (FR-027). |
54
+ | `Reactor.cancel(id:, reason:)` on a `rolling_back` run | Raises `RubyReactor::Error::ValidationError`: "rollback in progress; cannot cancel" (FR-027). Cancel on any other status behaves as today. |
55
+
56
+ ## Composition
57
+
58
+ A `compose`d child (at any depth) whose steps include a `fan_out` map now works:
59
+
60
+ - the top-level run is resumed when the map settles, and finishes;
61
+ - rollback travels through the top-level run.
62
+
63
+ Nothing to declare.
64
+
65
+ ## Background workers and routers
66
+
67
+ Both shipped routers gain two methods, and each adapter gains one worker class:
68
+
69
+ | Adapter | Router methods | Worker class |
70
+ | --- | --- | --- |
71
+ | Sidekiq | `perform_map_element_rollback_async(**args)`, `perform_map_element_rollback_in(delay, **args)` | `RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker` |
72
+ | ActiveJob | `perform_map_element_rollback_async(**args)`, `perform_map_element_rollback_in(delay, **args)` | `RubyReactor::Adapters::ActiveJob::MapElementRollbackWorker` |
73
+
74
+ `perform_map_element_rollback_in` is the requeue a job uses when the element's lock is contended
75
+ (R-07).
76
+
77
+ Args (JSON-safe):
78
+
79
+ - `map_id`, `position`, `element_context_id`, `reactor_class_info`;
80
+ - `parent_reactor_class_name`, `step_name`, `batch_size`;
81
+ - `owner_context_id`, `owner_reactor_class_name`;
82
+ - `attempt`: 0 on dispatch, incremented on each contended-lock requeue.
83
+
84
+ The worker runs on the same queue as `MapElementWorker`.
85
+
86
+ `MapCollectorWorker` keeps its class and arguments. A collector job enqueued before the upgrade is
87
+ handled by the new body: it signals the owner instead of resuming the parent.
88
+
89
+ ## Dashboard API
90
+
91
+ `GET /api/reactors/:id`: a hydrated map reference whose map has rollback records gains:
92
+
93
+ ```json
94
+ { "rollback": { "total": 1000, "settled": 350, "outstanding": 650, "failed": 2 } }
95
+ ```
96
+
97
+ `outstanding = total - settled`. The records outlive the run by the durability TTL, so the field is
98
+ present after the rollback finishes too; clients decide from the run's status.
99
+
100
+ GUI:
101
+
102
+ - `rolling_back` appears in amber with an undo icon everywhere statuses are shown or filtered: the
103
+ status badge, the "running" status group, and the live and per-class filters.
104
+ - The step inspector shows "Rolling back 350/1000 (650 outstanding, 2 failed)" **only while the
105
+ run is `rolling_back`**.
106
+
107
+ ## Structured logs
108
+
109
+ key=value, one line each, through `RubyReactor.configuration.logger`:
110
+
111
+ ```text
112
+ event=ruby_reactor.map.rollback.started reactor=<owner class> context_id=<owner id> map_step=<step> total=<n> batch_size=<b>
113
+ event=ruby_reactor.map.rollback.element reactor=<owner class> context_id=<owner id> map_step=<step> index=<i> outcome=<outcome> failures=<count>
114
+ event=ruby_reactor.map.rollback.completed reactor=<owner class> context_id=<owner id> map_step=<step> total=<n> failed=<count>
115
+ ```
116
+
117
+ ## RSpec test surface
118
+
119
+ | Addition | Use |
120
+ | --- | --- |
121
+ | `be_rolling_back` matcher | `expect(subject).to be_rolling_back`, for a run handed off at a fan-out map and not drained yet. |
122
+ | `PendingJob#worker_class` on the ActiveJob helper | Alias of `job_class`, so `pending_async_jobs.first.worker_class` works on both backends for step-wise draining. |
123
+
124
+ `drain_async_jobs` drains `MapElementRollbackWorker` like any other adapter worker. There is no API
125
+ change.
@@ -0,0 +1,157 @@
1
+ # Contract: Map Completion and Distributed Rollback Protocol
2
+
3
+ The sequences the implementation must follow, and the invariants the specs pin. The record names
4
+ are defined in [data-model.md](../data-model.md).
5
+
6
+ ## Invariants
7
+
8
+ - **I-1: single writer.** A context is written only by the execution that owns it: the top-level
9
+ run's worker holding `async:<owner id>`, or the caller's process during `Reactor#undo`, which
10
+ holds the same lock. The collector and element rollback jobs write only map records and element
11
+ contexts.
12
+ - **I-2: the owner resumes once per signal, and never before its hand-off is saved.** Each settle
13
+ signal (`owner_signalled`, `rollback:signalled`) enqueues the owner's `Worker` at most once. A
14
+ rollback signal also requires `rollback:handed_off`, which the handler sets after saving (R-05).
15
+ Extra resumes from the sweepers are harmless because every construct adopts idempotently on
16
+ re-entry.
17
+ - **I-3: no double dispatch.** A map dispatches its elements once (`map_operations[step]` is set
18
+ before dispatch), and starts its rollback once (`rollback:metadata` is created with `HSETNX`).
19
+ - **I-4: step-level saga order.** Steps before a map are undone only after its rollback has
20
+ settled. Within an element, steps are undone in reverse order.
21
+ - **I-5: resume cursor.** An undo entry leaves the stack only after its undo returns. A hand-off
22
+ leaves the map entry, and every entry below it, on the stack.
23
+ - **I-6: save before release.** Every hand-off (a forward `DispatchResult` or a rollback hand-off)
24
+ persists the context before the owner's `async:` lock is released.
25
+ - **I-7: oracle.** For the same reactor and failure, the final `Failure` and the set of undone
26
+ elements are the same whether the map is inline or fan-out. That includes `rollback_failures`
27
+ entries with symbol values, and each unavailable element reported once.
28
+ - **I-8: no terminal status mid-rollback.** A `rolling_back` run cannot be cancelled or undone
29
+ again (FR-027). Only `resume_rollback` moves it to `failed` / `cancelled`.
30
+
31
+ ## S-1: A fan-out map completes (any composition depth)
32
+
33
+ ```text
34
+ owner worker (holds async:<owner>) element jobs collector
35
+ ───────────────────────────────── ──────────── ─────────
36
+ MapStep#run (first time)
37
+ map_operations[step] = map_id
38
+ store owner tree + child snapshot
39
+ initialize map metadata (owner ids, batch_size, atomic)
40
+ Dispatcher: enqueue batch 0 (≤ B)
41
+ return DispatchResult ── executor saves, then releases async: (I-6)
42
+ run, store result slot,
43
+ position kB-1 → next batch,
44
+ counter hits 0 → enqueue collector
45
+ all slots present?
46
+ SET NX owner_signalled
47
+ enqueue Worker(owner)
48
+ Worker(owner) takes async:<owner>
49
+ resume_execution → … → ComposeStep#run → child resume_execution
50
+ MapStep#run (re-entry: map_operations[step] present)
51
+ settled + failed_context_id → return the element's Failure (+ its rollback_failures)
52
+ settled → return Success(collect(ResultEnumerator)); record started
53
+ not settled → return DispatchResult (no dispatch)
54
+ the executor continues: result recorded, undo entry {step, arguments: {}} (R-14)
55
+ ```
56
+
57
+ ## S-2: Rollback reaches a completed fan-out map
58
+
59
+ A later step failed, the map itself failed (atomic), or `Reactor#undo` was called.
60
+
61
+ ```text
62
+ owner (worker, or caller during undo; holds async:<owner>)
63
+ CompensationManager: compensate the failing step, then pop entries newest first …
64
+ MapStep#undo (map_operations[step] present)
65
+ HSETNX rollback:metadata (total = LLEN element_contexts, batch_size, owner ids)
66
+ created → log rollback.started; claim batch 0 (INCRBY offset B); enqueue ≤ B element rollback jobs
67
+ existed → dispatch nothing (I-3)
68
+ settled? (HLEN results == total)
69
+ yes → aggregate (HSCAN failures + started-index check) → return Success / Failure(rollback_failures)
70
+ no → raise RollbackHandedOff
71
+ ▲ unwinds without popping (I-5), through Rescuable sites untouched
72
+ composed child executor / child Executor#undo_all: record child `rollback` (incl. its failures so far), re-raise
73
+ top-level executor: record `rollback`, status rolling_back, SAVE (in rescue, lock held),
74
+ SET rollback:handed_off, then: settled && SET NX rollback:signalled → enqueue Worker(owner);
75
+ release
76
+ Reactor#undo: same, and skip `cancel`
77
+
78
+ element rollback job (position p, attempt a)
79
+ load element context (by id from the job args)
80
+ missing → outcome context_unavailable (index null)
81
+ take map_element:<map>:<index> (wait 2 s)
82
+ held and a < lock_snooze_max_attempts → perform_map_element_rollback_in(delay, attempt: a+1); stop (no outcome)
83
+ held and a = max → outcome element_in_flight
84
+ status completed|aborted → rollback_completed_steps, saving the element after each pop
85
+ → undone | failed(failures)
86
+ otherwise (failed / halted / superseded running) → not_needed
87
+ HSET results[p]; SADD indexes index; log rollback.element
88
+ p ≡ B-1 (mod B) → claim the next batch
89
+ HLEN results == total && EXISTS rollback:handed_off && SET NX rollback:signalled → enqueue Worker(owner)
90
+
91
+ Worker(owner), status rolling_back → Executor#resume_rollback (takes async:<owner>)
92
+ restore rollback_failures from `rollback.failures`
93
+ unless compensated: handle the failing step's compensate again (constructs adopt)
94
+ rollback_completed_steps → MapStep#undo re-entry → settled → aggregate → pop → continue
95
+ finalize: failure → the same Failure as the inline path (I-7) | undo → cancelled
96
+ clear `rollback`, save, release
97
+ ```
98
+
99
+ A second fan-out map lower in the undo stack repeats S-2 from inside `resume_rollback`. The
100
+ status stays `rolling_back`.
101
+
102
+ **Aggregation** (`MapStep#undo` re-entry once settled): `HSCAN` failures and re-symbolize them.
103
+ Then report unavailable elements once: named started-but-unseen indexes when `started` is known,
104
+ otherwise one entry per nil-index outcome (DM §5).
105
+
106
+ **Inline test mode** (all jobs run inside dispatch): every outcome is stored before `MapStep#undo`
107
+ checks settled, and `handed_off` never exists, so no job enqueues the owner. `#undo` aggregates
108
+ synchronously and never raises the hand-off.
109
+
110
+ ## S-3: The failing step is inside a composed child
111
+
112
+ ```text
113
+ root: ComposeStep#run → child executes → child step fails
114
+ child handle_step_failure: compensate the child step, then pop child entries … child MapStep#undo → RollbackHandedOff
115
+ child executor: record child.rollback {trigger: failure, step: <child step>, compensated: true, failure: <child Failure>}, re-raise
116
+ root StepExecutor: passes it through (not Rescuable)
117
+ root executor: root.rollback {trigger: failure, step: <compose step>, compensated: false, failure: <child Failure>}
118
+ status rolling_back, save (the child is embedded), release
119
+ … element rollbacks settle → Worker(root)
120
+ root resume_rollback: compensated false → handle_step_failure(compose step)
121
+ ComposeStep#compensate → child executor undo_all (restores child.rollback failures) → child stack:
122
+ map entry → adopt → pop → the rest of the child → clear child.rollback
123
+ → then the root's own completed steps → finalize with the saved failure
124
+ ```
125
+
126
+ The same path runs when a **manual undo from the root** reaches a composed child: root
127
+ `Reactor#undo` → root `undo_all` → `ComposeStep#undo` → child `undo_all` → child `MapStep#undo`
128
+ hands off. Child `undo_all` records its failures and re-raises, and `Reactor#undo` records the root
129
+ `rollback {trigger: undo}`. On resume, child `undo_all` restores its failures, so none are lost.
130
+
131
+ ## S-4: Inline map rollback (no hand-off)
132
+
133
+ ```text
134
+ MapStep#undo (map_operations[step] absent)
135
+ for each chunk of 100 positions from the tail of element_contexts:
136
+ load contexts → Map::ElementRollback (same class as the job, in process) → collect failures, indexes
137
+ started-index check → Success / Failure(rollback_failures)
138
+ ```
139
+
140
+ ## S-5: Recovery
141
+
142
+ | Lost thing | Detected by | Action |
143
+ | --- | --- | --- |
144
+ | Element rollback job (crash before storing the outcome) | `Map::Sweeper` rollback pass: a position `< offset` with no outcome and no live element lock | Re-enqueue that position. The element resumes after its last saved undo. |
145
+ | Batch trigger (the job at `kB-1` died after storing its outcome) | `Map::Sweeper`: offset `< total` and every claimed position settled | Claim and enqueue the next batch. |
146
+ | Owner resume (after `rollback:signalled` was set) | `RubyReactor::Sweeper`: `rolling_back`, no `async:` lock | Re-enqueue the owner. `resume_rollback` adopts the settled map. |
147
+ | Owner resume (after `owner_signalled` was set) | `RubyReactor::Sweeper`: `running`, no `async:` lock (existing) | Re-enqueue the owner. `MapStep#run` adopts. |
148
+ | Collector | `Map::Sweeper` recollect (existing), with the owner's `async:` lock checked instead of the parent's | Re-trigger the collector. `owner_signalled` keeps it to one resume. |
149
+
150
+ ## S-6: Legacy in-flight work across the upgrade
151
+
152
+ | Payload | Handling |
153
+ | --- | --- |
154
+ | Element / dispatcher / collector / retry job carrying `fail_fast` | `Map::Helpers.normalize_arguments` maps it to `atomic`. |
155
+ | Map metadata without `owner_context_id` (a map started before the upgrade) | Collector falls back to `parent_context_id` / `parent_reactor_class_name`. This is correct for root-level maps. A map inside a composed child started before the upgrade keeps the old hang; documented in CHANGELOG. |
156
+ | Map metadata without `batch_size` | The rollback uses `DEFAULT_BATCH_SIZE`. |
157
+ | Element index with duplicate ids | Safe: an empty undo stack reports `undone`, and a concurrent duplicate waits on the element lock or reports `element_in_flight`. |
@@ -0,0 +1,151 @@
1
+ # Data Model: Distributed Map Rollback and Bounded Fan-out
2
+
3
+ **Feature**: [spec.md](spec.md) | **Research**: [research.md](research.md)
4
+
5
+ All records live in Redis through `Storage::RedisAdapter`. Every key expires with
6
+ `durability_ttl`, like the existing map keys. `<P>` is the class name of the context that ran the
7
+ map (the parent), as in today's map keys; `<map_id>` is `"<parent context id>:<map step>"`.
8
+
9
+ ## 1. Context (changed)
10
+
11
+ | Field | Type | Change | Notes |
12
+ | --- | --- | --- | --- |
13
+ | `status` | string | new value `rolling_back` | A rollback handed off at a fan-out map and has not finished. Non-terminal. |
14
+ | `rollback` | hash or nil | **new** | Where this execution level's rollback stands. Set only while `rolling_back`; cleared when the rollback finishes. A composed child keeps its own copy inside the root blob. |
15
+ | `failure_reason` | Failure | unchanged | Holds the final `Failure` once the rollback finishes. |
16
+ | `undo_stack` | array | unchanged | Already pops one entry per completed undo (008 R-16); this is the rollback's resume cursor. |
17
+ | `map_operations[step]` | string | meaning extended | Present means the map was dispatched: `MapStep#run` adopts instead of dispatching again, and `#undo` rolls back distributed (R-01, R-03). |
18
+
19
+ ### `rollback` hash
20
+
21
+ | Key | Type | Meaning |
22
+ | --- | --- | --- |
23
+ | `trigger` | `"failure"` \| `"undo"` | What started the rollback: a step failure, or `Reactor#undo`. |
24
+ | `step` | string or nil | The failing step (`failure` trigger). For a compose whose child handed off, this is the compose step. |
25
+ | `compensated` | bool | Whether `step`'s own compensate has finished. False when the hand-off came from compensating `step` itself (a failed fan-out map, or a compose whose child is mid-rollback). |
26
+ | `failure` | serialized `Failure` or nil | The triggering failure. `resume_rollback` rebuilds the `StepFailureError` from it. |
27
+ | `failures` | array of rollback-failure entries | Rollback failures collected before the hand-off, restored into `CompensationManager#rollback_failures` on resume. |
28
+
29
+ ### Status transitions
30
+
31
+ ```text
32
+ running ──step failure──▶ (rollback in process) ──hand-off at fan-out map──▶ rolling_back
33
+ rolling_back ──owner resumed, map settled, rest undone──▶ failed (trigger: failure)
34
+ rolling_back ──owner resumed, map settled, rest undone──▶ cancelled (trigger: undo)
35
+ completed | failed | paused ──Reactor.undo──▶ (undo in caller) ──hand-off──▶ rolling_back
36
+ rolling_back ──another hand-off (a second fan-out map lower in the stack)──▶ rolling_back
37
+ ```
38
+
39
+ Rules:
40
+
41
+ - `Worker#perform` on `rolling_back` calls `Executor#resume_rollback` and never resumes forward.
42
+ - `Reactor.undo` on `rolling_back` raises `ValidationError` (FR-027).
43
+ - `Reactor.cancel` on `rolling_back` raises `ValidationError` (FR-027). `cancelled` is terminal and
44
+ would strand the steps before the map.
45
+ - `RubyReactor::Sweeper` re-enqueues `rolling_back` when no `async:` lock is held.
46
+ - A parent waiting on an `async_reactor` child keeps waiting while the child is `rolling_back`,
47
+ because the status is non-terminal.
48
+
49
+ ## 2. Map metadata (changed)
50
+
51
+ Key: `reactor:<P>:map:<map_id>:metadata` (hash, existing).
52
+
53
+ | Field | Change | Notes |
54
+ | --- | --- | --- |
55
+ | `owner_context_id` | **new** | The top-level run: `(context.root_context \|\| context).context_id`. The collector and the rollback resume this run's `Worker` (R-03, R-05). |
56
+ | `owner_reactor_class_name` | **new** | Storage name of the owner's class. |
57
+ | `batch_size` | **new** | The effective batch size: declared, or `Map::DEFAULT_BATCH_SIZE` (50). Read by forward re-dispatch and by the rollback. |
58
+ | `atomic` | **new** | Replaces the never-stored `fail_fast`. Read by `Dispatcher.requeue_index`. |
59
+ | everything else | unchanged | `count`, `strict_ordering`, `reactor_class_info`, `parent_context_id`, `step_name`, the nested-map fields. |
60
+
61
+ ## 3. Map owner signal (new)
62
+
63
+ Key: `reactor:<P>:map:<map_id>:owner_signalled`, a string set with `SET NX` (TTL `durability_ttl`).
64
+
65
+ The collector claims it once the map has settled, then enqueues the owner's `Worker`. Only one
66
+ collector delivery ever enqueues.
67
+
68
+ ## 4. Element-context index (write rule changed)
69
+
70
+ Key: `reactor:<P>:map:<map_id>:element_contexts` (list, existing).
71
+
72
+ - **Forward write**: `ElementExecutor` pushes the context id only for a **fresh** element context,
73
+ not on a parked or retried re-entry (R-06). The inline map path is unchanged: one push per
74
+ element.
75
+ - **Rollback read**: positions counted from the tail (position 0 is the last pushed id), one
76
+ `LRANGE` per claimed batch.
77
+
78
+ ## 5. Map rollback records (new)
79
+
80
+ | Key suffix (`reactor:<P>:map:<map_id>:rollback…`) | Type | Contents |
81
+ | --- | --- | --- |
82
+ | `:metadata` | hash, created with `HSETNX` | `total` (LLEN of the element index at start), `batch_size`, `step_name`, `owner_context_id`, `owner_reactor_class_name`, `reactor_class_info` (element class), `started_at`. Its presence means "a rollback of this map exists" (FR-027). |
83
+ | `:offset` | integer | The next unclaimed position. `INCRBY batch_size` claims a batch (R-02). |
84
+ | `:results` | hash, position → JSON outcome | One element rollback outcome per position (below). `HLEN == total` means settled. |
85
+ | `:indexes` | set of integers | Element indexes that a job saw. Used to name started-but-never-seen indexes (R-09). |
86
+ | `:handed_off` | string, `SET` | Set by the top-level hand-off handler **after** it saved the `rolling_back` context. Until it exists, no job enqueues the owner (R-05). Never set when the map settles synchronously (inline mode). |
87
+ | `:signalled` | string, `SET NX` | Claimed by whichever side observes both "settled" and `handed_off` second: the job storing an outcome, or the handler right after setting `handed_off`. The claimer enqueues the owner's `Worker` once (R-05). |
88
+
89
+ ### Element rollback outcome (value in `:results`)
90
+
91
+ ```json
92
+ { "index": 42, "outcome": "undone", "failures": [] }
93
+ ```
94
+
95
+ | `outcome` | When | Contributes to the final `rollback_failures` |
96
+ | --- | --- | --- |
97
+ | `undone` | The element was `completed` or `aborted` and its undo stack was replayed with no failure, or the stack was already empty (a duplicate). | nothing |
98
+ | `failed` | Replayed, but one or more undos failed. | each entry of `failures`, tagged `map_step`, `element_index` |
99
+ | `not_needed` | The element started but has nothing to undo: `failed` (it rolled itself back), `halted`, or still `running` from a superseded delivery. Skipped elements never started, so they have no position and no outcome. | nothing |
100
+ | `element_in_flight` | The element's `map_element:` lock was still held after the job requeued itself `lock_snooze_max_attempts` times (R-07): a genuinely live forward duplicate. A contended lock before that only requeues the job; no outcome is stored. | one entry, `reason: :element_in_flight` (as 008) |
101
+ | `context_unavailable` | The element context row is gone (expired). `index` is null. | only when `started` is unknown (below) |
102
+
103
+ **Aggregation** (owner, on re-entry once settled; R-09):
104
+
105
+ 1. `HSCAN` outcomes in chunks of 500, and keep the `failures` of `failed` and `element_in_flight`
106
+ outcomes.
107
+ 2. **Re-symbolize** each entry, because JSON round-trips symbols as strings. Keys become symbols;
108
+ the `step`, `kind`, `reason` and `map_step` values become symbols; `element_index` stays an
109
+ Integer or nil. The entries then equal the inline path's (FR-005).
110
+ 3. **Unavailable elements, each reported once**:
111
+ - `started` known: add one `context_unavailable` entry per index in `0...started` missing from
112
+ `:indexes`, and ignore nil-index `context_unavailable` outcomes, which describe the same
113
+ elements;
114
+ - `started` unknown: add one unnamed entry per nil-index `context_unavailable` outcome.
115
+
116
+ This mirrors 008's `report_unavailable`, which uses one form or the other, never both.
117
+
118
+ ### Rollback-failure entry (unchanged shape, 008)
119
+
120
+ ```ruby
121
+ { step: :charge, kind: :undo, key: nil, reason: :raised, message: "...",
122
+ map_step: :charge_orders, element_index: 42 }
123
+ ```
124
+
125
+ ## 6. Element context during rollback (write rule changed)
126
+
127
+ The element rollback job saves the element context after **each** popped undo entry (R-07), under
128
+ the element's `map_element:<map_id>:<index>` lock. A redelivered job therefore resumes after the
129
+ last saved entry. Status stays as it was (`completed` / `aborted`); an empty undo stack is the
130
+ "already undone" signal.
131
+
132
+ ## 7. Constants
133
+
134
+ | Constant | Value | Where |
135
+ | --- | --- | --- |
136
+ | `Map::DEFAULT_BATCH_SIZE` | 50 | Forward dispatch and rollback when no `batch_size` is declared. |
137
+ | `Map::ROLLBACK_CHUNK` | 100 | Elements loaded per read in inline map rollback. |
138
+ | `MapStep::ELEMENT_LOCK_WAIT` | 2 s | Existing. The rollback's wait on an element's liveness lock, per attempt. |
139
+ | `lock_snooze_max_attempts` / `lock_snooze_base_delay` | configuration (existing) | How many times, and how far apart, an element rollback job requeues itself on a contended element lock before reporting `element_in_flight` (R-07). |
140
+ | `Reactor::UNDO_LOCK_WAIT` | 5 s | Manual undo's wait on the run's `async:` lock (R-12). |
141
+
142
+ ## 8. DSL value (renamed)
143
+
144
+ | DSL | Stored as | Default |
145
+ | --- | --- | --- |
146
+ | `atomic(enabled = true)` | `MapStep` input `atomic` | `true` |
147
+ | `fail_fast(enabled = true)` (deprecated) | same input; one warning per call site | — |
148
+ | `fan_out(enabled = true, batch_size: nil)` | `batch_size` input; the effective value is resolved at dispatch | 50 when omitted |
149
+
150
+ Legacy job payload key `fail_fast` is read as `atomic` by `Map::Helpers.normalize_arguments`
151
+ (FR-023).