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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +114 -1
  5. data/CLAUDE.md +5 -0
  6. data/README.md +131 -16
  7. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  8. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  10. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  11. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  13. data/lib/ruby_reactor/async_waiter.rb +113 -0
  14. data/lib/ruby_reactor/configuration.rb +30 -1
  15. data/lib/ruby_reactor/context.rb +21 -0
  16. data/lib/ruby_reactor/context_serializer.rb +2 -0
  17. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  18. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  19. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  20. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  21. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  22. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  23. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  24. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  25. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  26. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  27. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  28. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  29. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  30. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  31. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  32. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  33. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  34. data/lib/ruby_reactor/executor.rb +162 -8
  35. data/lib/ruby_reactor/lock.rb +19 -0
  36. data/lib/ruby_reactor/map/element_executor.rb +6 -1
  37. data/lib/ruby_reactor/map/helpers.rb +1 -1
  38. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  39. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  40. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  41. data/lib/ruby_reactor/reactor.rb +25 -2
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  43. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  44. data/lib/ruby_reactor/rspec/test_subject.rb +66 -11
  45. data/lib/ruby_reactor/semaphore.rb +10 -0
  46. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  47. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  48. data/lib/ruby_reactor/step/map_step.rb +1 -1
  49. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  50. data/lib/ruby_reactor/step_worker.rb +260 -0
  51. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  52. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  53. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  54. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  55. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  56. data/lib/ruby_reactor/sweeper.rb +7 -1
  57. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  58. data/lib/ruby_reactor/template/result.rb +151 -5
  59. data/lib/ruby_reactor/version.rb +1 -1
  60. data/lib/ruby_reactor/web/api.rb +94 -16
  61. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  62. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  63. data/lib/ruby_reactor/web/public/index.html +2 -2
  64. data/lib/ruby_reactor/worker.rb +53 -3
  65. data/lib/ruby_reactor.rb +24 -3
  66. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  67. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  68. data/specs/001-background-async-steps/data-model.md +117 -0
  69. data/specs/001-background-async-steps/plan.md +168 -0
  70. data/specs/001-background-async-steps/quickstart.md +102 -0
  71. data/specs/001-background-async-steps/research.md +150 -0
  72. data/specs/001-background-async-steps/spec.md +146 -0
  73. data/specs/001-background-async-steps/tasks.md +271 -0
  74. data/specs/active_job.md +1 -1
  75. metadata +27 -3
  76. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  77. 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: 7ab75328ca5dda28377061047d7ac45c1515266e6e1386778470e57a2b21a674
4
- data.tar.gz: 8877cc95bc390355f86a02abff2a1a405c9d021b055d842306ac78abd4145efe
3
+ metadata.gz: b08f95b4b201fa008ca5f98e8e221f7bb8da00becac3307f7f107406b4a3fd95
4
+ data.tar.gz: d3884e832408aea3d1caee63342b71c16823474abba8b98536a76dbe55ca1fc2
5
5
  SHA512:
6
- metadata.gz: 778ed5734e8fbc634e00d93d5af7282d4dd44665b0345d07589b94a3eb866f7e85e1e7b12346369c4066d8d62462ff0b84b621f7c7fc268285aeb3680013e56f
7
- data.tar.gz: bf90e0ee6d4da42d7ea783fb81039f978d1deb41e5b2936f1bcb51aecc49952fd05eb7ae90b98fe8ad76352931df48fcd7b978506d6cf53751a4168794d6acbf
6
+ metadata.gz: 2df63b3d9a9603d61baa4c51ea453399c2890b012fd74e11e01b7931081dfc9d3cc1eba358930166c2847c6cef964d9491b968e40c448d6a62fcb8ea0ae90e90
7
+ data.tar.gz: 809eb63f486448fb5d0c0d8d15eec39c3e358e3de26dee6c1db8318c9a2f0449fbf32a7f91695e75d58b8eee2ac026e46ce0e063181b567f4b777c35b44d2b52
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.0"
2
+ ".": "0.7.0"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/001-cross-service-reactor-sagas"
2
+ "feature_directory": "specs/001-background-async-steps"
3
3
  }
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
- * `AsyncResult` returning intermediate_results ([#10](https://github.com/arturictus/ruby_reactor/issues/10)) ([0cb96d6](https://github.com/arturictus/ruby_reactor/commit/0cb96d66e88097665998601276e38e1c2249c581))
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
@@ -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
+ at specs/001-background-async-steps/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
@@ -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
- async true # Entire reactor runs in background
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 AsyncResult
427
+ # Returns immediately with DispatchResult
413
428
  result = AsyncReactor.run(params)
414
429
  ```
415
430
 
416
- #### Step-Level Async
431
+ #### Background Hand-off
417
432
 
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.
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
- # From here on will run async
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
- # 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
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 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.
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::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