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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +14 -0
- data/README.md +146 -65
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
- data/lib/ruby_reactor/configuration.rb +24 -4
- data/lib/ruby_reactor/dsl/template_helpers.rb +7 -1
- data/lib/ruby_reactor/map/element_executor.rb +1 -1
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
- data/lib/ruby_reactor/rspec.rb +4 -0
- data/lib/ruby_reactor/step.rb +4 -0
- data/lib/ruby_reactor/sweeper_job.rb +70 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/worker.rb +226 -0
- data/lib/ruby_reactor.rb +40 -1
- data/specs/active_job.md +259 -0
- metadata +54 -6
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- 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
|

|
|
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
|
|
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
|
-
##
|
|
120
|
-
# config.
|
|
120
|
+
## Queue used by RubyReactor's async worker. Default: :default.
|
|
121
|
+
# config.queue_name = :default
|
|
121
122
|
|
|
122
|
-
##
|
|
123
|
-
##
|
|
124
|
-
# config.
|
|
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
|
|
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::
|
|
165
|
-
##
|
|
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 =
|
|
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
|
|
184
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
623
|
-
|
|
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.
|
|
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
|
-
- [
|
|
1249
|
+
- [X] Multiple Async adapters
|
|
1169
1250
|
- [X] Sidekiq
|
|
1170
|
-
- [
|
|
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
|