ruby_reactor 0.8.5 → 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 (65) 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 +58 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +23 -8
  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/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +33 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +72 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  21. data/lib/ruby_reactor/executor/step_executor.rb +3 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +41 -2
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +40 -21
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +3 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/future_improvements.md +18 -30
  64. metadata +21 -3
  65. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4187fd51277447b11381c872a01b46d8e7663323b5b470316d8600ead76906e
4
- data.tar.gz: 540b0c6b94d007a020f3bfbb3ff035eb47469691bf138cd41588e5a6a28ba468
3
+ metadata.gz: c59b0af2a9d34b620e3400cff78e0df3b2e580d1138b37ed5aee1b9e5a78f548
4
+ data.tar.gz: 4e3c2b92b6ae58797beae470b60c69015e15b2b8d6855ce710e71a999d0ae62f
5
5
  SHA512:
6
- metadata.gz: 59c5faa4c755625a2172a5a7613941a82970aa3b52ac170f6c2f12015f41b289700d320d0195b51d4b13e1cf1f9b86e209bc8c59d5dcc84830a2e65c1c49a48d
7
- data.tar.gz: 0be05ffe9539a5ae4c012a8f8e3f26394d27853a86219a7c02d82d9685f7f018bd760c26f749cf34c4899f0c9346c6a54a91ee64e0e274b2f2ad2d12048ee818
6
+ metadata.gz: a3ac367241909b09a5f0451b9278489776e7bedc56b506043c786892b86247a646b297a7c6192be6e5935b5e9a861f36e66e2f370a7c3e0741820cbcaa1ee409
7
+ data.tar.gz: 1fd3ff1f692008747021d11d5356613163a8c7777e33ead22260da4b3959bc5f6ec7fff3cb5928444815298011ff4b0e1e2e10e34c55a9781b98d9ab80893e40
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.5"
2
+ ".": "0.8.6"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/008-rollback-reliability"
2
+ "feature_directory": "specs/009-distributed-map-undo"
3
3
  }
@@ -1,27 +1,28 @@
1
1
  <!--
2
2
  SYNC IMPACT REPORT
3
3
  ==================
4
- Version change: 1.2.1 → 1.3.0 (MINOR: Development Workflow now REQUIRES every generated
5
- plan.md and tasks.md to carry an explicit documentation-update task/gate, with the
6
- requirement propagated into both templates — a new enforcement mechanism, not just
7
- wording)
4
+ Version change: 1.3.0 → 1.3.1 (PATCH: factual correction in Technical Constraints — the worker
5
+ path named a directory that no longer exists; no principle or rule changed)
8
6
 
9
- Modified principles: none (Development Workflow section, not a Core Principle)
7
+ Modified principles: none
8
+
9
+ Modified sections: Technical Constraints — the "Sidekiq" bullet now points at
10
+ `lib/ruby_reactor/adapters/{sidekiq,active_job}/`, where the queue-backend workers actually
11
+ live, and names ActiveJob as the alternative adapter
10
12
 
11
13
  Added sections: none
12
14
 
13
15
  Removed sections: none
14
16
 
15
17
  Templates checked:
16
- - .specify/templates/plan-template.md ✅ Added Constitution Check checklist item:
17
- identify README.md/./documentation impact
18
- - .specify/templates/spec-template.md ✅ No principle-specific content — no edit required
19
- - .specify/templates/tasks-template.md ✅ Polish-phase doc task now explicit and marked
20
- REQUIRED, citing README.md + ./documentation
18
+ - .specify/templates/plan-template.md ✅ No reference to the worker path — no edit required
19
+ - .specify/templates/spec-template.md ✅ No reference — no edit required
20
+ - .specify/templates/tasks-template.md ✅ No reference — no edit required
21
21
  - .specify/templates/checklist-template.md ✅ Generic — no edit required
22
22
  - .specify/extensions.yml ✅ No before/after_constitution hooks registered
23
- - README.md ✅ No principle-specific claims to update
24
- - documentation/*.md ✅ No principle-specific claims to update
23
+ - README.md, documentation/*.md ✅ No reference to the old path
24
+
25
+ Found by /speckit-analyze on specs/009-distributed-map-undo (finding C5), 2026-09-30.
25
26
 
26
27
  Deferred TODOs: none
27
28
  -->
@@ -151,7 +152,9 @@ gaps in the matcher library surface as work instead of as workarounds.
151
152
  ## Technical Constraints
152
153
 
153
154
  - **Ruby**: >= 3.0.0 required. No polyfills for older Rubies.
154
- - **Sidekiq**: Core async dependency. Workers live in `lib/ruby_reactor/sidekiq_workers/`.
155
+ - **Sidekiq**: Core async dependency. ActiveJob is the supported alternative. Queue-backend
156
+ workers and routers live in `lib/ruby_reactor/adapters/{sidekiq,active_job}/`; the
157
+ backend-agnostic worker logic lives in `lib/ruby_reactor/worker.rb` and `lib/ruby_reactor/map/`.
155
158
  - **Redis**: Required for state persistence, locks, semaphores, rate limits, and
156
159
  periods. The gem does NOT manage Redis connections — callers provide them.
157
160
  - **dry-validation**: Input validation DSL. Schema definitions stay inside the
@@ -208,4 +211,4 @@ justified in the `Complexity Tracking` table of the plan.
208
211
  Compliance review: at each MINOR or MAJOR gem release, confirm this constitution
209
212
  still accurately reflects the codebase and update as needed.
210
213
 
211
- **Version**: 1.3.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-11
214
+ **Version**: 1.3.1 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-30
data/CHANGELOG.md CHANGED
@@ -241,6 +241,33 @@ Every breaking or shape-changing item of the rollback work, with what to change.
241
241
 
242
242
  ### Features
243
243
 
244
+ * **`fan_out` without `batch_size` is back-pressured: at most 50 element jobs per throw**
245
+ (`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback. Before, every element was
246
+ enqueued at once. A map of more than 50 elements with no declared `batch_size` now runs in
247
+ throws of 50, which can lower its peak throughput: declare a larger `batch_size` to keep more
248
+ element jobs in flight.
249
+ * **Distributed rollback of fan-out maps.** A `fan_out` map that must be rolled back (it failed,
250
+ a later step failed, or `Reactor.undo`) now rolls back the way it ran: one
251
+ `MapElementRollbackWorker` job per started element, enqueued `batch_size` per throw with the
252
+ forward run's back pressure, each loading one element's state. Before, one process loaded every
253
+ element and undid them one after another. The run ends with the same `Failure` as an inline map's
254
+ rollback. An element's rollback saves after every undone step, so a worker killed mid-rollback
255
+ resumes after the last one (the undo that was cut off runs again: keep undos idempotent). Both
256
+ sweepers recover lost rollback jobs and resumes. Inline maps keep rolling back in process, now
257
+ reading element states 100 at a time.
258
+ * **`rolling_back` execution status.** A run whose rollback handed off at a fan-out map, until the
259
+ last element reports; then a worker undoes the steps before the map and the run ends `failed`
260
+ (`cancelled` for an undo). `Reactor.cancel` and `Reactor.undo` raise
261
+ `RubyReactor::Error::ValidationError` on it. The dashboard shows it (amber) in every status
262
+ surface, with the map's rollback progress (`total`, `settled`, `outstanding`, `failed`).
263
+ * **Router methods `perform_map_element_rollback_async` / `perform_map_element_rollback_in`** on both
264
+ shipped routers, with a `MapElementRollbackWorker` per adapter, on the queue `MapElementWorker`
265
+ uses. A custom router needs both methods for fan-out map rollback.
266
+ * **`be_rolling_back` matcher.** `pending_async_jobs` / `drain_async_jobs` work on the ActiveJob
267
+ test adapter too, and `worker_class` names a pending job's class on both backends.
268
+ * **`Reactor.undo` holds the run's context lock**, and raises `RubyReactor::Lock::AcquisitionError`
269
+ while a live run or rollback holds it.
270
+
244
271
  * **`aborted` execution status.** A run in the caller's process that an interruption
245
272
  (`SignalException` including `Interrupt`, `SystemExit`, `NoMemoryError`, or an enclosing
246
273
  `Timeout.timeout`) cuts short runs no rollback code: the exception reaches the caller unchanged,
@@ -302,6 +329,11 @@ Every breaking or shape-changing item of the rollback work, with what to change.
302
329
 
303
330
  ### Deprecations
304
331
 
332
+ * **`fail_fast` on a `map` is now `atomic`** (same meaning: every element succeeds, or none is
333
+ kept). `fail_fast` keeps working and prints one deprecation line per declaration site naming
334
+ `atomic`; it will be removed no earlier than the next major version. Declaring both on one map
335
+ raises `RubyReactor::Error::ValidationError`. Element jobs enqueued before the upgrade, which
336
+ carry `fail_fast`, keep the policy they were enqueued with.
305
337
  * Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
306
338
  for steps without a contract, and print one deprecation notice per declaration site. Move them
307
339
  to `input` / `validate_inputs` on the step class, or into an `inputs do ... end` block for an
@@ -310,6 +342,25 @@ Every breaking or shape-changing item of the rollback work, with what to change.
310
342
 
311
343
  ### Bug Fixes
312
344
 
345
+ * **A fan-out map inside a composed reactor no longer leaves the root running forever.** The map's
346
+ completion resumed the child as a run of its own, and nothing resumed the root. Now the root
347
+ resumes and finishes, and a failure of the map or of any later step rolls back through the root.
348
+ A map that was already running inside a composed child when you upgraded keeps the old
349
+ behavior: its metadata names no owner run.
350
+ * A fan-out map's completion now resumes the run through its own Worker, which adopts the map's
351
+ outcome, instead of the collector job resuming the run itself. This costs one extra job per
352
+ fan-out map completion, and removes the collector's write to the run's context.
353
+ * A map element re-dispatched by `RubyReactor::Map::Sweeper` kept neither `batch_size` nor
354
+ `fail_fast`: the map metadata never stored them, so the element stopped triggering later
355
+ batches and was no longer atomic. Both are stored now.
356
+ * `Executor#resume_execution` saves the context before it releases the run's context lock. Released
357
+ first, a worker resuming the same run could load the pre-save state and have its progress
358
+ overwritten by the late save.
359
+ * A map's undo record no longer stores the map's resolved source in the parent context.
360
+ * A step that returns another unit's `Failure` (a map adopting a failed element, a composed child)
361
+ keeps that Failure's `exception_class` on the run's final Failure, also when the step's retries
362
+ are exhausted. Before, an inline map whose element raised reported no `exception_class`.
363
+
313
364
  * `Reactor.continue` accepts a resume only while the reactor is paused at an interrupt. A resume
314
365
  that arrives while the reactor is executing or rolling back, or after it finished or was
315
366
  aborted, raises `RubyReactor::Error::ValidationError` and changes nothing. Before, it resumed the
@@ -393,6 +444,13 @@ Every breaking or shape-changing item of the rollback work, with what to change.
393
444
  * Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
394
445
  within the lock's `ttl`; a lapsed lock is acquired again.
395
446
 
447
+ ## [0.8.6](https://github.com/arturictus/ruby_reactor/compare/v0.8.5...v0.8.6) (2026-10-08)
448
+
449
+
450
+ ### Miscellaneous Chores
451
+
452
+ * release commit ([#68](https://github.com/arturictus/ruby_reactor/issues/68)) ([9fcb14c](https://github.com/arturictus/ruby_reactor/commit/9fcb14cb8aa6a2c9e48d3327cb8eb279022f59c0))
453
+
396
454
  ## [0.8.5](https://github.com/arturictus/ruby_reactor/compare/v0.8.4...v0.8.5) (2026-09-30)
397
455
 
398
456
 
data/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- SPECKIT START -->
2
2
  For additional context about technologies to be used, project structure,
3
3
  shell commands, and other important information, read the current plan
4
- at specs/008-rollback-reliability/plan.md
4
+ at specs/009-distributed-map-undo/plan.md
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -634,7 +634,9 @@ That's all that's required: `start_sweeper!` is idempotent (safe to call on ever
634
634
  boot — duplicate kicks collapse to one chain), runs both the top-level reactor
635
635
  sweeper and the map sweeper every `config.sweeper_interval` seconds, and stops if
636
636
  you set `config.sweeper_enabled = false`. The interval is your recovery-latency
637
- bound.
637
+ bound. Both cover a fan-out map's distributed rollback too: the map sweeper
638
+ re-dispatches a lost element rollback job, and the reactor sweeper re-enqueues a
639
+ `rolling_back` run (like a `running` one) whose resume was lost.
638
640
 
639
641
  > **Sidekiq Enterprise `super_fetch` compatibility:** the chain is safe under
640
642
  > reliable fetch. `super_fetch` re-runs a job whose worker died mid-execution, so
@@ -893,19 +895,23 @@ class DataProcessingReactor < RubyReactor::Reactor
893
895
  end
894
896
  ```
895
897
 
896
- A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`).
898
+ A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`), and inside a `compose`d child, where the top-level run is the one that resumes.
897
899
 
898
900
  By using `fan_out` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
899
901
 
900
902
  **Rollback.** A map rolls back like a composed reactor, with no map-level rollback DSL: the `undo`s
901
903
  already declared on the element reactor's steps are each element's rollback. When the map fails
902
- (an element fails under `fail_fast`, or `collect` raises), and when a later step fails or the run is
903
- undone manually, every element that completed is rolled back, highest index first, in inline and
904
- fan-out mode alike. A fail-fast fan-out map lets elements already in flight finish before it reports
905
- the failure. Make element `undo`s idempotent. See
904
+ (an element fails in an `atomic` map, the default, or `collect` raises), and when a later step fails or the run is
905
+ undone manually, every element that completed is rolled back, newest-started first, and the run ends
906
+ with the same `Failure` in inline and fan-out mode alike. A map rolls back the way it ran: an inline
907
+ map in process, reading element states 100 at a time; a fan-out map with **one rollback job per
908
+ started element**, `batch_size` per throw, the same back pressure as its forward run. Until the last
909
+ element reports, the run is `rolling_back`, and only then are the steps before the map undone. A
910
+ failing atomic fan-out map lets elements already in flight finish before it reports the failure. Make
911
+ element `undo`s idempotent: one cut off by a killed worker runs again. See
906
912
  [Rollback](documentation/data_pipelines.md#rollback).
907
913
 
908
- `batch_size` is optional: with `fan_out` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
914
+ `batch_size` is optional: with `fan_out` alone, no throw enqueues more than 50 element jobs (`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback, and the outcomes are aggregated into a `ResultEnumerator`. Set `batch_size` to change the throw size. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
909
915
 
910
916
  > **Breaking change:** `async true` inside a `map` block has been **removed** — it read like `async_step`/`async_reactor`, which dispatch independent units the reactor does not stop for. It now raises at class-definition time. The exact replacement is `fan_out` (`fan_out batch_size: N`).
911
917
 
@@ -1447,6 +1453,15 @@ rollback code. A run in the caller's process is stored with status `aborted`,
1447
1453
  the steps not yet undone still outstanding; `MyReactor.undo(id)` rolls them
1448
1454
  back. A run in a worker is redelivered instead.
1449
1455
 
1456
+ `MyReactor.undo(id)` holds the run's context lock while it undoes, and raises
1457
+ `RubyReactor::Lock::AcquisitionError` if a live run or rollback holds it (retry
1458
+ later). When the undo reaches a completed fan-out map, it undoes the steps after
1459
+ the map, dispatches the map's element rollbacks and returns with the run
1460
+ `rolling_back`; a worker finishes the rest and marks the run `cancelled`. While a
1461
+ run is `rolling_back`, both `undo` and `cancel` raise
1462
+ `RubyReactor::Error::ValidationError`: cancelling would mark it finished and
1463
+ strand the steps before the map.
1464
+
1450
1465
  ### Using Pre-defined Schemas
1451
1466
 
1452
1467
  You can use existing dry-validation schemas:
@@ -1518,7 +1533,7 @@ Explore the ways to move work off the calling process: background execution (`ba
1518
1533
  Discover how to build complex, modular workflows by composing reactors within other reactors. This guide covers inline composition, class-based composition, and how to manage dependencies between composed workflows.
1519
1534
 
1520
1535
  ### [Data Pipelines](documentation/data_pipelines.md)
1521
- Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
1536
+ Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like atomic maps vs. partial result collection.
1522
1537
 
1523
1538
  ### [Retry Configuration](documentation/retry_configuration.md)
1524
1539
  Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to declare retries on a step class or inline step, and how background retries work without blocking workers.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ # Rolls back one element of a fan-out map (009 R-07).
9
+ class MapElementRollbackWorker < ::ActiveJob::Base
10
+ extend Compat
11
+
12
+ queue_as { RubyReactor.configuration.queue_name }
13
+
14
+ def perform(arguments)
15
+ RubyReactor::Map::ElementRollback.perform(arguments)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -56,7 +56,7 @@ module RubyReactor
56
56
  # rubocop:disable Metrics/ParameterLists
57
57
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
58
58
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
59
- step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
59
+ step_name:, batch_size: nil, serialized_context: nil, atomic: nil)
60
60
  job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
61
61
  {
62
62
  "map_id" => map_id,
@@ -70,7 +70,7 @@ module RubyReactor
70
70
  "step_name" => step_name,
71
71
  "batch_size" => batch_size,
72
72
  "serialized_context" => serialized_context,
73
- "fail_fast" => fail_fast
73
+ "atomic" => atomic
74
74
  }
75
75
  )
76
76
  RubyReactor::DispatchResult.new(job_id: job_id)
@@ -79,7 +79,7 @@ module RubyReactor
79
79
  def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
80
80
  reactor_class_info:, strict_ordering:, parent_context_id:,
81
81
  parent_reactor_class_name:, step_name:, batch_size: nil,
82
- serialized_context: nil, fail_fast: nil)
82
+ serialized_context: nil, atomic: nil)
83
83
  job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
84
84
  delay,
85
85
  {
@@ -94,7 +94,7 @@ module RubyReactor
94
94
  "step_name" => step_name,
95
95
  "batch_size" => batch_size,
96
96
  "serialized_context" => serialized_context,
97
- "fail_fast" => fail_fast
97
+ "atomic" => atomic
98
98
  }
99
99
  )
100
100
  # Return an DispatchResult so RetryManager#handle_async_retry recognises the
@@ -103,6 +103,34 @@ module RubyReactor
103
103
  end
104
104
  # rubocop:enable Metrics/ParameterLists
105
105
 
106
+ # One element's rollback in a fan-out map's distributed rollback (009
107
+ # R-07); the `_in` form is its requeue on a contended element lock.
108
+ def self.perform_map_element_rollback_async(**args)
109
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementRollbackWorker.perform_async(map_element_rollback_payload(**args))
110
+ RubyReactor::DispatchResult.new(job_id: job_id)
111
+ end
112
+
113
+ def self.perform_map_element_rollback_in(delay, **args)
114
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementRollbackWorker.perform_in(
115
+ delay, map_element_rollback_payload(**args)
116
+ )
117
+ RubyReactor::DispatchResult.new(job_id: job_id)
118
+ end
119
+
120
+ # rubocop:disable Metrics/ParameterLists
121
+ def self.map_element_rollback_payload(map_id:, position:, element_context_id:, reactor_class_info:,
122
+ parent_reactor_class_name:, step_name:, batch_size:, owner_context_id:,
123
+ owner_reactor_class_name:, attempt: 0)
124
+ {
125
+ "map_id" => map_id, "position" => position, "element_context_id" => element_context_id,
126
+ "reactor_class_info" => reactor_class_info, "parent_reactor_class_name" => parent_reactor_class_name,
127
+ "step_name" => step_name, "batch_size" => batch_size, "owner_context_id" => owner_context_id,
128
+ "owner_reactor_class_name" => owner_reactor_class_name, "attempt" => attempt
129
+ }
130
+ end
131
+ # rubocop:enable Metrics/ParameterLists
132
+ private_class_method :map_element_rollback_payload
133
+
106
134
  # rubocop:disable Metrics/ParameterLists
107
135
  def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
108
136
  strict_ordering:, timeout:)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ # Rolls back one element of a fan-out map (009 R-07).
7
+ class MapElementRollbackWorker
8
+ include ::Sidekiq::Worker
9
+
10
+ def perform(arguments)
11
+ RubyReactor::Map::ElementRollback.perform(arguments)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -58,7 +58,7 @@ module RubyReactor
58
58
  # rubocop:disable Metrics/ParameterLists
59
59
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
60
60
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
61
- step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
61
+ step_name:, batch_size: nil, serialized_context: nil, atomic: nil)
62
62
  job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
63
63
  {
64
64
  "map_id" => map_id,
@@ -72,7 +72,7 @@ module RubyReactor
72
72
  "step_name" => step_name,
73
73
  "batch_size" => batch_size,
74
74
  "serialized_context" => serialized_context,
75
- "fail_fast" => fail_fast
75
+ "atomic" => atomic
76
76
  }
77
77
  )
78
78
  RubyReactor::DispatchResult.new(job_id: job_id)
@@ -81,7 +81,7 @@ module RubyReactor
81
81
  def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
82
82
  reactor_class_info:, strict_ordering:, parent_context_id:,
83
83
  parent_reactor_class_name:, step_name:, batch_size: nil,
84
- serialized_context: nil, fail_fast: nil)
84
+ serialized_context: nil, atomic: nil)
85
85
  job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
86
86
  delay,
87
87
  {
@@ -96,7 +96,7 @@ module RubyReactor
96
96
  "step_name" => step_name,
97
97
  "batch_size" => batch_size,
98
98
  "serialized_context" => serialized_context,
99
- "fail_fast" => fail_fast
99
+ "atomic" => atomic
100
100
  }
101
101
  )
102
102
  # Return an DispatchResult so RetryManager#handle_async_retry recognises the
@@ -105,6 +105,34 @@ module RubyReactor
105
105
  end
106
106
  # rubocop:enable Metrics/ParameterLists
107
107
 
108
+ # One element's rollback in a fan-out map's distributed rollback (009
109
+ # R-07); the `_in` form is its requeue on a contended element lock.
110
+ def self.perform_map_element_rollback_async(**args)
111
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_async(map_element_rollback_payload(**args))
112
+ RubyReactor::DispatchResult.new(job_id: job_id)
113
+ end
114
+
115
+ def self.perform_map_element_rollback_in(delay, **args)
116
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_in(
117
+ delay, map_element_rollback_payload(**args)
118
+ )
119
+ RubyReactor::DispatchResult.new(job_id: job_id)
120
+ end
121
+
122
+ # rubocop:disable Metrics/ParameterLists
123
+ def self.map_element_rollback_payload(map_id:, position:, element_context_id:, reactor_class_info:,
124
+ parent_reactor_class_name:, step_name:, batch_size:, owner_context_id:,
125
+ owner_reactor_class_name:, attempt: 0)
126
+ {
127
+ "map_id" => map_id, "position" => position, "element_context_id" => element_context_id,
128
+ "reactor_class_info" => reactor_class_info, "parent_reactor_class_name" => parent_reactor_class_name,
129
+ "step_name" => step_name, "batch_size" => batch_size, "owner_context_id" => owner_context_id,
130
+ "owner_reactor_class_name" => owner_reactor_class_name, "attempt" => attempt
131
+ }
132
+ end
133
+ # rubocop:enable Metrics/ParameterLists
134
+ private_class_method :map_element_rollback_payload
135
+
108
136
  # rubocop:disable Metrics/ParameterLists
109
137
  def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
110
138
  strict_ordering:, timeout:)
@@ -29,6 +29,12 @@ module RubyReactor
29
29
  :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
30
30
  :middlewares
31
31
 
32
+ # Where this execution level's rollback stands while it is `rolling_back`
33
+ # (009 DM §1): `trigger`, `step`, `compensated`, `failure`, `failures`.
34
+ # Kept JSON-safe (string keys; `failure` / `failures` already serialized),
35
+ # so it round-trips unchanged. Nil otherwise.
36
+ attr_accessor :rollback
37
+
32
38
  # Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
33
39
  # and `deserialize_from_retry` below — confirmed by
34
40
  # reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
@@ -76,6 +82,10 @@ module RubyReactor
76
82
  @status.to_s == "failed"
77
83
  end
78
84
 
85
+ def rolling_back?
86
+ @status.to_s == "rolling_back"
87
+ end
88
+
79
89
  def get_input(name, path = nil)
80
90
  value = Utils::FetchIndifferent.call(@inputs, name)
81
91
  return nil if value.nil?
@@ -149,7 +159,8 @@ module RubyReactor
149
159
  reactor_class: @reactor_class,
150
160
  execution_trace: @execution_trace,
151
161
  status: @status,
152
- failure_reason: @failure_reason
162
+ failure_reason: @failure_reason,
163
+ rollback: @rollback
153
164
  }
154
165
  end
155
166
 
@@ -175,6 +186,7 @@ module RubyReactor
175
186
  cancellation_reason: @cancellation_reason,
176
187
  status: @status,
177
188
  failure_reason: ContextSerializer.serialize_value(@failure_reason),
189
+ rollback: @rollback,
178
190
  parent_context_id: @parent_context&.context_id || @parent_context_id,
179
191
  retried_from_id: @retried_from_id
180
192
  }
@@ -200,6 +212,7 @@ module RubyReactor
200
212
  context.cancellation_reason = data["cancellation_reason"]
201
213
  context.status = data["status"] || "pending"
202
214
  context.failure_reason = ContextSerializer.deserialize_value(data["failure_reason"])
215
+ context.rollback = data["rollback"]
203
216
  context.parent_context_id = data["parent_context_id"]
204
217
  context.retried_from_id = data["retried_from_id"]
205
218
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # Definition-time warnings for the DSL builders, printed once per
6
+ # declaration site for the process. The including builder sets `@reactor`.
7
+ module DefinitionWarnings
8
+ def self.deprecation_sites
9
+ @deprecation_sites ||= Set.new
10
+ end
11
+
12
+ private
13
+
14
+ def warn_deprecation(site, message)
15
+ warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
16
+ end
17
+
18
+ def warn_definition(site, prefix, message)
19
+ location = "#{site.path}:#{site.lineno}"
20
+ return unless DefinitionWarnings.deprecation_sites.add?(location)
21
+
22
+ warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
23
+ end
24
+
25
+ def reactor_label
26
+ @reactor&.name || @reactor.inspect
27
+ end
28
+ end
29
+ end
30
+ end
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  module Dsl
5
5
  class MapBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
+ include RubyReactor::Dsl::DefinitionWarnings
7
8
 
8
9
  attr_accessor :name, :mapped_reactor_class, :argument_mappings, :source_enumerable
9
10
 
@@ -25,7 +26,7 @@ module RubyReactor
25
26
  @batch_size = nil
26
27
  @source_enumerable = nil
27
28
  @collect_block = nil
28
- @fail_fast = true # Default: stop on first error
29
+ @atomic = true # Default: every element succeeds, or none is kept
29
30
  end
30
31
 
31
32
  def argument(mapped_input_name, source)
@@ -41,10 +42,10 @@ module RubyReactor
41
42
  end
42
43
 
43
44
  # Run every element as its own background job. `batch_size` caps how many
44
- # element jobs are enqueued at a time (back pressure); without it the whole
45
- # source fans out at once. A fan-out map is a hand-off point: the reactor
46
- # stops at the map and resumes in a worker once the collector has every
47
- # element's outcome.
45
+ # element jobs one throw enqueues (back pressure), forward and rollback;
46
+ # without it, `Map::DEFAULT_BATCH_SIZE` (50) does. A fan-out map is a
47
+ # hand-off point: the reactor stops at the map and resumes in a worker
48
+ # once the collector has every element's outcome.
48
49
  def fan_out(enabled = true, batch_size: nil)
49
50
  @fan_out = enabled
50
51
  self.batch_size(batch_size) unless batch_size.nil?
@@ -83,11 +84,30 @@ module RubyReactor
83
84
  @collect_block = block
84
85
  end
85
86
 
87
+ # On (the default), the map succeeds only if every element does: the
88
+ # first element failure fails it, no new element starts, and every
89
+ # completed element is rolled back. Off, it completes with every
90
+ # element's outcome; a failed element rolls itself back, the others are
91
+ # kept.
92
+ def atomic(enabled = true)
93
+ @atomic_declared = true
94
+ @atomic = enabled
95
+ end
96
+
97
+ # The old name for `atomic` (009 R-11), which read as "stop early".
86
98
  def fail_fast(enabled = true)
87
- @fail_fast = enabled
99
+ @fail_fast_site = caller_locations(1, 1).first
100
+ @atomic = enabled
101
+ warn_deprecation(@fail_fast_site, "map :#{@name} declares fail_fast. Use atomic (same meaning).")
88
102
  end
89
103
 
90
104
  def build
105
+ if @atomic_declared && @fail_fast_site
106
+ raise RubyReactor::Error::ValidationError.new(
107
+ "map :#{@name} declares both fail_fast and atomic; declare only atomic", step: @name
108
+ )
109
+ end
110
+
91
111
  dependencies = extract_dependencies_from_mappings
92
112
  dependencies << @source_enumerable.step_name if @source_enumerable.is_a?(RubyReactor::Template::Result)
93
113
 
@@ -123,7 +143,7 @@ module RubyReactor
123
143
  strict_ordering: { source: RubyReactor::Template::Value.new(@strict_ordering) },
124
144
  batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
125
145
  collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
126
- fail_fast: { source: RubyReactor::Template::Value.new(@fail_fast) },
146
+ atomic: { source: RubyReactor::Template::Value.new(@atomic) },
127
147
  fan_out: { source: RubyReactor::Template::Value.new(@fan_out) }
128
148
  },
129
149
  run_block: nil,
@@ -7,6 +7,7 @@ module RubyReactor
7
7
  include RubyReactor::Dsl::ValidationHelpers
8
8
  include RubyReactor::Dsl::Lockable::ClassMethods
9
9
  include RubyReactor::Dsl::Retryable
10
+ include RubyReactor::Dsl::DefinitionWarnings
10
11
 
11
12
  COORDINATION_MACROS = {
12
13
  lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
@@ -34,11 +35,6 @@ module RubyReactor
34
35
  @rule_sites = []
35
36
  end
36
37
 
37
- # Deprecation notices print once per declaration site for the process.
38
- def self.deprecation_sites
39
- @deprecation_sites ||= Set.new
40
- end
41
-
42
38
  def argument(name, source, type = nil, transform: nil, **predicates)
43
39
  @arguments[name] = {
44
40
  source: source,
@@ -244,25 +240,9 @@ module RubyReactor
244
240
  "the step instead (`validate_inputs` in #{target}).")
245
241
  end
246
242
 
247
- def warn_deprecation(site, message)
248
- warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
249
- end
250
-
251
- # A definition-time warning, printed once per declaration site.
252
- def warn_definition(site, prefix, message)
253
- location = "#{site.path}:#{site.lineno}"
254
- return unless StepBuilder.deprecation_sites.add?(location)
255
-
256
- warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
257
- end
258
-
259
243
  def owned_contract
260
244
  @inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
261
245
  end
262
-
263
- def reactor_label
264
- @reactor&.name || @reactor.inspect
265
- end
266
246
  end
267
247
 
268
248
  class StepConfig
@@ -450,6 +430,13 @@ module RubyReactor
450
430
  rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
451
431
  end
452
432
 
433
+ # What the undo record keeps of the step's resolved arguments: all of
434
+ # them, unless the step reads its rollback state elsewhere (a map reads
435
+ # its declaration and the map records, 009 R-14).
436
+ def rollback_arguments(resolved)
437
+ has_impl? && impl.respond_to?(:rollback_arguments) ? impl.rollback_arguments(resolved) : resolved
438
+ end
439
+
453
440
  def has_impl?
454
441
  !@impl.nil?
455
442
  end