ruby_reactor 0.6.0 → 0.7.0
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/CHANGELOG.md +114 -1
- data/CLAUDE.md +5 -0
- data/README.md +131 -16
- 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 +21 -0
- data/lib/ruby_reactor/context_serializer.rb +2 -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 +1 -1
- 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/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/ordered_lock_support.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +12 -1
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
- data/lib/ruby_reactor/executor/step_executor.rb +58 -11
- data/lib/ruby_reactor/executor.rb +162 -8
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +6 -1
- data/lib/ruby_reactor/map/helpers.rb +1 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/open_telemetry.rb +1 -1
- data/lib/ruby_reactor/reactor.rb +25 -2
- 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 +66 -11
- 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 +1 -1
- 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 +22 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
- 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_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 +94 -16
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.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 +24 -3
- data/specs/001-background-async-steps/checklists/requirements.md +39 -0
- data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
- data/specs/001-background-async-steps/data-model.md +117 -0
- data/specs/001-background-async-steps/plan.md +168 -0
- data/specs/001-background-async-steps/quickstart.md +102 -0
- data/specs/001-background-async-steps/research.md +150 -0
- data/specs/001-background-async-steps/spec.md +146 -0
- data/specs/001-background-async-steps/tasks.md +271 -0
- data/specs/active_job.md +1 -1
- metadata +27 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b08f95b4b201fa008ca5f98e8e221f7bb8da00becac3307f7f107406b4a3fd95
|
|
4
|
+
data.tar.gz: d3884e832408aea3d1caee63342b71c16823474abba8b98536a76dbe55ca1fc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2df63b3d9a9603d61baa4c51ea453399c2890b012fd74e11e01b7931081dfc9d3cc1eba358930166c2847c6cef964d9491b968e40c448d6a62fcb8ea0ae90e90
|
|
7
|
+
data.tar.gz: 809eb63f486448fb5d0c0d8d15eec39c3e358e3de26dee6c1db8318c9a2f0449fbf32a7f91695e75d58b8eee2ac026e46ce0e063181b567f4b777c35b44d2b52
|
data/.specify/feature.json
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,124 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/arturictus/ruby_reactor/compare/v0.6.0...v0.7.0) (2026-09-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* `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.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* 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))
|
|
13
|
+
|
|
3
14
|
## [0.6.0](https://github.com/arturictus/ruby_reactor/compare/v0.5.4...v0.6.0) (2026-08-16)
|
|
4
15
|
|
|
5
16
|
|
|
6
17
|
### Features
|
|
7
18
|
|
|
8
19
|
* ActiveJob Support ([#42](https://github.com/arturictus/ruby_reactor/issues/42)) ([0fb6dc4](https://github.com/arturictus/ruby_reactor/commit/0fb6dc4ae4b16c34e0aa33a66f95df3e14ae0807))
|
|
20
|
+
## Unreleased
|
|
21
|
+
|
|
22
|
+
### ⚠ BREAKING CHANGES
|
|
23
|
+
|
|
24
|
+
* **The per-step `async` flag is removed.** `async true` inside a `step` **or a
|
|
25
|
+
`compose`** block now raises `RubyReactor::Error::DeprecatedDslError` (a
|
|
26
|
+
subclass of `Error::ValidationError`) at reactor **class-definition** time.
|
|
27
|
+
|
|
28
|
+
It was ambiguous: only the **first** flagged step in a reactor ever took
|
|
29
|
+
effect, and every later one was silently ignored. A reactor now declares one
|
|
30
|
+
hand-off point instead, nameable from either side:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Before — only the first `async true` did anything
|
|
34
|
+
step :process_payment do
|
|
35
|
+
async true
|
|
36
|
+
# ...
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# After — the exact equivalent
|
|
40
|
+
step :process_payment do
|
|
41
|
+
# ...
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
background before: :process_payment
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Migration:** for a flagged step `:x`, use `background before: :x`. That
|
|
48
|
+
reproduces the old semantics precisely — `:x` and everything after it move to
|
|
49
|
+
the worker — without having to identify a predecessor step. The same applies to
|
|
50
|
+
`async` inside a `compose` block. `after: :x` is the other side of the same cut
|
|
51
|
+
point (`:x` stays in the calling process); the two coincide in a linear chain
|
|
52
|
+
but pin different steps in a DAG.
|
|
53
|
+
|
|
54
|
+
Not affected: the map-internal `async` element dispatch option
|
|
55
|
+
(`map :items do async true, batch_size: 2 end`) — a different mechanism that
|
|
56
|
+
keeps working unchanged.
|
|
57
|
+
|
|
58
|
+
One behavior change falls out of "exactly one hand-off point per reactor":
|
|
59
|
+
resuming a reactor past its hand-off point now finishes in the resuming
|
|
60
|
+
process, where the old per-step flag would queue a second, undeclared hand-off.
|
|
61
|
+
|
|
62
|
+
* `RubyReactor::Dsl::StepConfig#async?` and the per-step `async:` field in the
|
|
63
|
+
dashboard's `Web::API` step structure are gone. The dashboard now exposes the
|
|
64
|
+
reactor's normalized hand-off point once, as `background_handoff`.
|
|
65
|
+
|
|
66
|
+
* **Whole-reactor `async true` is removed.** It named the same hand-off idea as
|
|
67
|
+
`background`, with a different word, and read confusingly next to the new
|
|
68
|
+
`async_step` / `async_reactor` step macros (both "async" + "reactor", meaning
|
|
69
|
+
different things). Using it now raises `RubyReactor::Error::DeprecatedDslError`
|
|
70
|
+
at class-definition time.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Before
|
|
74
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
75
|
+
async true
|
|
76
|
+
# ...
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# After — identical behavior, including validating inputs inside the worker
|
|
80
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
81
|
+
background all: true
|
|
82
|
+
# ...
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Migration:** replace `async true` with `background all: true`. `async?` (the
|
|
87
|
+
reader) is unchanged and still answers the same question.
|
|
88
|
+
|
|
89
|
+
* **`RubyReactor::AsyncResult` is renamed to `RubyReactor::DispatchResult`.** The
|
|
90
|
+
old name no longer fit: the class is the sentinel returned whenever a step's
|
|
91
|
+
work is handed to a worker and not yet resolved, produced alike by
|
|
92
|
+
`background`, `async_step`, `async_reactor`, and map's async element dispatch
|
|
93
|
+
— not specific to "async" as a concept.
|
|
94
|
+
|
|
95
|
+
**Migration:** replace any `RubyReactor::AsyncResult` reference (e.g. in a
|
|
96
|
+
custom `async_router`, or `result.is_a?(RubyReactor::AsyncResult)` checks)
|
|
97
|
+
with `RubyReactor::DispatchResult`.
|
|
98
|
+
|
|
99
|
+
### Features
|
|
100
|
+
|
|
101
|
+
* **`background after:` / `background before:` / `background all:`** — one
|
|
102
|
+
unambiguous, reactor-level cut point between what runs in the calling process
|
|
103
|
+
and what runs in a worker (`all:` — the whole reactor, replacing the old
|
|
104
|
+
whole-reactor `async true`).
|
|
105
|
+
* **`async_step`** — dispatch one step's work to its own job while the reactor
|
|
106
|
+
keeps running every other ready step. Dependent steps read the outcome through
|
|
107
|
+
the existing `result(:name)` helper, which gains a bounded notified wait.
|
|
108
|
+
* **`async_reactor`** — dispatch a whole nested reactor to run independently,
|
|
109
|
+
linked to the parent by execution id for traceability but excluded from its
|
|
110
|
+
compensation graph. A dispatch-time guard fails loudly instead of deadlocking
|
|
111
|
+
when a child declares a lock key the parent holds.
|
|
112
|
+
* **`Configuration#async_wait_timeout`** (default `30` seconds) — bounds how long
|
|
113
|
+
a step blocks reading a dispatched result. Never an unbounded wait.
|
|
114
|
+
* The dashboard renders both new step types and drills into an `async_reactor`
|
|
115
|
+
child's own execution.
|
|
116
|
+
|
|
117
|
+
**Compensation for the two new units is opt-in, by design.** A failing
|
|
118
|
+
`async_step` / `async_reactor` does not automatically compensate its parent — it
|
|
119
|
+
was dispatched precisely so the parent would not depend on it. A later step that
|
|
120
|
+
reads the result and returns `Failure` triggers compensation normally, so no
|
|
121
|
+
failure is unrecoverable, just not automatic.
|
|
9
122
|
|
|
10
123
|
## [0.5.4](https://github.com/arturictus/ruby_reactor/compare/v0.5.3...v0.5.4) (2026-06-18)
|
|
11
124
|
|
|
@@ -55,7 +168,7 @@
|
|
|
55
168
|
|
|
56
169
|
### Features
|
|
57
170
|
|
|
58
|
-
* `
|
|
171
|
+
* `DispatchResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
|
|
59
172
|
* 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
173
|
* 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
174
|
* 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
|
|
@@ -402,20 +417,23 @@ async, durability, retries, snoozing) works identically on either backend.
|
|
|
402
417
|
|
|
403
418
|
```ruby
|
|
404
419
|
class AsyncReactor < RubyReactor::Reactor
|
|
405
|
-
|
|
420
|
+
background all: true # Entire reactor runs in background
|
|
406
421
|
|
|
407
422
|
step :long_running_task do
|
|
408
423
|
run { perform_heavy_work }
|
|
409
424
|
end
|
|
410
425
|
end
|
|
411
426
|
|
|
412
|
-
# Returns immediately with
|
|
427
|
+
# Returns immediately with DispatchResult
|
|
413
428
|
result = AsyncReactor.run(params)
|
|
414
429
|
```
|
|
415
430
|
|
|
416
|
-
####
|
|
431
|
+
#### Background Hand-off
|
|
417
432
|
|
|
418
|
-
|
|
433
|
+
A reactor can name **one** point where execution stops running in the caller's
|
|
434
|
+
process and is handed to a worker. Everything before it runs in the caller;
|
|
435
|
+
everything after it runs in a single background job. The cut point is nameable
|
|
436
|
+
from either side.
|
|
419
437
|
|
|
420
438
|
```ruby
|
|
421
439
|
class CreateUserReactor < RubyReactor::Reactor
|
|
@@ -430,15 +448,16 @@ class CreateUserReactor < RubyReactor::Reactor
|
|
|
430
448
|
run { |args| User.create(args[:params]) }
|
|
431
449
|
end
|
|
432
450
|
|
|
433
|
-
#
|
|
451
|
+
# :create_user is the LAST step to run in the calling process.
|
|
452
|
+
# Equivalently here: `background before: :open_account`.
|
|
453
|
+
background after: :create_user
|
|
454
|
+
|
|
434
455
|
step :open_account do
|
|
435
|
-
async true
|
|
436
456
|
argument :user, result(:create_user)
|
|
437
457
|
run { |args| Bank.open_account(args[:user]) }
|
|
438
458
|
end
|
|
439
459
|
|
|
440
460
|
step :report_new_user do
|
|
441
|
-
async true
|
|
442
461
|
argument :user, result(:create_user)
|
|
443
462
|
wait_for :open_account
|
|
444
463
|
run { |args| Analytics.track(args[:user]) }
|
|
@@ -447,16 +466,112 @@ end
|
|
|
447
466
|
|
|
448
467
|
# Usage
|
|
449
468
|
def create(params)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
469
|
+
# Returns an DispatchResult immediately once :create_user completes
|
|
470
|
+
result = CreateUserReactor.run(params)
|
|
471
|
+
|
|
472
|
+
# Access synchronous results immediately
|
|
473
|
+
user = result.intermediate_results[:create_user]
|
|
474
|
+
|
|
475
|
+
# do something with user
|
|
457
476
|
end
|
|
458
477
|
```
|
|
459
478
|
|
|
479
|
+
`after: :x` guarantees `:x` runs in the calling process and is the last to do so.
|
|
480
|
+
`before: :x` guarantees `:x` runs in the worker and is the first to do so. They
|
|
481
|
+
coincide in a linear chain; **in a DAG they pin different steps**, so pick
|
|
482
|
+
whichever step you actually need pinned. Compensation is unchanged — `background`
|
|
483
|
+
changes where code runs, not the saga contract.
|
|
484
|
+
|
|
485
|
+
> **Breaking change:** the per-step `async true` flag has been **removed**. It was
|
|
486
|
+
> ambiguous — only the *first* flagged step in a reactor ever took effect and the
|
|
487
|
+
> rest were silently ignored — and it now raises at class-definition time. The
|
|
488
|
+
> exact replacement for a flagged step `:x` is `background before: :x`. The same
|
|
489
|
+
> applies to `async` inside a `compose` block.
|
|
490
|
+
>
|
|
491
|
+
> **Breaking change:** whole-reactor `async true` has also been **removed** — it
|
|
492
|
+
> named the same idea as `background`'s cut point with a different word, right next
|
|
493
|
+
> to `async_step`/`async_reactor`, whose names mean something else. It now raises
|
|
494
|
+
> at class-definition time. The exact replacement is `background all: true`.
|
|
495
|
+
|
|
496
|
+
#### `async_step`: one step, dispatched on its own
|
|
497
|
+
|
|
498
|
+
Where `background` relocates the *rest* of a reactor, `async_step` dispatches one
|
|
499
|
+
step's work to its own job while the reactor keeps running every other ready step.
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
class SignupReactor < RubyReactor::Reactor
|
|
503
|
+
input :email
|
|
504
|
+
|
|
505
|
+
async_step :send_email do
|
|
506
|
+
argument :to, input(:email)
|
|
507
|
+
run { |args| Mailer.welcome(args[:to]).deliver_now; Success(:sent) }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Does NOT wait — it has no dependency on :send_email.
|
|
511
|
+
step :record_signup do
|
|
512
|
+
argument :email, input(:email)
|
|
513
|
+
run { |args| Success(Signup.create!(email: args[:email])) }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# DOES wait, because it reads the result.
|
|
517
|
+
step :confirm_delivery do
|
|
518
|
+
argument :delivery, result(:send_email)
|
|
519
|
+
run { |args| Success("confirmed") }
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Reading `result(:send_email)` is what makes a step wait — never unbounded, and
|
|
525
|
+
never thread-hungry: a **synchronous caller** blocks, bounded by
|
|
526
|
+
`config.async_wait_timeout` (default 30s); a reader **inside a worker** parks
|
|
527
|
+
instead — after a short grace the job re-enqueues itself, freeing the thread and
|
|
528
|
+
keeping any held lock/semaphore checked out, bounded by
|
|
529
|
+
`config.async_park_timeout` (default 1h) so legitimately slow units get their
|
|
530
|
+
time. On success the reader gets the raw value; on failure it gets the
|
|
531
|
+
`Failure` **object**, so it can inspect it and decide.
|
|
532
|
+
|
|
533
|
+
**Compensation is opt-in.** If a dispatched step fails and nothing reads its
|
|
534
|
+
result, the reactor is not compensated — it was dispatched precisely so the
|
|
535
|
+
reactor would not depend on it. A reader that returns `Failure` triggers
|
|
536
|
+
compensation normally. The independence cuts both ways: async dispatches never
|
|
537
|
+
enter the parent's undo stack, so a parent rolling back for its own reasons
|
|
538
|
+
never "undoes" a unit that runs (and may still succeed) elsewhere.
|
|
539
|
+
|
|
540
|
+
#### `async_reactor`: a whole nested reactor, running independently
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
class SignupReactor < RubyReactor::Reactor
|
|
544
|
+
input :user_id
|
|
545
|
+
|
|
546
|
+
# Fire-and-forget: nothing reads it, so its failure never affects this reactor.
|
|
547
|
+
async_reactor :backfill_profile, ProfileBackfillReactor do
|
|
548
|
+
argument :user_id, input(:user_id)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
async_reactor :provision_account, AccountProvisioningReactor do
|
|
552
|
+
argument :user_id, input(:user_id)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
step :verify do
|
|
556
|
+
argument :account, result(:provision_account) # waits for the child
|
|
557
|
+
run do |args|
|
|
558
|
+
args[:account].success? ? Success(args[:account].value) : Failure(args[:account].error)
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
The child is an ordinary, independently addressable execution, linked to the
|
|
565
|
+
parent by execution id (drillable in the dashboard) but excluded from its
|
|
566
|
+
compensation graph. Reach for `compose` instead when the child belongs to this
|
|
567
|
+
unit of work — its result is available immediately and its failure rolls the
|
|
568
|
+
parent back.
|
|
569
|
+
|
|
570
|
+
Lock ownership is never shared across the async boundary: a child declaring a key
|
|
571
|
+
the parent holds fails at dispatch with an explanatory error rather than
|
|
572
|
+
deadlocking. See [Async Reactors](documentation/async_reactors.md) for the full
|
|
573
|
+
rules.
|
|
574
|
+
|
|
460
575
|
### Durability & Recovery
|
|
461
576
|
|
|
462
577
|
Async reactors are durable: state lives in Redis, not in the job payload. Before
|
|
@@ -1203,7 +1318,7 @@ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Con
|
|
|
1203
1318
|
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
1319
|
|
|
1205
1320
|
### [Async Reactors](documentation/async_reactors.md)
|
|
1206
|
-
Explore the
|
|
1321
|
+
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
1322
|
|
|
1208
1323
|
### [Composition](documentation/composition.md)
|
|
1209
1324
|
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.
|
|
@@ -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
|