ruby_reactor 0.6.0 → 0.7.1
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/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +79 -12
- data/.specify/templates/tasks-template.md +7 -0
- data/CHANGELOG.md +125 -1
- data/CLAUDE.md +5 -0
- data/README.md +155 -25
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +28 -0
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +3 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
- data/lib/ruby_reactor/executor/result_handler.rb +47 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
- data/lib/ruby_reactor/executor/step_executor.rb +77 -24
- data/lib/ruby_reactor/executor.rb +179 -23
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +13 -2
- data/lib/ruby_reactor/map/helpers.rb +10 -8
- data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +8 -5
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +6 -2
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +26 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +117 -22
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +53 -3
- data/lib/ruby_reactor.rb +80 -10
- data/specs/active_job.md +1 -1
- metadata +23 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
data/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,135 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* reactor signal semantics ([#52](https://github.com/arturictus/ruby_reactor/issues/52)) ([9147d06](https://github.com/arturictus/ruby_reactor/commit/9147d066da603c0afc1af6536b6afac7e165a034))
|
|
9
|
+
|
|
10
|
+
## [0.7.0](https://github.com/arturictus/ruby_reactor/compare/v0.6.0...v0.7.0) (2026-09-08)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### ⚠ BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* `async` inside a `step` or `compose` block is removed. It was ambiguous — only the first flagged step in a reactor ever took effect and the rest were silently ignored — so it now raises `Error::DeprecatedDslError` at class-definition time, naming its replacements.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* Async steps and reactors, background DSL instead of `async` ([#46](https://github.com/arturictus/ruby_reactor/issues/46)) ([9326433](https://github.com/arturictus/ruby_reactor/commit/93264331ad69f648f30d9e365048705cd9b0d82d))
|
|
20
|
+
|
|
3
21
|
## [0.6.0](https://github.com/arturictus/ruby_reactor/compare/v0.5.4...v0.6.0) (2026-08-16)
|
|
4
22
|
|
|
5
23
|
|
|
6
24
|
### Features
|
|
7
25
|
|
|
8
26
|
* ActiveJob Support ([#42](https://github.com/arturictus/ruby_reactor/issues/42)) ([0fb6dc4](https://github.com/arturictus/ruby_reactor/commit/0fb6dc4ae4b16c34e0aa33a66f95df3e14ae0807))
|
|
27
|
+
## Unreleased
|
|
28
|
+
|
|
29
|
+
### ⚠ BREAKING CHANGES
|
|
30
|
+
|
|
31
|
+
* **The per-step `async` flag is removed.** `async true` inside a `step` **or a
|
|
32
|
+
`compose`** block now raises `RubyReactor::Error::DeprecatedDslError` (a
|
|
33
|
+
subclass of `Error::ValidationError`) at reactor **class-definition** time.
|
|
34
|
+
|
|
35
|
+
It was ambiguous: only the **first** flagged step in a reactor ever took
|
|
36
|
+
effect, and every later one was silently ignored. A reactor now declares one
|
|
37
|
+
hand-off point instead, nameable from either side:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Before — only the first `async true` did anything
|
|
41
|
+
step :process_payment do
|
|
42
|
+
async true
|
|
43
|
+
# ...
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# After — the exact equivalent
|
|
47
|
+
step :process_payment do
|
|
48
|
+
# ...
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
background before: :process_payment
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Migration:** for a flagged step `:x`, use `background before: :x`. That
|
|
55
|
+
reproduces the old semantics precisely — `:x` and everything after it move to
|
|
56
|
+
the worker — without having to identify a predecessor step. The same applies to
|
|
57
|
+
`async` inside a `compose` block. `after: :x` is the other side of the same cut
|
|
58
|
+
point (`:x` stays in the calling process); the two coincide in a linear chain
|
|
59
|
+
but pin different steps in a DAG.
|
|
60
|
+
|
|
61
|
+
Not affected: the map-internal `async` element dispatch option
|
|
62
|
+
(`map :items do async true, batch_size: 2 end`) — a different mechanism that
|
|
63
|
+
keeps working unchanged.
|
|
64
|
+
|
|
65
|
+
One behavior change falls out of "exactly one hand-off point per reactor":
|
|
66
|
+
resuming a reactor past its hand-off point now finishes in the resuming
|
|
67
|
+
process, where the old per-step flag would queue a second, undeclared hand-off.
|
|
68
|
+
|
|
69
|
+
* `RubyReactor::Dsl::StepConfig#async?` and the per-step `async:` field in the
|
|
70
|
+
dashboard's `Web::API` step structure are gone. The dashboard now exposes the
|
|
71
|
+
reactor's normalized hand-off point once, as `background_handoff`.
|
|
72
|
+
|
|
73
|
+
* **Whole-reactor `async true` is removed.** It named the same hand-off idea as
|
|
74
|
+
`background`, with a different word, and read confusingly next to the new
|
|
75
|
+
`async_step` / `async_reactor` step macros (both "async" + "reactor", meaning
|
|
76
|
+
different things). Using it now raises `RubyReactor::Error::DeprecatedDslError`
|
|
77
|
+
at class-definition time.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Before
|
|
81
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
82
|
+
async true
|
|
83
|
+
# ...
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# After — identical behavior, including validating inputs inside the worker
|
|
87
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
88
|
+
background all: true
|
|
89
|
+
# ...
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Migration:** replace `async true` with `background all: true`. `async?` (the
|
|
94
|
+
reader) is unchanged and still answers the same question.
|
|
95
|
+
|
|
96
|
+
* **`RubyReactor::AsyncResult` is renamed to `RubyReactor::DispatchResult`.** The
|
|
97
|
+
old name no longer fit: the class is the sentinel returned whenever a step's
|
|
98
|
+
work is handed to a worker and not yet resolved, produced alike by
|
|
99
|
+
`background`, `async_step`, `async_reactor`, and map's async element dispatch
|
|
100
|
+
— not specific to "async" as a concept.
|
|
101
|
+
|
|
102
|
+
**Migration:** replace any `RubyReactor::AsyncResult` reference (e.g. in a
|
|
103
|
+
custom `async_router`, or `result.is_a?(RubyReactor::AsyncResult)` checks)
|
|
104
|
+
with `RubyReactor::DispatchResult`.
|
|
105
|
+
|
|
106
|
+
### Features
|
|
107
|
+
|
|
108
|
+
* **`background after:` / `background before:` / `background all:`** — one
|
|
109
|
+
unambiguous, reactor-level cut point between what runs in the calling process
|
|
110
|
+
and what runs in a worker (`all:` — the whole reactor, replacing the old
|
|
111
|
+
whole-reactor `async true`).
|
|
112
|
+
* **`async_step`** — dispatch one step's work to its own job while the reactor
|
|
113
|
+
keeps running every other ready step. Dependent steps read the outcome through
|
|
114
|
+
the existing `result(:name)` helper, which gains a bounded notified wait.
|
|
115
|
+
* **`async_reactor`** — dispatch a whole nested reactor to run independently,
|
|
116
|
+
linked to the parent by execution id for traceability but excluded from its
|
|
117
|
+
compensation graph. A dispatch-time guard fails loudly instead of deadlocking
|
|
118
|
+
when a child declares a lock key the parent holds.
|
|
119
|
+
* **`Configuration#async_wait_timeout`** (default `30` seconds) — bounds how long
|
|
120
|
+
a step blocks reading a dispatched result. Never an unbounded wait.
|
|
121
|
+
* The dashboard renders both new step types and drills into an `async_reactor`
|
|
122
|
+
child's own execution.
|
|
123
|
+
|
|
124
|
+
**Compensation for the two new units is opt-in, by design.** A failing
|
|
125
|
+
`async_step` / `async_reactor` does not automatically compensate its parent — it
|
|
126
|
+
was dispatched precisely so the parent would not depend on it. A later step that
|
|
127
|
+
reads the result and returns `Failure` triggers compensation normally, so no
|
|
128
|
+
failure is unrecoverable, just not automatic.
|
|
129
|
+
|
|
130
|
+
* Reactor signal semantics: `Skipped` is renamed to `Halt` (the existing clean-stop behaviour, unchanged otherwise), and `Skipped` is reused with new meaning — marking a single step skipped while the reactor continues, with its value flowing to dependants exactly like `Success`. One-line outcome helpers `success!`, `fail!`, `skip!`, and `halt!` end a step immediately from any call depth. `Failure` (and `fail!`) accept a `retry:` spelling alongside the existing `retryable:`. `compensate`/`undo` now default to `Skipped` instead of `Success`, so the execution trace distinguishes rollback logic that ran from rollback logic that was never written.
|
|
131
|
+
|
|
132
|
+
**Migration**: `Skipped(reason: "...")` (the old halt) is now `Halt(reason: "...")`; `result.skipped?` for a clean halt is now `result.halted?`; the `be_skipped` matcher for a clean halt is now `be_halted`. The old call shape raises `ArgumentError` naming `Halt` — there is no silent compatibility path. Run status `:skipped` is renamed `:halted`; contexts persisted by a pre-upgrade version with status `"skipped"` are still read back correctly as halted.
|
|
9
133
|
|
|
10
134
|
## [0.5.4](https://github.com/arturictus/ruby_reactor/compare/v0.5.3...v0.5.4) (2026-06-18)
|
|
11
135
|
|
|
@@ -55,7 +179,7 @@
|
|
|
55
179
|
|
|
56
180
|
### Features
|
|
57
181
|
|
|
58
|
-
* `
|
|
182
|
+
* `DispatchResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
|
|
59
183
|
* enhance deserialization error handling in Sidekiq worker ([#23](https://github.com/arturictus/ruby_reactor/issues/23)) ([60dde95](https://github.com/arturictus/ruby_reactor/commit/60dde95606d52cc6a9d352ad0117b4092a1ebb9d))
|
|
60
184
|
* Enhance failure messages with step, reactor, redacted inputs, a… ([#11](https://github.com/arturictus/ruby_reactor/issues/11)) ([952feae](https://github.com/arturictus/ruby_reactor/commit/952feaeb6ebbe5fbe2daf470263d8e769ba64138))
|
|
61
185
|
* Introduce reactor interrupt functionality, allowing pausing and… ([#13](https://github.com/arturictus/ruby_reactor/issues/13)) ([53d0861](https://github.com/arturictus/ruby_reactor/commit/53d0861f0238f0e2247e581b0a27cba2f42cfba6))
|
data/CLAUDE.md
ADDED
data/README.md
CHANGED
|
@@ -59,7 +59,9 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
59
59
|
- [Basic Example: User Registration](#basic-example-user-registration)
|
|
60
60
|
- [Async Execution](#async-execution)
|
|
61
61
|
- [Full Reactor Async](#full-reactor-async)
|
|
62
|
-
- [
|
|
62
|
+
- [Background Hand-off](#background-hand-off)
|
|
63
|
+
- [`async_step`](#async_step-one-step-dispatched-on-its-own)
|
|
64
|
+
- [`async_reactor`](#async_reactor-a-whole-nested-reactor-running-independently)
|
|
63
65
|
- [Durability & Recovery](#durability--recovery)
|
|
64
66
|
- [Interrupts (Pause & Resume)](#interrupts-pause--resume)
|
|
65
67
|
- [Locks, Semaphores & Ordered Locks](#locks-semaphores--ordered-locks)
|
|
@@ -139,6 +141,19 @@ RubyReactor.configure do |config|
|
|
|
139
141
|
# config.lock_snooze_jitter = 5
|
|
140
142
|
# config.lock_snooze_max_attempts = 20
|
|
141
143
|
|
|
144
|
+
## === Waiting on async results (`result(:name)` on async_step / async_reactor) ===
|
|
145
|
+
|
|
146
|
+
## How long a SYNCHRONOUS caller blocks on a still-pending async result before
|
|
147
|
+
## failing with Error::AsyncWaitTimeoutError. Kept tight: a blocking wait pins
|
|
148
|
+
## a thread and must stay under typical host timeouts. Default: 30.
|
|
149
|
+
# config.async_wait_timeout = 30
|
|
150
|
+
|
|
151
|
+
## Total parked time allowed when the reader runs INSIDE a worker: the job
|
|
152
|
+
## re-enqueues itself instead of blocking (locks stay held across the gap),
|
|
153
|
+
## so this bound can be generous. Measured from the unit's dispatch.
|
|
154
|
+
## :infinity removes the bound. Default: 3600.
|
|
155
|
+
# config.async_park_timeout = 3600
|
|
156
|
+
|
|
142
157
|
## === Durability & crash recovery (see "Durability & Recovery" below) ===
|
|
143
158
|
|
|
144
159
|
## Retention TTL (seconds) for stored reactor/map state. Must exceed your
|
|
@@ -215,11 +230,14 @@ RubyReactor supports two ways to define step logic:
|
|
|
215
230
|
| **Class steps** (preferred) | Real business logic, compensation/undo, shared steps, testability |
|
|
216
231
|
| **Inline blocks** | Quick prototypes, trivial one-liners, documentation examples |
|
|
217
232
|
|
|
218
|
-
Whichever style you use, a step's `run` returns one of
|
|
233
|
+
Whichever style you use, a step's `run` returns one of four signals — all exposed as bare helpers in both class steps and inline blocks:
|
|
219
234
|
|
|
220
235
|
- **`Success(value)`** — step succeeded; `value` flows to dependent steps.
|
|
221
236
|
- **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
|
|
222
|
-
- **`
|
|
237
|
+
- **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
|
|
238
|
+
- **`Skipped(value)`** — mark this one step skipped; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
|
|
239
|
+
|
|
240
|
+
One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
|
|
223
241
|
|
|
224
242
|
**Class steps** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
|
|
225
243
|
|
|
@@ -402,20 +420,23 @@ async, durability, retries, snoozing) works identically on either backend.
|
|
|
402
420
|
|
|
403
421
|
```ruby
|
|
404
422
|
class AsyncReactor < RubyReactor::Reactor
|
|
405
|
-
|
|
423
|
+
background all: true # Entire reactor runs in background
|
|
406
424
|
|
|
407
425
|
step :long_running_task do
|
|
408
426
|
run { perform_heavy_work }
|
|
409
427
|
end
|
|
410
428
|
end
|
|
411
429
|
|
|
412
|
-
# Returns immediately with
|
|
430
|
+
# Returns immediately with DispatchResult
|
|
413
431
|
result = AsyncReactor.run(params)
|
|
414
432
|
```
|
|
415
433
|
|
|
416
|
-
####
|
|
434
|
+
#### Background Hand-off
|
|
417
435
|
|
|
418
|
-
|
|
436
|
+
A reactor can name **one** point where execution stops running in the caller's
|
|
437
|
+
process and is handed to a worker. Everything before it runs in the caller;
|
|
438
|
+
everything after it runs in a single background job. The cut point is nameable
|
|
439
|
+
from either side.
|
|
419
440
|
|
|
420
441
|
```ruby
|
|
421
442
|
class CreateUserReactor < RubyReactor::Reactor
|
|
@@ -430,15 +451,16 @@ class CreateUserReactor < RubyReactor::Reactor
|
|
|
430
451
|
run { |args| User.create(args[:params]) }
|
|
431
452
|
end
|
|
432
453
|
|
|
433
|
-
#
|
|
454
|
+
# :create_user is the LAST step to run in the calling process.
|
|
455
|
+
# Equivalently here: `background before: :open_account`.
|
|
456
|
+
background after: :create_user
|
|
457
|
+
|
|
434
458
|
step :open_account do
|
|
435
|
-
async true
|
|
436
459
|
argument :user, result(:create_user)
|
|
437
460
|
run { |args| Bank.open_account(args[:user]) }
|
|
438
461
|
end
|
|
439
462
|
|
|
440
463
|
step :report_new_user do
|
|
441
|
-
async true
|
|
442
464
|
argument :user, result(:create_user)
|
|
443
465
|
wait_for :open_account
|
|
444
466
|
run { |args| Analytics.track(args[:user]) }
|
|
@@ -447,16 +469,112 @@ end
|
|
|
447
469
|
|
|
448
470
|
# Usage
|
|
449
471
|
def create(params)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
472
|
+
# Returns an DispatchResult immediately once :create_user completes
|
|
473
|
+
result = CreateUserReactor.run(params)
|
|
474
|
+
|
|
475
|
+
# Access synchronous results immediately
|
|
476
|
+
user = result.intermediate_results[:create_user]
|
|
477
|
+
|
|
478
|
+
# do something with user
|
|
479
|
+
end
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`after: :x` guarantees `:x` runs in the calling process and is the last to do so.
|
|
483
|
+
`before: :x` guarantees `:x` runs in the worker and is the first to do so. They
|
|
484
|
+
coincide in a linear chain; **in a DAG they pin different steps**, so pick
|
|
485
|
+
whichever step you actually need pinned. Compensation is unchanged — `background`
|
|
486
|
+
changes where code runs, not the saga contract.
|
|
487
|
+
|
|
488
|
+
> **Breaking change:** the per-step `async true` flag has been **removed**. It was
|
|
489
|
+
> ambiguous — only the *first* flagged step in a reactor ever took effect and the
|
|
490
|
+
> rest were silently ignored — and it now raises at class-definition time. The
|
|
491
|
+
> exact replacement for a flagged step `:x` is `background before: :x`. The same
|
|
492
|
+
> applies to `async` inside a `compose` block.
|
|
493
|
+
>
|
|
494
|
+
> **Breaking change:** whole-reactor `async true` has also been **removed** — it
|
|
495
|
+
> named the same idea as `background`'s cut point with a different word, right next
|
|
496
|
+
> to `async_step`/`async_reactor`, whose names mean something else. It now raises
|
|
497
|
+
> at class-definition time. The exact replacement is `background all: true`.
|
|
498
|
+
|
|
499
|
+
#### `async_step`: one step, dispatched on its own
|
|
500
|
+
|
|
501
|
+
Where `background` relocates the *rest* of a reactor, `async_step` dispatches one
|
|
502
|
+
step's work to its own job while the reactor keeps running every other ready step.
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
class SignupReactor < RubyReactor::Reactor
|
|
506
|
+
input :email
|
|
507
|
+
|
|
508
|
+
async_step :send_email do
|
|
509
|
+
argument :to, input(:email)
|
|
510
|
+
run { |args| Mailer.welcome(args[:to]).deliver_now; Success(:sent) }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Does NOT wait — it has no dependency on :send_email.
|
|
514
|
+
step :record_signup do
|
|
515
|
+
argument :email, input(:email)
|
|
516
|
+
run { |args| Success(Signup.create!(email: args[:email])) }
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# DOES wait, because it reads the result.
|
|
520
|
+
step :confirm_delivery do
|
|
521
|
+
argument :delivery, result(:send_email)
|
|
522
|
+
run { |args| Success("confirmed") }
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Reading `result(:send_email)` is what makes a step wait — never unbounded, and
|
|
528
|
+
never thread-hungry: a **synchronous caller** blocks, bounded by
|
|
529
|
+
`config.async_wait_timeout` (default 30s); a reader **inside a worker** parks
|
|
530
|
+
instead — after a short grace the job re-enqueues itself, freeing the thread and
|
|
531
|
+
keeping any held lock/semaphore checked out, bounded by
|
|
532
|
+
`config.async_park_timeout` (default 1h) so legitimately slow units get their
|
|
533
|
+
time. On success the reader gets the raw value; on failure it gets the
|
|
534
|
+
`Failure` **object**, so it can inspect it and decide.
|
|
535
|
+
|
|
536
|
+
**Compensation is opt-in.** If a dispatched step fails and nothing reads its
|
|
537
|
+
result, the reactor is not compensated — it was dispatched precisely so the
|
|
538
|
+
reactor would not depend on it. A reader that returns `Failure` triggers
|
|
539
|
+
compensation normally. The independence cuts both ways: async dispatches never
|
|
540
|
+
enter the parent's undo stack, so a parent rolling back for its own reasons
|
|
541
|
+
never "undoes" a unit that runs (and may still succeed) elsewhere.
|
|
542
|
+
|
|
543
|
+
#### `async_reactor`: a whole nested reactor, running independently
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
class SignupReactor < RubyReactor::Reactor
|
|
547
|
+
input :user_id
|
|
548
|
+
|
|
549
|
+
# Fire-and-forget: nothing reads it, so its failure never affects this reactor.
|
|
550
|
+
async_reactor :backfill_profile, ProfileBackfillReactor do
|
|
551
|
+
argument :user_id, input(:user_id)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
async_reactor :provision_account, AccountProvisioningReactor do
|
|
555
|
+
argument :user_id, input(:user_id)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
step :verify do
|
|
559
|
+
argument :account, result(:provision_account) # waits for the child
|
|
560
|
+
run do |args|
|
|
561
|
+
args[:account].success? ? Success(args[:account].value) : Failure(args[:account].error)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
457
564
|
end
|
|
458
565
|
```
|
|
459
566
|
|
|
567
|
+
The child is an ordinary, independently addressable execution, linked to the
|
|
568
|
+
parent by execution id (drillable in the dashboard) but excluded from its
|
|
569
|
+
compensation graph. Reach for `compose` instead when the child belongs to this
|
|
570
|
+
unit of work — its result is available immediately and its failure rolls the
|
|
571
|
+
parent back.
|
|
572
|
+
|
|
573
|
+
Lock ownership is never shared across the async boundary: a child declaring a key
|
|
574
|
+
the parent holds fails at dispatch with an explanatory error rather than
|
|
575
|
+
deadlocking. See [Async Reactors](documentation/async_reactors.md) for the full
|
|
576
|
+
rules.
|
|
577
|
+
|
|
460
578
|
### Durability & Recovery
|
|
461
579
|
|
|
462
580
|
Async reactors are durable: state lives in Redis, not in the job payload. Before
|
|
@@ -611,7 +729,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
|
|
|
611
729
|
input :org_id
|
|
612
730
|
|
|
613
731
|
# Run at most once per UTC month per org. Subsequent calls in the same month
|
|
614
|
-
# return RubyReactor::
|
|
732
|
+
# return RubyReactor::Halt without executing any step. Pair with
|
|
615
733
|
# with_lock for strict at-most-one even under concurrent racers.
|
|
616
734
|
with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
|
|
617
735
|
|
|
@@ -686,26 +804,38 @@ On contention:
|
|
|
686
804
|
- **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
|
|
687
805
|
- **Async** (Sidekiq or ActiveJob) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay uses the error's `retry_after_seconds` hint (precise wakeup — the bucket roll time is known exactly); for locks, semaphores, and ordered-lock waits it's `lock_snooze_base_delay + jitter` (a short re-poll, since a held lock or a live blocker nonce typically clears in milliseconds). Snoozes do not count against the backend's retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
|
|
688
806
|
|
|
689
|
-
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::
|
|
807
|
+
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
|
|
690
808
|
|
|
691
809
|
```ruby
|
|
692
810
|
result = MonthlyBillingReactor.run(org_id: 42)
|
|
693
|
-
result.success? # true (
|
|
694
|
-
result.
|
|
811
|
+
result.success? # true (Halt is a Success subclass)
|
|
812
|
+
result.halted? # true on dedup hit, false otherwise
|
|
695
813
|
```
|
|
696
814
|
|
|
697
|
-
A step's `run` block can also return `
|
|
815
|
+
A step's `run` block can also return `Halt(reason: "...")` to stop the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back"). `Halt` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Halt(...)`).
|
|
698
816
|
|
|
699
817
|
```ruby
|
|
700
818
|
step :ensure_active do
|
|
701
819
|
argument :user, result(:fetch_user)
|
|
702
820
|
run do |args, _ctx|
|
|
703
|
-
next
|
|
821
|
+
next Halt(reason: "user_opted_out") if args[:user].opted_out?
|
|
704
822
|
Success(args[:user])
|
|
705
823
|
end
|
|
706
824
|
end
|
|
707
825
|
```
|
|
708
826
|
|
|
827
|
+
To skip a *single* step while the reactor continues — the step did nothing, but the rest of the workflow should still run — return `Skipped(value)` instead. The value flows to dependants exactly like a `Success` value, and the step is not enrolled for rollback:
|
|
828
|
+
|
|
829
|
+
```ruby
|
|
830
|
+
step :maybe_sync do
|
|
831
|
+
argument :user, result(:fetch_user)
|
|
832
|
+
run do |args, _ctx|
|
|
833
|
+
next Skipped(args[:user]) if args[:user].already_synced?
|
|
834
|
+
Success(sync!(args[:user]))
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
```
|
|
838
|
+
|
|
709
839
|
See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, and operational notes.
|
|
710
840
|
|
|
711
841
|
### Map & Parallel Execution
|
|
@@ -1203,7 +1333,7 @@ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Con
|
|
|
1203
1333
|
Deep dive into how RubyReactor manages dependencies. This guide explains how the Directed Acyclic Graph is constructed to ensure steps execute in the correct topological order, enabling automatic parallelization of independent steps.
|
|
1204
1334
|
|
|
1205
1335
|
### [Async Reactors](documentation/async_reactors.md)
|
|
1206
|
-
Explore the
|
|
1336
|
+
Explore the ways to move work off the calling process: Full Reactor Async, the `background` hand-off, `async_step`, and `async_reactor`. Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
|
|
1207
1337
|
|
|
1208
1338
|
### [Composition](documentation/composition.md)
|
|
1209
1339
|
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.
|
|
@@ -1222,7 +1352,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
|
|
|
1222
1352
|
|
|
1223
1353
|
### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
|
|
1224
1354
|
|
|
1225
|
-
Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `
|
|
1355
|
+
Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
|
|
1226
1356
|
|
|
1227
1357
|
### [Middlewares & OpenTelemetry](documentation/middlewares.md)
|
|
1228
1358
|
|
|
@@ -9,16 +9,31 @@ module RubyReactor
|
|
|
9
9
|
# there is no blob to deserialize here.
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
|
|
12
|
-
RubyReactor::
|
|
12
|
+
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
13
|
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
|
-
RubyReactor::
|
|
18
|
+
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
19
|
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# One `async_step`'s work, dispatched as its own independent unit. The
|
|
23
|
+
# payload is identity-only for the same reason every other dispatch here
|
|
24
|
+
# is: the worker rehydrates the parent context from storage.
|
|
25
|
+
def self.perform_step_async(root_context_id:, reactor_class_name:, step_context_id:, step_name:)
|
|
26
|
+
job_id = RubyReactor::Adapters::ActiveJob::StepWorker.perform_async(
|
|
27
|
+
{
|
|
28
|
+
"root_context_id" => root_context_id,
|
|
29
|
+
"reactor_class_name" => reactor_class_name,
|
|
30
|
+
"step_context_id" => step_context_id,
|
|
31
|
+
"step_name" => step_name.to_s
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
# rubocop:disable Metrics/ParameterLists
|
|
23
38
|
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
24
39
|
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
|
|
@@ -39,7 +54,7 @@ module RubyReactor
|
|
|
39
54
|
"fail_fast" => fail_fast
|
|
40
55
|
}
|
|
41
56
|
)
|
|
42
|
-
RubyReactor::
|
|
57
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
|
|
@@ -63,9 +78,9 @@ module RubyReactor
|
|
|
63
78
|
"fail_fast" => fail_fast
|
|
64
79
|
}
|
|
65
80
|
)
|
|
66
|
-
# Return an
|
|
81
|
+
# Return an DispatchResult so RetryManager#handle_async_retry recognises the
|
|
67
82
|
# element was successfully requeued and yields a RetryQueuedResult.
|
|
68
|
-
RubyReactor::
|
|
83
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
69
84
|
end
|
|
70
85
|
# rubocop:enable Metrics/ParameterLists
|
|
71
86
|
|
|
@@ -82,7 +97,7 @@ module RubyReactor
|
|
|
82
97
|
"timeout" => timeout
|
|
83
98
|
}
|
|
84
99
|
)
|
|
85
|
-
RubyReactor::
|
|
100
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
86
101
|
end
|
|
87
102
|
# rubocop:enable Metrics/ParameterLists
|
|
88
103
|
end
|
|
@@ -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
|
+
# ActiveJob counterpart to Sidekiq::StepWorker — binding only.
|
|
9
|
+
class StepWorker < ::ActiveJob::Base
|
|
10
|
+
extend Compat
|
|
11
|
+
|
|
12
|
+
queue_as { RubyReactor.configuration.queue_name }
|
|
13
|
+
|
|
14
|
+
def perform(arguments)
|
|
15
|
+
RubyReactor::StepWorker.perform(arguments)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -17,7 +17,12 @@ module RubyReactor
|
|
|
17
17
|
include RubyReactor::Worker
|
|
18
18
|
|
|
19
19
|
queue_as { RubyReactor.configuration.queue_name }
|
|
20
|
-
retry_on StandardError, attempts: RubyReactor.configuration.job_retry_count
|
|
20
|
+
retry_on StandardError, attempts: RubyReactor.configuration.job_retry_count do |job, error|
|
|
21
|
+
# Attempts exhausted and the job is done for — mark the context
|
|
22
|
+
# failed and signal any reader waiting on it (mirrors the Sidekiq
|
|
23
|
+
# adapter's retries-exhausted hook).
|
|
24
|
+
RubyReactor::Worker.record_retries_exhausted(job.arguments, error)
|
|
25
|
+
end
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
28
|
end
|
|
@@ -9,16 +9,31 @@ module RubyReactor
|
|
|
9
9
|
# there is no blob to deserialize here.
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
|
|
12
|
-
RubyReactor::
|
|
12
|
+
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
13
|
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
|
-
RubyReactor::
|
|
18
|
+
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
19
|
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# One `async_step`'s work, dispatched as its own independent unit. The
|
|
23
|
+
# payload is identity-only for the same reason every other dispatch here
|
|
24
|
+
# is: the worker rehydrates the parent context from storage.
|
|
25
|
+
def self.perform_step_async(root_context_id:, reactor_class_name:, step_context_id:, step_name:)
|
|
26
|
+
job_id = RubyReactor::Adapters::Sidekiq::StepWorker.perform_async(
|
|
27
|
+
{
|
|
28
|
+
"root_context_id" => root_context_id,
|
|
29
|
+
"reactor_class_name" => reactor_class_name,
|
|
30
|
+
"step_context_id" => step_context_id,
|
|
31
|
+
"step_name" => step_name.to_s
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
# rubocop:disable Metrics/ParameterLists
|
|
23
38
|
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
24
39
|
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
|
|
@@ -39,7 +54,7 @@ module RubyReactor
|
|
|
39
54
|
"fail_fast" => fail_fast
|
|
40
55
|
}
|
|
41
56
|
)
|
|
42
|
-
RubyReactor::
|
|
57
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
|
|
@@ -63,9 +78,9 @@ module RubyReactor
|
|
|
63
78
|
"fail_fast" => fail_fast
|
|
64
79
|
}
|
|
65
80
|
)
|
|
66
|
-
# Return an
|
|
81
|
+
# Return an DispatchResult so RetryManager#handle_async_retry recognises the
|
|
67
82
|
# element was successfully requeued and yields a RetryQueuedResult.
|
|
68
|
-
RubyReactor::
|
|
83
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
69
84
|
end
|
|
70
85
|
# rubocop:enable Metrics/ParameterLists
|
|
71
86
|
|
|
@@ -82,7 +97,7 @@ module RubyReactor
|
|
|
82
97
|
"timeout" => timeout
|
|
83
98
|
}
|
|
84
99
|
)
|
|
85
|
-
RubyReactor::
|
|
100
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
86
101
|
end
|
|
87
102
|
# rubocop:enable Metrics/ParameterLists
|
|
88
103
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sidekiq
|
|
6
|
+
# One dispatched `async_step`, mirroring MapElementWorker: the backend
|
|
7
|
+
# binding only, all behavior in the shared `RubyReactor::StepWorker`.
|
|
8
|
+
class StepWorker
|
|
9
|
+
include ::Sidekiq::Worker
|
|
10
|
+
|
|
11
|
+
def perform(arguments)
|
|
12
|
+
RubyReactor::StepWorker.perform(arguments)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -16,8 +16,11 @@ module RubyReactor
|
|
|
16
16
|
sidekiq_options retry: RubyReactor.configuration.job_retry_count, dead: false,
|
|
17
17
|
queue: RubyReactor.configuration.queue_name
|
|
18
18
|
|
|
19
|
-
sidekiq_retries_exhausted do |
|
|
20
|
-
#
|
|
19
|
+
sidekiq_retries_exhausted do |msg, exception|
|
|
20
|
+
# With `dead: false` this job is discarded after its last retry, so
|
|
21
|
+
# this hook is the only place left to surface the failure: mark the
|
|
22
|
+
# context failed and signal any reader waiting on it.
|
|
23
|
+
RubyReactor::Worker.record_retries_exhausted(msg["args"], exception)
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
26
|
end
|