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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +125 -1
  9. data/CLAUDE.md +5 -0
  10. data/README.md +155 -25
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  12. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  13. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  14. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  15. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  16. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  17. data/lib/ruby_reactor/async_waiter.rb +113 -0
  18. data/lib/ruby_reactor/configuration.rb +30 -1
  19. data/lib/ruby_reactor/context.rb +28 -0
  20. data/lib/ruby_reactor/context_serializer.rb +15 -0
  21. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  22. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  24. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  25. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  26. data/lib/ruby_reactor/dsl/lockable.rb +3 -3
  27. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  28. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  29. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  30. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  31. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  32. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  33. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  34. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  35. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  36. data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
  37. data/lib/ruby_reactor/executor/result_handler.rb +47 -11
  38. data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
  39. data/lib/ruby_reactor/executor/step_executor.rb +77 -24
  40. data/lib/ruby_reactor/executor.rb +179 -23
  41. data/lib/ruby_reactor/lock.rb +19 -0
  42. data/lib/ruby_reactor/map/element_executor.rb +13 -2
  43. data/lib/ruby_reactor/map/helpers.rb +10 -8
  44. data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
  45. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  46. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  47. data/lib/ruby_reactor/open_telemetry.rb +8 -5
  48. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  49. data/lib/ruby_reactor/reactor.rb +25 -2
  50. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  51. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  52. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  53. data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
  54. data/lib/ruby_reactor/semaphore.rb +10 -0
  55. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  56. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  57. data/lib/ruby_reactor/step/map_step.rb +6 -2
  58. data/lib/ruby_reactor/step.rb +10 -4
  59. data/lib/ruby_reactor/step_signals.rb +33 -0
  60. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  61. data/lib/ruby_reactor/step_worker.rb +260 -0
  62. data/lib/ruby_reactor/storage/adapter.rb +26 -1
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
  64. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  65. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  66. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  67. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  68. data/lib/ruby_reactor/sweeper.rb +7 -1
  69. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  70. data/lib/ruby_reactor/template/result.rb +151 -5
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +117 -22
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  74. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  75. data/lib/ruby_reactor/web/public/index.html +2 -2
  76. data/lib/ruby_reactor/worker.rb +53 -3
  77. data/lib/ruby_reactor.rb +80 -10
  78. data/specs/active_job.md +1 -1
  79. metadata +23 -3
  80. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  81. 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
- * `AsyncResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
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
@@ -0,0 +1,5 @@
1
+ <!-- SPECKIT START -->
2
+ For additional context about technologies to be used, project structure,
3
+ shell commands, and other important information, read the current plan:
4
+ `specs/001-reactor-signal-semantics/plan.md`
5
+ <!-- SPECKIT END -->
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
- - [Step-Level Async](#step-level-async)
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 three signals — all exposed as bare helpers in both class steps and inline blocks:
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
- - **`Skipped(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Skipping a reactor cleanly](documentation/core_concepts.md#skipping-a-reactor-cleanly).
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
- async true # Entire reactor runs in background
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 AsyncResult
430
+ # Returns immediately with DispatchResult
413
431
  result = AsyncReactor.run(params)
414
432
  ```
415
433
 
416
- #### Step-Level Async
434
+ #### Background Hand-off
417
435
 
418
- You can also mark individual steps as async. Execution will proceed synchronously until the first async step is encountered, at which point the reactor execution is offloaded to a background job.
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
- # From here on will run async
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
- # Returns an AsyncResult immediately when 'open_account' is reached
451
- result = CreateUserReactor.run(params)
452
-
453
- # Access synchronous results immediately
454
- user = result.intermediate_results[:create_user]
455
-
456
- # do something with user
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::Skipped without executing any step. Pair with
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::Skipped` result instead — no steps run, no exception:
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 (Skipped is a Success subclass)
694
- result.skipped? # true on dedup hit, false otherwise
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 `Skipped(reason: "...")` to halt 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"). `Skipped` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Skipped(...)`).
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 Skipped(reason: "user_opted_out") if args[:user].opted_out?
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 two asynchronous execution models: Full Reactor Async and Step-Level Async. Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
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 `Skipped` 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.
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::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
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::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
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::AsyncResult.new(job_id: job_id)
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 AsyncResult so RetryManager#handle_async_retry recognises the
81
+ # Return an DispatchResult so RetryManager#handle_async_retry recognises the
67
82
  # element was successfully requeued and yields a RetryQueuedResult.
68
- RubyReactor::AsyncResult.new(job_id: job_id)
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::AsyncResult.new(job_id: job_id)
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::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
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::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
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::AsyncResult.new(job_id: job_id)
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 AsyncResult so RetryManager#handle_async_retry recognises the
81
+ # Return an DispatchResult so RetryManager#handle_async_retry recognises the
67
82
  # element was successfully requeued and yields a RetryQueuedResult.
68
- RubyReactor::AsyncResult.new(job_id: job_id)
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::AsyncResult.new(job_id: job_id)
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 |_, _exception|
20
- # Handle infrastructure failures (network, Redis, etc.)
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