ruby_reactor 0.5.4 → 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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +121 -1
  41. data/CLAUDE.md +5 -0
  42. data/README.md +181 -39
  43. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  46. data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
  47. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  48. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  49. data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
  53. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  54. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  55. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
  56. data/lib/ruby_reactor/async_waiter.rb +113 -0
  57. data/lib/ruby_reactor/configuration.rb +54 -5
  58. data/lib/ruby_reactor/context.rb +21 -0
  59. data/lib/ruby_reactor/context_serializer.rb +2 -0
  60. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  61. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  62. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  63. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  64. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  65. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  66. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  67. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  68. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  69. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  70. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  71. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  72. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  73. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  74. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  75. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  76. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  77. data/lib/ruby_reactor/executor.rb +162 -8
  78. data/lib/ruby_reactor/lock.rb +19 -0
  79. data/lib/ruby_reactor/map/element_executor.rb +7 -2
  80. data/lib/ruby_reactor/map/helpers.rb +1 -1
  81. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  82. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  83. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  84. data/lib/ruby_reactor/reactor.rb +25 -2
  85. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  86. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  87. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
  88. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  89. data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
  90. data/lib/ruby_reactor/rspec.rb +4 -0
  91. data/lib/ruby_reactor/semaphore.rb +10 -0
  92. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  93. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  94. data/lib/ruby_reactor/step/map_step.rb +1 -1
  95. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  96. data/lib/ruby_reactor/step_worker.rb +260 -0
  97. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  98. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  99. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  100. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  101. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  102. data/lib/ruby_reactor/sweeper.rb +7 -1
  103. data/lib/ruby_reactor/sweeper_job.rb +71 -0
  104. data/lib/ruby_reactor/template/result.rb +151 -5
  105. data/lib/ruby_reactor/version.rb +1 -1
  106. data/lib/ruby_reactor/web/api.rb +94 -16
  107. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  108. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  109. data/lib/ruby_reactor/web/public/index.html +2 -2
  110. data/lib/ruby_reactor/worker.rb +276 -0
  111. data/lib/ruby_reactor.rb +64 -4
  112. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  113. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  114. data/specs/001-background-async-steps/data-model.md +117 -0
  115. data/specs/001-background-async-steps/plan.md +168 -0
  116. data/specs/001-background-async-steps/quickstart.md +102 -0
  117. data/specs/001-background-async-steps/research.md +150 -0
  118. data/specs/001-background-async-steps/spec.md +146 -0
  119. data/specs/001-background-async-steps/tasks.md +271 -0
  120. data/specs/active_job.md +259 -0
  121. metadata +80 -8
  122. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  123. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  124. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  125. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  126. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
  127. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  128. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  # RubyReactor
7
7
 
8
- A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq** for asynchronous execution and **Redis** for state persistence.
8
+ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq or ActiveJob** for asynchronous execution and **Redis** for state persistence.
9
9
 
10
10
  ![Payment workflow reactor](documentation/images/payment_workflow.png)
11
11
 
@@ -18,7 +18,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
18
18
  ## Features
19
19
 
20
20
  - **DAG-based Execution**: Steps are executed based on their dependencies, allowing for parallel execution of independent steps.
21
- - **Async Execution**: Steps can be executed asynchronously in the background using Sidekiq.
21
+ - **Async Execution**: Steps can be executed asynchronously in the background using Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
22
22
  - **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
23
23
  - **Retries**: Configurable retry logic for failed steps, with exponential backoff.
24
24
  - **Compensation**: Automatic rollback of completed steps when a failure occurs.
@@ -35,7 +35,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
35
35
  | Interrupts (pause/resume)| Yes | No | No | Manual |
36
36
  | Locks / sem / rate / per | Yes | No | No | Manual |
37
37
  | Built-in web dashboard | Yes | No | No | No |
38
- | Async with Sidekiq | Yes | No | Limited | Yes |
38
+ | Async (Sidekiq/AJ) | Yes | No | Limited | Yes |
39
39
  | Durable crash recovery | Yes | No | No | Manual |
40
40
 
41
41
  ## Real-World Use Cases
@@ -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)
@@ -115,18 +117,21 @@ RubyReactor.configure do |config|
115
117
  ## Extra options passed to Redis.new. Default: {}.
116
118
  # config.storage.redis_options = { timeout: 1 }
117
119
 
118
- ## === Sidekiq ===
120
+ ## === Background job backend (Sidekiq by default, or ActiveJob) ===
119
121
 
120
- ## Sidekiq queue used by RubyReactor's async worker. Default: :default.
121
- # config.sidekiq_queue = :default
122
+ ## Queue used by RubyReactor's async worker. Default: :default.
123
+ # config.queue_name = :default
122
124
 
123
- ## Sidekiq retry count for infrastructure failures only (deserialization,
124
- ## Redis, network). Step retries are managed separately. Default: 3.
125
- # config.sidekiq_retry_count = 3
125
+ ## Retry count for infrastructure failures only (deserialization, Redis,
126
+ ## network). Step retries are managed separately. Default: 3.
127
+ # config.job_retry_count = 3
128
+
129
+ ## `sidekiq_queue` / `sidekiq_retry_count` still work as deprecated aliases
130
+ ## for `queue_name` / `job_retry_count` above.
126
131
 
127
132
  ## === Contention snooze (locks / semaphores / rate limits / ordered locks) ===
128
133
 
129
- ## When a Sidekiq worker cannot acquire a primitive it re-enqueues itself with
134
+ ## When a worker cannot acquire a primitive it re-enqueues itself with
130
135
  ## `lock_snooze_base_delay + rand(0..lock_snooze_jitter)` seconds (rate-limit
131
136
  ## uses a precise `retry_after_seconds` hint from the error; ordered-lock waits
132
137
  ## re-poll at the base delay so a successor catches its blocker finishing fast),
@@ -136,6 +141,19 @@ RubyReactor.configure do |config|
136
141
  # config.lock_snooze_jitter = 5
137
142
  # config.lock_snooze_max_attempts = 20
138
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
+
139
157
  ## === Durability & crash recovery (see "Durability & Recovery" below) ===
140
158
 
141
159
  ## Retention TTL (seconds) for stored reactor/map state. Must exceed your
@@ -162,10 +180,11 @@ RubyReactor.configure do |config|
162
180
  ## Logger. Default: Logger.new($stdout).
163
181
  # config.logger = Logger.new($stdout)
164
182
 
165
- ## Async router. Default: RubyReactor::SidekiqAdapter. Swap for a custom adapter
166
- ## if you don't use Sidekiq it only needs to respond to
183
+ ## Async router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
184
+ ## built-in ActiveJob adapter (see "Async Execution" below), or point at any
185
+ ## custom adapter — it only needs to respond to
167
186
  ## `perform_async(context_id, reactor_class_name, **)`.
168
- # config.async_router = MyCustomAdapter
187
+ # config.async_router = RubyReactor::Adapters::ActiveJob::Router
169
188
 
170
189
  ## === Examples (no default — set these to use the feature) ===
171
190
 
@@ -181,8 +200,9 @@ You can also leave out the `configure` block entirely — defaults work for loca
181
200
 
182
201
  > **Crash recovery needs a kick.** The `sweeper_*` settings above only configure
183
202
  > the recovery sweeper — they do not start it. Call `RubyReactor.start_sweeper!`
184
- > once at boot (ideally from a Sidekiq `on(:startup)` hook) or no crashed reactor
185
- > will ever resume. See [Durability & Recovery](#durability--recovery).
203
+ > once at boot (ideally from a worker-process startup hook a Sidekiq
204
+ > `on(:startup)` hook, or a Rails initializer for ActiveJob) or no crashed
205
+ > reactor will ever resume. See [Durability & Recovery](#durability--recovery).
186
206
 
187
207
 
188
208
  ## Quick Start
@@ -380,26 +400,40 @@ end
380
400
 
381
401
  ### Async Execution
382
402
 
383
- Execute reactors in the background using Sidekiq.
403
+ Execute reactors in the background using Sidekiq or ActiveJob. The backend is
404
+ chosen via `config.async_router` (defaults to the Sidekiq adapter); to run on
405
+ ActiveJob instead:
406
+
407
+ ```ruby
408
+ RubyReactor.configure do |config|
409
+ config.async_router = RubyReactor::Adapters::ActiveJob::Router
410
+ end
411
+ ```
412
+
413
+ That's the only switch — everything below (full-reactor async, step-level
414
+ async, durability, retries, snoozing) works identically on either backend.
384
415
 
385
416
  #### Full Reactor Async
386
417
 
387
418
  ```ruby
388
419
  class AsyncReactor < RubyReactor::Reactor
389
- async true # Entire reactor runs in background
420
+ background all: true # Entire reactor runs in background
390
421
 
391
422
  step :long_running_task do
392
423
  run { perform_heavy_work }
393
424
  end
394
425
  end
395
426
 
396
- # Returns immediately with AsyncResult
427
+ # Returns immediately with DispatchResult
397
428
  result = AsyncReactor.run(params)
398
429
  ```
399
430
 
400
- #### Step-Level Async
431
+ #### Background Hand-off
401
432
 
402
- 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.
403
437
 
404
438
  ```ruby
405
439
  class CreateUserReactor < RubyReactor::Reactor
@@ -414,15 +448,16 @@ class CreateUserReactor < RubyReactor::Reactor
414
448
  run { |args| User.create(args[:params]) }
415
449
  end
416
450
 
417
- # 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
+
418
455
  step :open_account do
419
- async true
420
456
  argument :user, result(:create_user)
421
457
  run { |args| Bank.open_account(args[:user]) }
422
458
  end
423
459
 
424
460
  step :report_new_user do
425
- async true
426
461
  argument :user, result(:create_user)
427
462
  wait_for :open_account
428
463
  run { |args| Analytics.track(args[:user]) }
@@ -431,16 +466,112 @@ end
431
466
 
432
467
  # Usage
433
468
  def create(params)
434
- # Returns an AsyncResult immediately when 'open_account' is reached
435
- result = CreateUserReactor.run(params)
436
-
437
- # Access synchronous results immediately
438
- user = result.intermediate_results[:create_user]
439
-
440
- # 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
441
476
  end
442
477
  ```
443
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
+
444
575
  ### Durability & Recovery
445
576
 
446
577
  Async reactors are durable: state lives in Redis, not in the job payload. Before
@@ -455,8 +586,8 @@ reactor only resumes when the recovery sweeper notices the lapsed liveness lock
455
586
  and re-enqueues it. The sweeper is a self-rescheduling chain — **kick it once per
456
587
  process boot:**
457
588
 
458
- The recommended spot is a Sidekiq server startup hook, so only the worker
459
- process runs recovery (not your web/console/client processes):
589
+ The recommended spot is a worker-process startup hook, so only the worker
590
+ process runs recovery (not your web/console/client processes). On Sidekiq:
460
591
 
461
592
  ```ruby
462
593
  # config/initializers/sidekiq.rb
@@ -465,7 +596,18 @@ Sidekiq.configure_server do |config|
465
596
  end
466
597
  ```
467
598
 
468
- Anywhere that runs once at boot works too e.g. a Rails initializer:
599
+ ActiveJob has no equivalent server-only hook call it from wherever your
600
+ queue adapter's worker process boots (e.g. `bin/jobs` for Solid Queue, or a
601
+ dedicated initializer guarded by an env var so it doesn't also run in web
602
+ processes):
603
+
604
+ ```ruby
605
+ # config/initializers/ruby_reactor.rb
606
+ RubyReactor.start_sweeper! if ENV["RUBY_REACTOR_WORKER"]
607
+ ```
608
+
609
+ Anywhere that runs once at boot works too (idempotent, so it's safe even if
610
+ every process calls it) — e.g. an unconditional Rails initializer:
469
611
 
470
612
  ```ruby
471
613
  # config/initializers/ruby_reactor.rb
@@ -599,7 +741,7 @@ class ChargeReactor < RubyReactor::Reactor
599
741
 
600
742
  # Respect upstream Stripe rate limits: 3/sec and 100/min.
601
743
  # Async workers snooze for exactly retry_after seconds instead of
602
- # consuming Sidekiq retry budget.
744
+ # consuming the backend's retry budget.
603
745
  with_rate_limit(
604
746
  limits: { second: 3, minute: 100 }
605
747
  ) { |inputs| "stripe:#{inputs[:account_id]}" }
@@ -657,7 +799,7 @@ Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::Unknown
657
799
  On contention:
658
800
 
659
801
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
660
- - **Async** (Sidekiq) 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 Sidekiq retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
802
+ - **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).
661
803
 
662
804
  On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
663
805
 
@@ -1176,7 +1318,7 @@ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Con
1176
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.
1177
1319
 
1178
1320
  ### [Async Reactors](documentation/async_reactors.md)
1179
- Explore the two asynchronous execution models: Full Reactor Async and Step-Level Async. Learn how RubyReactor leverages Sidekiq 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.
1180
1322
 
1181
1323
  ### [Composition](documentation/composition.md)
1182
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.
@@ -1219,9 +1361,9 @@ Hook into the execution lifecycle with observer middlewares. Covers the full set
1219
1361
  - [ ] Multiple storage adapters
1220
1362
  - [X] Redis
1221
1363
  - [ ] ActiveRecord
1222
- - [ ] Multiple Async adapters
1364
+ - [X] Multiple Async adapters
1223
1365
  - [X] Sidekiq
1224
- - [ ] ActiveJob
1366
+ - [X] ActiveJob
1225
1367
  - [X] OpenTelemetry support
1226
1368
  - [X] locks
1227
1369
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module ActiveJob
6
+ # `Sidekiq::Worker` gives every job class `.perform_async` / `.perform_in`
7
+ # for free; ActiveJob only has `.perform_later`. Extending this onto an
8
+ # ActiveJob class normalizes its enqueue API to the same two class
9
+ # methods, so `RubyReactor::Worker` and `RubyReactor::SweeperJob` can keep
10
+ # calling `self.class.perform_in(...)` unchanged regardless of backend.
11
+ # Both methods return the job id (a String), matching what Sidekiq's
12
+ # native `perform_async`/`perform_in` return.
13
+ module Compat
14
+ def perform_async(*args)
15
+ perform_later(*args).job_id
16
+ end
17
+
18
+ def perform_in(delay, *args)
19
+ set(wait: delay).perform_later(*args).job_id
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ class MapCollectorWorker < ::ActiveJob::Base
9
+ extend Compat
10
+
11
+ queue_as { RubyReactor.configuration.queue_name }
12
+
13
+ def perform(arguments)
14
+ RubyReactor::Map::Collector.perform(arguments)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ class MapElementWorker < ::ActiveJob::Base
9
+ extend Compat
10
+
11
+ queue_as { RubyReactor.configuration.queue_name }
12
+
13
+ def perform(arguments)
14
+ RubyReactor::Map::ElementExecutor.perform(arguments)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module ActiveJob
6
+ class Router
7
+ # Identity-only payload: the worker rehydrates the live context from storage
8
+ # by (context_id, reactor_class_name). The caller already holds context_id, so
9
+ # there is no blob to deserialize here.
10
+ def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
11
+ job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
12
+ RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
13
+ execution_id: context_id)
14
+ end
15
+
16
+ def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
17
+ job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
18
+ RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
19
+ execution_id: context_id)
20
+ end
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
+
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
+ strict_ordering:, parent_context_id:, parent_reactor_class_name:,
40
+ step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
41
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
42
+ {
43
+ "map_id" => map_id,
44
+ "element_id" => element_id,
45
+ "index" => index,
46
+ "serialized_inputs" => serialized_inputs,
47
+ "reactor_class_info" => reactor_class_info,
48
+ "strict_ordering" => strict_ordering,
49
+ "parent_context_id" => parent_context_id,
50
+ "parent_reactor_class_name" => parent_reactor_class_name,
51
+ "step_name" => step_name,
52
+ "batch_size" => batch_size,
53
+ "serialized_context" => serialized_context,
54
+ "fail_fast" => fail_fast
55
+ }
56
+ )
57
+ RubyReactor::DispatchResult.new(job_id: job_id)
58
+ end
59
+
60
+ def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
61
+ reactor_class_info:, strict_ordering:, parent_context_id:,
62
+ parent_reactor_class_name:, step_name:, batch_size: nil,
63
+ serialized_context: nil, fail_fast: nil)
64
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
65
+ delay,
66
+ {
67
+ "map_id" => map_id,
68
+ "element_id" => element_id,
69
+ "index" => index,
70
+ "serialized_inputs" => serialized_inputs,
71
+ "reactor_class_info" => reactor_class_info,
72
+ "strict_ordering" => strict_ordering,
73
+ "parent_context_id" => parent_context_id,
74
+ "parent_reactor_class_name" => parent_reactor_class_name,
75
+ "step_name" => step_name,
76
+ "batch_size" => batch_size,
77
+ "serialized_context" => serialized_context,
78
+ "fail_fast" => fail_fast
79
+ }
80
+ )
81
+ # Return an DispatchResult so RetryManager#handle_async_retry recognises the
82
+ # element was successfully requeued and yields a RetryQueuedResult.
83
+ RubyReactor::DispatchResult.new(job_id: job_id)
84
+ end
85
+ # rubocop:enable Metrics/ParameterLists
86
+
87
+ # rubocop:disable Metrics/ParameterLists
88
+ def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
89
+ strict_ordering:, timeout:)
90
+ job_id = RubyReactor::Adapters::ActiveJob::MapCollectorWorker.perform_async(
91
+ {
92
+ "parent_context_id" => parent_context_id,
93
+ "map_id" => map_id,
94
+ "parent_reactor_class_name" => parent_reactor_class_name,
95
+ "step_name" => step_name,
96
+ "strict_ordering" => strict_ordering,
97
+ "timeout" => timeout
98
+ }
99
+ )
100
+ RubyReactor::DispatchResult.new(job_id: job_id)
101
+ end
102
+ # rubocop:enable Metrics/ParameterLists
103
+ end
104
+ end
105
+ end
106
+ 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
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ class SweeperWorker < ::ActiveJob::Base
9
+ extend Compat
10
+ include RubyReactor::SweeperJob
11
+
12
+ queue_as { RubyReactor.configuration.queue_name }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module ActiveJob
8
+ # ActiveJob worker for executing RubyReactor reactors asynchronously.
9
+ # All resume/snooze/escalate logic lives in `RubyReactor::Worker` — this
10
+ # class only wires it to ActiveJob.
11
+ #
12
+ # Infra-failure retries only: reactor-specific contention/config errors
13
+ # are already rescued inside `RubyReactor::Worker#perform` (snoozed or
14
+ # escalated to `failed`) before they'd ever reach this `retry_on`.
15
+ class Worker < ::ActiveJob::Base
16
+ extend Compat
17
+ include RubyReactor::Worker
18
+
19
+ queue_as { RubyReactor.configuration.queue_name }
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
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ class MapCollectorWorker
7
+ include ::Sidekiq::Worker
8
+
9
+ def perform(arguments)
10
+ RubyReactor::Map::Collector.perform(arguments)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ class MapElementWorker
7
+ include ::Sidekiq::Worker
8
+
9
+ def perform(arguments)
10
+ RubyReactor::Map::ElementExecutor.perform(arguments)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end