ruby_reactor 0.5.3 → 0.6.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 (72) 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 +14 -0
  41. data/README.md +146 -65
  42. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  43. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
  46. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  47. data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
  48. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  49. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
  53. data/lib/ruby_reactor/configuration.rb +24 -4
  54. data/lib/ruby_reactor/dsl/template_helpers.rb +7 -1
  55. data/lib/ruby_reactor/map/element_executor.rb +1 -1
  56. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  57. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  58. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  59. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  60. data/lib/ruby_reactor/rspec.rb +4 -0
  61. data/lib/ruby_reactor/step.rb +4 -0
  62. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  63. data/lib/ruby_reactor/version.rb +1 -1
  64. data/lib/ruby_reactor/worker.rb +226 -0
  65. data/lib/ruby_reactor.rb +40 -1
  66. data/specs/active_job.md +259 -0
  67. metadata +54 -6
  68. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  69. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  70. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  71. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  72. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
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
@@ -52,6 +52,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
52
52
  - [Installation](#installation)
53
53
  - [Configuration](#configuration)
54
54
  - [Quick Start](#quick-start)
55
+ - [Defining Steps](#defining-steps)
55
56
  - [Web Dashboard](#web-dashboard)
56
57
  - [Rails Installation](#rails-installation)
57
58
  - [Usage](#usage)
@@ -114,18 +115,21 @@ RubyReactor.configure do |config|
114
115
  ## Extra options passed to Redis.new. Default: {}.
115
116
  # config.storage.redis_options = { timeout: 1 }
116
117
 
117
- ## === Sidekiq ===
118
+ ## === Background job backend (Sidekiq by default, or ActiveJob) ===
118
119
 
119
- ## Sidekiq queue used by RubyReactor's async worker. Default: :default.
120
- # config.sidekiq_queue = :default
120
+ ## Queue used by RubyReactor's async worker. Default: :default.
121
+ # config.queue_name = :default
121
122
 
122
- ## Sidekiq retry count for infrastructure failures only (deserialization,
123
- ## Redis, network). Step retries are managed separately. Default: 3.
124
- # config.sidekiq_retry_count = 3
123
+ ## Retry count for infrastructure failures only (deserialization, Redis,
124
+ ## network). Step retries are managed separately. Default: 3.
125
+ # config.job_retry_count = 3
126
+
127
+ ## `sidekiq_queue` / `sidekiq_retry_count` still work as deprecated aliases
128
+ ## for `queue_name` / `job_retry_count` above.
125
129
 
126
130
  ## === Contention snooze (locks / semaphores / rate limits / ordered locks) ===
127
131
 
128
- ## When a Sidekiq worker cannot acquire a primitive it re-enqueues itself with
132
+ ## When a worker cannot acquire a primitive it re-enqueues itself with
129
133
  ## `lock_snooze_base_delay + rand(0..lock_snooze_jitter)` seconds (rate-limit
130
134
  ## uses a precise `retry_after_seconds` hint from the error; ordered-lock waits
131
135
  ## re-poll at the base delay so a successor catches its blocker finishing fast),
@@ -161,10 +165,11 @@ RubyReactor.configure do |config|
161
165
  ## Logger. Default: Logger.new($stdout).
162
166
  # config.logger = Logger.new($stdout)
163
167
 
164
- ## Async router. Default: RubyReactor::SidekiqAdapter. Swap for a custom adapter
165
- ## if you don't use Sidekiq it only needs to respond to
168
+ ## Async router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
169
+ ## built-in ActiveJob adapter (see "Async Execution" below), or point at any
170
+ ## custom adapter — it only needs to respond to
166
171
  ## `perform_async(context_id, reactor_class_name, **)`.
167
- # config.async_router = MyCustomAdapter
172
+ # config.async_router = RubyReactor::Adapters::ActiveJob::Router
168
173
 
169
174
  ## === Examples (no default — set these to use the feature) ===
170
175
 
@@ -180,8 +185,9 @@ You can also leave out the `configure` block entirely — defaults work for loca
180
185
 
181
186
  > **Crash recovery needs a kick.** The `sweeper_*` settings above only configure
182
187
  > the recovery sweeper — they do not start it. Call `RubyReactor.start_sweeper!`
183
- > once at boot (ideally from a Sidekiq `on(:startup)` hook) or no crashed reactor
184
- > will ever resume. See [Durability & Recovery](#durability--recovery).
188
+ > once at boot (ideally from a worker-process startup hook a Sidekiq
189
+ > `on(:startup)` hook, or a Rails initializer for ActiveJob) or no crashed
190
+ > reactor will ever resume. See [Durability & Recovery](#durability--recovery).
185
191
 
186
192
 
187
193
  ## Quick Start
@@ -198,6 +204,60 @@ result = HelloReactor.run
198
204
  puts result.value # => "Hello from Ruby Reactor!"
199
205
  ```
200
206
 
207
+ > **Note:** Examples in this README use inline `step` blocks where a step is trivial. For production workflows, prefer [class-based steps](#defining-steps).
208
+
209
+ ## Defining Steps
210
+
211
+ RubyReactor supports two ways to define step logic:
212
+
213
+ | Style | Best for |
214
+ |-------|----------|
215
+ | **Class steps** (preferred) | Real business logic, compensation/undo, shared steps, testability |
216
+ | **Inline blocks** | Quick prototypes, trivial one-liners, documentation examples |
217
+
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:
219
+
220
+ - **`Success(value)`** — step succeeded; `value` flows to dependent steps.
221
+ - **`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).
223
+
224
+ **Class steps** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
225
+
226
+ ```ruby
227
+ class ReserveInventoryStep
228
+ include RubyReactor::Step
229
+
230
+ def self.run(arguments, context)
231
+ reservation_id = InventoryService.reserve(arguments[:order][:items])
232
+ Success(reservation_id: reservation_id)
233
+ end
234
+
235
+ def self.compensate(error, arguments, context)
236
+ InventoryService.release_partial(arguments[:order][:items])
237
+ Success()
238
+ end
239
+
240
+ def self.undo(result, arguments, context)
241
+ InventoryService.release(result[:reservation_id])
242
+ Success()
243
+ end
244
+ end
245
+
246
+ class OrderProcessingReactor < RubyReactor::Reactor
247
+ step :reserve_inventory, ReserveInventoryStep do
248
+ argument :order, result(:validate_order)
249
+ end
250
+ end
251
+ ```
252
+
253
+ **Why prefer class steps?**
254
+
255
+ - **Testability** — unit-test `run`, `compensate`, and `undo` in isolation without booting the whole reactor
256
+ - **Composability** — share the same step class across multiple reactors and compose larger workflows from small, focused units
257
+ - **Readability** — reactor files stay orchestration-only; business logic lives in named classes instead of growing inline blocks
258
+
259
+ See [Core Concepts — Step Classes](documentation/core_concepts.md#step-classes-preferred) for the full reference. Usage examples below mix class and inline steps — inline where the logic is trivial.
260
+
201
261
  ## Web Dashboard
202
262
 
203
263
  RubyReactor ships with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps. The dashboard is a Rack app (a [Roda](https://roda.jeremyevans.net/) application) bundled inside the gem with its pre-compiled JS/CSS assets — no extra install or asset build step is required.
@@ -237,60 +297,59 @@ You can secure the dashboard using standard Rails authentication methods (e.g.,
237
297
 
238
298
  ## Usage
239
299
 
240
- RubyReactor allows you to define complex workflows as "reactors" with steps that can depend on each other, handle failures with compensations, and validate inputs.
300
+ RubyReactor allows you to define complex workflows as "reactors" with steps that can depend on each other, handle failures with compensations, and validate inputs. Examples in this section mix class steps with inline blocks; see [Defining Steps](#defining-steps) for guidance.
241
301
 
242
302
  ### Basic Example: User Registration
243
303
 
244
304
  ```ruby
245
305
  require 'ruby_reactor'
246
306
 
307
+ class ValidateEmailStep
308
+ include RubyReactor::Step
309
+
310
+ def self.run(arguments, _context)
311
+ email = arguments[:email]
312
+ email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
313
+ end
314
+ end
315
+
316
+ class CreateUserStep
317
+ include RubyReactor::Step
318
+
319
+ def self.run(arguments, _context)
320
+ Success(
321
+ id: rand(10000),
322
+ email: arguments[:email],
323
+ password_hash: arguments[:password_hash],
324
+ created_at: Time.now
325
+ )
326
+ end
327
+
328
+ def self.compensate(_error, arguments, _context)
329
+ Notify.to(arguments[:email])
330
+ Success()
331
+ end
332
+ end
333
+
247
334
  class UserRegistrationReactor < RubyReactor::Reactor
248
- # Define inputs with optional validation
249
335
  input :email
250
336
  input :password
251
337
 
252
- # Define steps with their dependencies
253
- step :validate_email do
338
+ step :validate_email, ValidateEmailStep do
254
339
  argument :email, input(:email)
255
-
256
- run do |args, context|
257
- if args[:email] && args[:email].include?('@')
258
- Success(args[:email].strip)
259
- else
260
- Failure("Email must contain @")
261
- end
262
- end
263
340
  end
264
341
 
265
342
  step :hash_password do
266
343
  argument :password, input(:password)
267
-
268
- run do |args, context|
344
+ run do |args, _context|
269
345
  require 'digest'
270
- hashed = Digest::SHA256.hexdigest(args[:password])
271
- Success(hashed)
346
+ Success(Digest::SHA256.hexdigest(args[:password]))
272
347
  end
273
348
  end
274
349
 
275
- step :create_user do
276
- # Arguments can reference results from other steps
350
+ step :create_user, CreateUserStep do
277
351
  argument :email, result(:validate_email)
278
352
  argument :password_hash, result(:hash_password)
279
-
280
- run do |args, context|
281
- user = {
282
- id: rand(10000),
283
- email: args[:email],
284
- password_hash: args[:password_hash],
285
- created_at: Time.now
286
- }
287
- Success(user)
288
- end
289
-
290
- compensate do |error, args, context|
291
- Notify.to(args[:email])
292
- Success()
293
- end
294
353
  end
295
354
 
296
355
  step :notify_user do
@@ -302,12 +361,12 @@ class UserRegistrationReactor < RubyReactor::Reactor
302
361
  Success()
303
362
  end
304
363
 
305
- compensate do |error, args, context|
364
+ compensate do |_error, args, _context|
306
365
  Email.send("support@acme.com", "Email verification for #{args[:email]} couldn't be sent")
307
366
  Success()
308
367
  end
309
368
  end
310
- # Specify which step's result to return
369
+
311
370
  returns :create_user
312
371
  end
313
372
 
@@ -326,7 +385,18 @@ end
326
385
 
327
386
  ### Async Execution
328
387
 
329
- Execute reactors in the background using Sidekiq.
388
+ Execute reactors in the background using Sidekiq or ActiveJob. The backend is
389
+ chosen via `config.async_router` (defaults to the Sidekiq adapter); to run on
390
+ ActiveJob instead:
391
+
392
+ ```ruby
393
+ RubyReactor.configure do |config|
394
+ config.async_router = RubyReactor::Adapters::ActiveJob::Router
395
+ end
396
+ ```
397
+
398
+ That's the only switch — everything below (full-reactor async, step-level
399
+ async, durability, retries, snoozing) works identically on either backend.
330
400
 
331
401
  #### Full Reactor Async
332
402
 
@@ -401,8 +471,8 @@ reactor only resumes when the recovery sweeper notices the lapsed liveness lock
401
471
  and re-enqueues it. The sweeper is a self-rescheduling chain — **kick it once per
402
472
  process boot:**
403
473
 
404
- The recommended spot is a Sidekiq server startup hook, so only the worker
405
- process runs recovery (not your web/console/client processes):
474
+ The recommended spot is a worker-process startup hook, so only the worker
475
+ process runs recovery (not your web/console/client processes). On Sidekiq:
406
476
 
407
477
  ```ruby
408
478
  # config/initializers/sidekiq.rb
@@ -411,7 +481,18 @@ Sidekiq.configure_server do |config|
411
481
  end
412
482
  ```
413
483
 
414
- Anywhere that runs once at boot works too e.g. a Rails initializer:
484
+ ActiveJob has no equivalent server-only hook call it from wherever your
485
+ queue adapter's worker process boots (e.g. `bin/jobs` for Solid Queue, or a
486
+ dedicated initializer guarded by an env var so it doesn't also run in web
487
+ processes):
488
+
489
+ ```ruby
490
+ # config/initializers/ruby_reactor.rb
491
+ RubyReactor.start_sweeper! if ENV["RUBY_REACTOR_WORKER"]
492
+ ```
493
+
494
+ Anywhere that runs once at boot works too (idempotent, so it's safe even if
495
+ every process calls it) — e.g. an unconditional Rails initializer:
415
496
 
416
497
  ```ruby
417
498
  # config/initializers/ruby_reactor.rb
@@ -545,7 +626,7 @@ class ChargeReactor < RubyReactor::Reactor
545
626
 
546
627
  # Respect upstream Stripe rate limits: 3/sec and 100/min.
547
628
  # Async workers snooze for exactly retry_after seconds instead of
548
- # consuming Sidekiq retry budget.
629
+ # consuming the backend's retry budget.
549
630
  with_rate_limit(
550
631
  limits: { second: 3, minute: 100 }
551
632
  ) { |inputs| "stripe:#{inputs[:account_id]}" }
@@ -603,7 +684,7 @@ Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::Unknown
603
684
  On contention:
604
685
 
605
686
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
606
- - **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).
687
+ - **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).
607
688
 
608
689
  On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
609
690
 
@@ -613,14 +694,14 @@ result.success? # true (Skipped is a Success subclass)
613
694
  result.skipped? # true on dedup hit, false otherwise
614
695
  ```
615
696
 
616
- A step's `run` block can also return `RubyReactor.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").
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(...)`).
617
698
 
618
699
  ```ruby
619
700
  step :ensure_active do
620
701
  argument :user, result(:fetch_user)
621
- run do |args|
622
- next RubyReactor.Skipped(reason: "user_opted_out") if args[:user].opted_out?
623
- RubyReactor.Success(args[:user])
702
+ run do |args, _ctx|
703
+ next Skipped(reason: "user_opted_out") if args[:user].opted_out?
704
+ Success(args[:user])
624
705
  end
625
706
  end
626
707
  ```
@@ -1089,7 +1170,7 @@ end
1089
1170
 
1090
1171
  ### Testing
1091
1172
 
1092
- RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation.
1173
+ RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation — including [unit-testing class-based steps](documentation/testing.md#testing-step-classes) directly.
1093
1174
 
1094
1175
  ```ruby
1095
1176
  RSpec.describe PaymentReactor do
@@ -1116,13 +1197,13 @@ end
1116
1197
  For detailed documentation, see the following guides:
1117
1198
 
1118
1199
  ### [Core Concepts](documentation/core_concepts.md)
1119
- Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Context, and Results. Understand how steps are defined, how data flows between them, and how the context maintains state throughout execution.
1200
+ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Context, and Results. Covers class-based steps (the preferred approach) and inline blocks, how data flows between steps, and how the context maintains state throughout execution.
1120
1201
 
1121
1202
  ### [DAG (Directed Acyclic Graph)](documentation/DAG.md)
1122
1203
  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.
1123
1204
 
1124
1205
  ### [Async Reactors](documentation/async_reactors.md)
1125
- 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.
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.
1126
1207
 
1127
1208
  ### [Composition](documentation/composition.md)
1128
1209
  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.
@@ -1165,9 +1246,9 @@ Hook into the execution lifecycle with observer middlewares. Covers the full set
1165
1246
  - [ ] Multiple storage adapters
1166
1247
  - [X] Redis
1167
1248
  - [ ] ActiveRecord
1168
- - [ ] Multiple Async adapters
1249
+ - [X] Multiple Async adapters
1169
1250
  - [X] Sidekiq
1170
- - [ ] ActiveJob
1251
+ - [X] ActiveJob
1171
1252
  - [X] OpenTelemetry support
1172
1253
  - [X] locks
1173
1254
 
@@ -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,91 @@
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::AsyncResult.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::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
19
+ execution_id: context_id)
20
+ end
21
+
22
+ # rubocop:disable Metrics/ParameterLists
23
+ def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
24
+ strict_ordering:, parent_context_id:, parent_reactor_class_name:,
25
+ step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
26
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
27
+ {
28
+ "map_id" => map_id,
29
+ "element_id" => element_id,
30
+ "index" => index,
31
+ "serialized_inputs" => serialized_inputs,
32
+ "reactor_class_info" => reactor_class_info,
33
+ "strict_ordering" => strict_ordering,
34
+ "parent_context_id" => parent_context_id,
35
+ "parent_reactor_class_name" => parent_reactor_class_name,
36
+ "step_name" => step_name,
37
+ "batch_size" => batch_size,
38
+ "serialized_context" => serialized_context,
39
+ "fail_fast" => fail_fast
40
+ }
41
+ )
42
+ RubyReactor::AsyncResult.new(job_id: job_id)
43
+ end
44
+
45
+ def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
46
+ reactor_class_info:, strict_ordering:, parent_context_id:,
47
+ parent_reactor_class_name:, step_name:, batch_size: nil,
48
+ serialized_context: nil, fail_fast: nil)
49
+ job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
50
+ delay,
51
+ {
52
+ "map_id" => map_id,
53
+ "element_id" => element_id,
54
+ "index" => index,
55
+ "serialized_inputs" => serialized_inputs,
56
+ "reactor_class_info" => reactor_class_info,
57
+ "strict_ordering" => strict_ordering,
58
+ "parent_context_id" => parent_context_id,
59
+ "parent_reactor_class_name" => parent_reactor_class_name,
60
+ "step_name" => step_name,
61
+ "batch_size" => batch_size,
62
+ "serialized_context" => serialized_context,
63
+ "fail_fast" => fail_fast
64
+ }
65
+ )
66
+ # Return an AsyncResult so RetryManager#handle_async_retry recognises the
67
+ # element was successfully requeued and yields a RetryQueuedResult.
68
+ RubyReactor::AsyncResult.new(job_id: job_id)
69
+ end
70
+ # rubocop:enable Metrics/ParameterLists
71
+
72
+ # rubocop:disable Metrics/ParameterLists
73
+ def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
74
+ strict_ordering:, timeout:)
75
+ job_id = RubyReactor::Adapters::ActiveJob::MapCollectorWorker.perform_async(
76
+ {
77
+ "parent_context_id" => parent_context_id,
78
+ "map_id" => map_id,
79
+ "parent_reactor_class_name" => parent_reactor_class_name,
80
+ "step_name" => step_name,
81
+ "strict_ordering" => strict_ordering,
82
+ "timeout" => timeout
83
+ }
84
+ )
85
+ RubyReactor::AsyncResult.new(job_id: job_id)
86
+ end
87
+ # rubocop:enable Metrics/ParameterLists
88
+ end
89
+ end
90
+ end
91
+ 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,24 @@
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
21
+ end
22
+ end
23
+ end
24
+ 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