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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +58 -0
- data/CLAUDE.md +1 -1
- data/README.md +23 -8
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +33 -9
- data/lib/ruby_reactor/executor/result_handler.rb +72 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_executor.rb +3 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +41 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +40 -21
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +3 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/future_improvements.md +18 -30
- metadata +21 -3
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c59b0af2a9d34b620e3400cff78e0df3b2e580d1138b37ed5aee1b9e5a78f548
|
|
4
|
+
data.tar.gz: 4e3c2b92b6ae58797beae470b60c69015e15b2b8d6855ce710e71a999d0ae62f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3ac367241909b09a5f0451b9278489776e7bedc56b506043c786892b86247a646b297a7c6192be6e5935b5e9a861f36e66e2f370a7c3e0741820cbcaa1ee409
|
|
7
|
+
data.tar.gz: 1fd3ff1f692008747021d11d5356613163a8c7777e33ead22260da4b3959bc5f6ec7fff3cb5928444815298011ff4b0e1e2e10e34c55a9781b98d9ab80893e40
|
data/.specify/feature.json
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
SYNC IMPACT REPORT
|
|
3
3
|
==================
|
|
4
|
-
Version change: 1.
|
|
5
|
-
|
|
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
|
|
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 ✅
|
|
17
|
-
|
|
18
|
-
- .specify/templates/
|
|
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
|
|
24
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
|
903
|
-
undone manually, every element that completed is rolled back,
|
|
904
|
-
fan-out mode alike. A
|
|
905
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
"
|
|
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:)
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
|
45
|
-
#
|
|
46
|
-
# stops at the map and resumes in a worker
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|