ruby_reactor 0.8.0 → 0.8.2
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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +17 -3
- data/README.md +47 -33
- data/lib/ruby_reactor/adapters/active_job/router.rb +2 -2
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +2 -2
- data/lib/ruby_reactor/dsl/map_builder.rb +31 -5
- data/lib/ruby_reactor/map/helpers.rb +9 -0
- data/lib/ruby_reactor/rspec/helpers.rb +4 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +203 -46
- data/lib/ruby_reactor/rspec.rb +8 -1
- data/lib/ruby_reactor/step/map_step.rb +13 -8
- data/lib/ruby_reactor/version.rb +1 -1
- data/specs/scoped_rspec_matchers.md +158 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31b3ac286396e1d29f1bad40e87f4f55b8ecc3e6c715e6cfd2ca0496d9c8dd0c
|
|
4
|
+
data.tar.gz: d518e5aceb0e5fa6dd43007e1f324612c84a53528e73a76463db7ecb652f0a95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e91db7e2c548b834f285fe514747bc407b29fbb4cdb10e4aef44b05ff556d518ccf77ad4d02fc3e097907d7298843bc32b7e5a57d4f4a8ae687795dc702fa71
|
|
7
|
+
data.tar.gz: 6a2252e7fdf4740f1d297f2855bb60f07c4b66e0a551c8d90e4394fd1acafd829098323d40afb4f9bc650c76a53818d76566c70f65c89c4b60ed11f9018a3d48
|
data/CHANGELOG.md
CHANGED
|
@@ -129,6 +129,20 @@
|
|
|
129
129
|
ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
|
|
130
130
|
already hands over a `Failure`.
|
|
131
131
|
|
|
132
|
+
## [0.8.2](https://github.com/arturictus/ruby_reactor/compare/v0.8.1...v0.8.2) (2026-09-22)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
### Features
|
|
136
|
+
|
|
137
|
+
* Enhance mocking capabilities for nested reactors with scoped APIs and examples ([#58](https://github.com/arturictus/ruby_reactor/issues/58)) ([a462265](https://github.com/arturictus/ruby_reactor/commit/a462265b233f94f07fc39628b59a2bb038744fc9))
|
|
138
|
+
|
|
139
|
+
## [0.8.1](https://github.com/arturictus/ruby_reactor/compare/v0.8.0...v0.8.1) (2026-09-22)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
### Miscellaneous Chores
|
|
143
|
+
|
|
144
|
+
* Update documentation clarifying background and async concepts ([#55](https://github.com/arturictus/ruby_reactor/issues/55)) ([968e32b](https://github.com/arturictus/ruby_reactor/commit/968e32bbc695e45471d04b7630d8cd437ff41624))
|
|
145
|
+
|
|
132
146
|
## [0.8.0](https://github.com/arturictus/ruby_reactor/compare/v0.7.1...v0.8.0) (2026-09-21)
|
|
133
147
|
|
|
134
148
|
|
|
@@ -198,9 +212,9 @@
|
|
|
198
212
|
point (`:x` stays in the calling process); the two coincide in a linear chain
|
|
199
213
|
but pin different steps in a DAG.
|
|
200
214
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
215
|
+
Also affected: the map-internal element dispatch option was renamed from
|
|
216
|
+
`async true` to `fan_out`, so `map :items do async true, batch_size: 2 end`
|
|
217
|
+
now raises and should become `map :items do fan_out batch_size: 2 end`.
|
|
204
218
|
|
|
205
219
|
One behavior change falls out of "exactly one hand-off point per reactor":
|
|
206
220
|
resuming a reactor past its hand-off point now finishes in the resuming
|
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 or ActiveJob** for
|
|
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 background execution and **Redis** for state persistence.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -18,13 +18,14 @@ 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
|
-
- **
|
|
21
|
+
- **Background Execution**: Hand a whole reactor, or everything after a declared step, to a background job via Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
|
|
22
|
+
- **Async Steps & Reactors**: `async_step` and `async_reactor` dispatch independent units of work while the reactor keeps running; steps that read their result wait for it.
|
|
22
23
|
- **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
|
|
23
24
|
- **Retries**: Configurable retry logic for failed steps, with exponential backoff.
|
|
24
25
|
- **Compensation**: Automatic rollback of completed steps when a failure occurs.
|
|
25
26
|
- **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
|
|
26
27
|
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
27
|
-
- **Distributed Locks, Semaphores, Rate Limits, Periods & Ordered Locks**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), `with_period` to dedup reactors to once per calendar bucket, and `with_ordered_lock` for strict transaction ordering via a monotonically increasing nonce assigned at enqueue.
|
|
28
|
+
- **Distributed Locks, Semaphores, Rate Limits, Periods & Ordered Locks**: Coordinate across processes with Redis-backed primitives — exclusive locks for at-most-one-runner, semaphores for capacity caps, fixed-window rate limits for external APIs (single or multi-window like "3/sec AND 100/min"), `with_period` to dedup reactors to once per calendar bucket, and `with_ordered_lock` for strict transaction ordering via a monotonically increasing nonce assigned at enqueue. Background jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
|
|
28
29
|
|
|
29
30
|
## Comparison
|
|
30
31
|
|
|
@@ -35,7 +36,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
35
36
|
| Interrupts (pause/resume)| Yes | No | No | Manual |
|
|
36
37
|
| Locks / sem / rate / per | Yes | No | No | Manual |
|
|
37
38
|
| Built-in web dashboard | Yes | No | No | No |
|
|
38
|
-
|
|
|
39
|
+
| Background (Sidekiq/AJ) | Yes | No | Limited | Yes |
|
|
39
40
|
| Durable crash recovery | Yes | No | No | Manual |
|
|
40
41
|
|
|
41
42
|
## Real-World Use Cases
|
|
@@ -57,9 +58,10 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
|
|
|
57
58
|
- [Rails Installation](#rails-installation)
|
|
58
59
|
- [Usage](#usage)
|
|
59
60
|
- [Basic Example: User Registration](#basic-example-user-registration)
|
|
60
|
-
- [
|
|
61
|
-
- [Full
|
|
61
|
+
- [Background Execution](#background-execution)
|
|
62
|
+
- [Full Background](#full-background)
|
|
62
63
|
- [Background Hand-off](#background-hand-off)
|
|
64
|
+
- [Async Steps & Reactors](#async-steps--reactors)
|
|
63
65
|
- [`async_step`](#async_step-one-step-dispatched-on-its-own)
|
|
64
66
|
- [`async_reactor`](#async_reactor-a-whole-nested-reactor-running-independently)
|
|
65
67
|
- [Durability & Recovery](#durability--recovery)
|
|
@@ -119,7 +121,7 @@ RubyReactor.configure do |config|
|
|
|
119
121
|
|
|
120
122
|
## === Background job backend (Sidekiq by default, or ActiveJob) ===
|
|
121
123
|
|
|
122
|
-
## Queue used by RubyReactor's
|
|
124
|
+
## Queue used by RubyReactor's background worker. Default: :default.
|
|
123
125
|
# config.queue_name = :default
|
|
124
126
|
|
|
125
127
|
## Retry count for infrastructure failures only (deserialization, Redis,
|
|
@@ -180,8 +182,8 @@ RubyReactor.configure do |config|
|
|
|
180
182
|
## Logger. Default: Logger.new($stdout).
|
|
181
183
|
# config.logger = Logger.new($stdout)
|
|
182
184
|
|
|
183
|
-
##
|
|
184
|
-
## built-in ActiveJob adapter (see "
|
|
185
|
+
## Background job router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
|
|
186
|
+
## built-in ActiveJob adapter (see "Background Execution" below), or point at any
|
|
185
187
|
## custom adapter — it only needs to respond to
|
|
186
188
|
## `perform_async(context_id, reactor_class_name, **)`.
|
|
187
189
|
# config.async_router = RubyReactor::Adapters::ActiveJob::Router
|
|
@@ -398,7 +400,7 @@ else
|
|
|
398
400
|
end
|
|
399
401
|
```
|
|
400
402
|
|
|
401
|
-
###
|
|
403
|
+
### Background Execution
|
|
402
404
|
|
|
403
405
|
Execute reactors in the background using Sidekiq or ActiveJob. The backend is
|
|
404
406
|
chosen via `config.async_router` (defaults to the Sidekiq adapter); to run on
|
|
@@ -410,13 +412,14 @@ RubyReactor.configure do |config|
|
|
|
410
412
|
end
|
|
411
413
|
```
|
|
412
414
|
|
|
413
|
-
That's the only switch — everything below (
|
|
414
|
-
|
|
415
|
+
That's the only switch — everything below (background execution, `async_step`
|
|
416
|
+
/ `async_reactor`, durability, retries, snoozing) works identically on either
|
|
417
|
+
backend.
|
|
415
418
|
|
|
416
|
-
#### Full
|
|
419
|
+
#### Full Background
|
|
417
420
|
|
|
418
421
|
```ruby
|
|
419
|
-
class
|
|
422
|
+
class BackgroundReactor < RubyReactor::Reactor
|
|
420
423
|
background all: true # Entire reactor runs in background
|
|
421
424
|
|
|
422
425
|
step :long_running_task do
|
|
@@ -425,7 +428,7 @@ class AsyncReactor < RubyReactor::Reactor
|
|
|
425
428
|
end
|
|
426
429
|
|
|
427
430
|
# Returns immediately with DispatchResult
|
|
428
|
-
result =
|
|
431
|
+
result = BackgroundReactor.run(params)
|
|
429
432
|
```
|
|
430
433
|
|
|
431
434
|
#### Background Hand-off
|
|
@@ -493,6 +496,13 @@ changes where code runs, not the saga contract.
|
|
|
493
496
|
> to `async_step`/`async_reactor`, whose names mean something else. It now raises
|
|
494
497
|
> at class-definition time. The exact replacement is `background all: true`.
|
|
495
498
|
|
|
499
|
+
### Async Steps & Reactors
|
|
500
|
+
|
|
501
|
+
`async_step` and `async_reactor` dispatch an **independent** unit of work to its
|
|
502
|
+
own job while the reactor keeps running. Unlike `background`, they do not move
|
|
503
|
+
the reactor itself — they are separate units with their own outcome and
|
|
504
|
+
compensation.
|
|
505
|
+
|
|
496
506
|
#### `async_step`: one step, dispatched on its own
|
|
497
507
|
|
|
498
508
|
Where `background` relocates the *rest* of a reactor, `async_step` dispatches one
|
|
@@ -569,12 +579,12 @@ parent back.
|
|
|
569
579
|
|
|
570
580
|
Lock ownership is never shared across the async boundary: a child declaring a key
|
|
571
581
|
the parent holds fails at dispatch with an explanatory error rather than
|
|
572
|
-
deadlocking. See [Async
|
|
582
|
+
deadlocking. See [Background & Async Execution](documentation/background_and_async.md) for the full
|
|
573
583
|
rules.
|
|
574
584
|
|
|
575
585
|
### Durability & Recovery
|
|
576
586
|
|
|
577
|
-
|
|
587
|
+
Background reactors are durable: state lives in Redis, not in the job payload. Before
|
|
578
588
|
any background job is enqueued the root context is persisted, and after every
|
|
579
589
|
completed step a checkpoint advances the stored blob — so a crash re-runs at most
|
|
580
590
|
one step, never the whole reactor. Each running reactor also holds a short
|
|
@@ -740,7 +750,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
740
750
|
input :account_id
|
|
741
751
|
|
|
742
752
|
# Respect upstream Stripe rate limits: 3/sec and 100/min.
|
|
743
|
-
#
|
|
753
|
+
# Background workers snooze for exactly retry_after seconds instead of
|
|
744
754
|
# consuming the backend's retry budget.
|
|
745
755
|
with_rate_limit(
|
|
746
756
|
limits: { second: 3, minute: 100 }
|
|
@@ -753,7 +763,7 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
753
763
|
end
|
|
754
764
|
|
|
755
765
|
class OrderedTransactionReactor < RubyReactor::Reactor
|
|
756
|
-
|
|
766
|
+
background all: true
|
|
757
767
|
input :account_id
|
|
758
768
|
input :transaction
|
|
759
769
|
|
|
@@ -794,12 +804,12 @@ class ChargeReactor < RubyReactor::Reactor
|
|
|
794
804
|
end
|
|
795
805
|
```
|
|
796
806
|
|
|
797
|
-
Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so
|
|
807
|
+
Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so background snooze behavior is identical.
|
|
798
808
|
|
|
799
809
|
On contention:
|
|
800
810
|
|
|
801
811
|
- **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
|
|
802
|
-
- **
|
|
812
|
+
- **Background** (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).
|
|
803
813
|
|
|
804
814
|
On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
|
|
805
815
|
|
|
@@ -847,8 +857,8 @@ class DataProcessingReactor < RubyReactor::Reactor
|
|
|
847
857
|
source input(:items)
|
|
848
858
|
argument :item, element(:process_items)
|
|
849
859
|
|
|
850
|
-
#
|
|
851
|
-
|
|
860
|
+
# Run elements as background jobs, in batches
|
|
861
|
+
fan_out batch_size: 50
|
|
852
862
|
|
|
853
863
|
step :transform do
|
|
854
864
|
argument :item, input(:item)
|
|
@@ -860,9 +870,13 @@ class DataProcessingReactor < RubyReactor::Reactor
|
|
|
860
870
|
end
|
|
861
871
|
```
|
|
862
872
|
|
|
863
|
-
|
|
873
|
+
A `fan_out` map is a **hand-off point**: the reactor stops at the map (the caller gets a `DispatchResult`), every element runs as its own background job, and once all outcomes are collected the reactor resumes in a worker with the steps after the map. It fans out the same way when the reactor is already running in a worker (e.g. `background all: true`).
|
|
874
|
+
|
|
875
|
+
By using `fan_out` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
|
|
876
|
+
|
|
877
|
+
`batch_size` is optional: with `fan_out` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [`fan_out` Without `batch_size`](documentation/data_pipelines.md#fan_out-without-batch_size).
|
|
864
878
|
|
|
865
|
-
|
|
879
|
+
> **Breaking change:** `async true` inside a `map` block has been **removed** — it read like `async_step`/`async_reactor`, which dispatch independent units the reactor does not stop for. It now raises at class-definition time. The exact replacement is `fan_out` (`fan_out batch_size: N`).
|
|
866
880
|
|
|
867
881
|
#### Map with Dynamic Source (ActiveRecord)
|
|
868
882
|
|
|
@@ -878,7 +892,7 @@ map :archive_old_users do
|
|
|
878
892
|
end
|
|
879
893
|
|
|
880
894
|
argument :user, element(:archive_old_users)
|
|
881
|
-
|
|
895
|
+
fan_out batch_size: 100
|
|
882
896
|
|
|
883
897
|
step :archive do
|
|
884
898
|
argument :user, input(:user)
|
|
@@ -1397,7 +1411,7 @@ end
|
|
|
1397
1411
|
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.
|
|
1398
1412
|
|
|
1399
1413
|
```ruby
|
|
1400
|
-
RSpec.describe PaymentReactor do
|
|
1414
|
+
RSpec.describe PaymentReactor, type: :reactor do
|
|
1401
1415
|
it "processes payment successfully" do
|
|
1402
1416
|
subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
|
|
1403
1417
|
|
|
@@ -1426,8 +1440,8 @@ Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Con
|
|
|
1426
1440
|
### [DAG (Directed Acyclic Graph)](documentation/DAG.md)
|
|
1427
1441
|
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.
|
|
1428
1442
|
|
|
1429
|
-
### [Async
|
|
1430
|
-
Explore the ways to move work off the calling process:
|
|
1443
|
+
### [Background & Async Execution](documentation/background_and_async.md)
|
|
1444
|
+
Explore the ways to move work off the calling process: background execution (`background all: true` and the `background after:`/`before:` hand-off) and async units (`async_step`, `async_reactor`). Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
|
|
1431
1445
|
|
|
1432
1446
|
### [Composition](documentation/composition.md)
|
|
1433
1447
|
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.
|
|
@@ -1436,7 +1450,7 @@ Discover how to build complex, modular workflows by composing reactors within ot
|
|
|
1436
1450
|
Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
|
|
1437
1451
|
|
|
1438
1452
|
### [Retry Configuration](documentation/retry_configuration.md)
|
|
1439
|
-
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to configure retries at the reactor or step level, and how
|
|
1453
|
+
Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to configure retries at the reactor or step level, and how background retries work without blocking workers.
|
|
1440
1454
|
|
|
1441
1455
|
### [Interrupts](documentation/interrupts.md)
|
|
1442
1456
|
Learn how to pause and resume reactors to handle long-running processes, manual approvals, and asynchronous callbacks. Patterns for correlation IDs, timeouts, and payload validation.
|
|
@@ -1446,11 +1460,11 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
|
|
|
1446
1460
|
|
|
1447
1461
|
### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
|
|
1448
1462
|
|
|
1449
|
-
Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-
|
|
1463
|
+
Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-background contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
|
|
1450
1464
|
|
|
1451
1465
|
### [Middlewares & OpenTelemetry](documentation/middlewares.md)
|
|
1452
1466
|
|
|
1453
|
-
Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo,
|
|
1467
|
+
Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo, background hand-off, locks/semaphores), writing and registering custom middlewares (global and per-reactor), and the built-in `RubyReactor::OpenTelemetry` tracing middleware — span structure, input/argument redaction, distributed trace propagation across background/retry boundaries, and custom exporters.
|
|
1454
1468
|
|
|
1455
1469
|
### Examples
|
|
1456
1470
|
- [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
|
|
@@ -1470,7 +1484,7 @@ Hook into the execution lifecycle with observer middlewares. Covers the full set
|
|
|
1470
1484
|
- [ ] Multiple storage adapters
|
|
1471
1485
|
- [X] Redis
|
|
1472
1486
|
- [ ] ActiveRecord
|
|
1473
|
-
- [X] Multiple
|
|
1487
|
+
- [X] Multiple background job adapters
|
|
1474
1488
|
- [X] Sidekiq
|
|
1475
1489
|
- [X] ActiveJob
|
|
1476
1490
|
- [X] OpenTelemetry support
|
|
@@ -10,13 +10,13 @@ module RubyReactor
|
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
|
|
12
12
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
-
|
|
13
|
+
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
18
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
-
|
|
19
|
+
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# One `async_step`'s work, dispatched as its own independent unit. The
|
|
@@ -10,13 +10,13 @@ module RubyReactor
|
|
|
10
10
|
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
11
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
|
|
12
12
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
-
|
|
13
|
+
execution_id: context_id)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
17
|
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
18
|
RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
-
|
|
19
|
+
execution_id: context_id)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# One `async_step`'s work, dispatched as its own independent unit. The
|
|
@@ -20,7 +20,7 @@ module RubyReactor
|
|
|
20
20
|
end
|
|
21
21
|
@reactor = reactor
|
|
22
22
|
@argument_mappings = {}
|
|
23
|
-
@
|
|
23
|
+
@fan_out = false
|
|
24
24
|
@strict_ordering = true
|
|
25
25
|
@batch_size = nil
|
|
26
26
|
@source_enumerable = nil
|
|
@@ -40,9 +40,28 @@ module RubyReactor
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# Run every element as its own background job. `batch_size` caps how many
|
|
44
|
+
# element jobs are enqueued at a time (back pressure); without it the whole
|
|
45
|
+
# source fans out at once. A fan-out map is a hand-off point: the reactor
|
|
46
|
+
# stops at the map and resumes in a worker once the collector has every
|
|
47
|
+
# element's outcome.
|
|
48
|
+
def fan_out(enabled = true, batch_size: nil)
|
|
49
|
+
@fan_out = enabled
|
|
50
|
+
self.batch_size(batch_size) unless batch_size.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `async` on a map named element fan-out with the word that now means
|
|
54
|
+
# `async_step` / `async_reactor` — independent units the reactor does not
|
|
55
|
+
# stop for. A fan-out map does stop the reactor, so it gets its own word.
|
|
56
|
+
def async(*)
|
|
57
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
58
|
+
"`async` inside a `map` block has been removed: it read as `async_step`/`async_reactor`, which " \
|
|
59
|
+
"dispatch independent units, whereas a fan-out map hands the reactor off until every element " \
|
|
60
|
+
"finishes. Use `fan_out` instead (`fan_out batch_size: N` for back pressure) — note this also " \
|
|
61
|
+
"changes worker dispatch: each element now runs in its own background worker rather than being " \
|
|
62
|
+
"suppressed inline by `inline_async_execution` as the old `async true` was.",
|
|
63
|
+
step: @name
|
|
64
|
+
)
|
|
46
65
|
end
|
|
47
66
|
|
|
48
67
|
def strict_ordering(enabled = true)
|
|
@@ -50,6 +69,13 @@ module RubyReactor
|
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
def batch_size(size)
|
|
72
|
+
unless size.is_a?(Integer) && size.positive?
|
|
73
|
+
raise RubyReactor::Error::ValidationError.new(
|
|
74
|
+
"`batch_size` must be a positive Integer, got #{size.inspect}",
|
|
75
|
+
step: @name
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
53
79
|
@batch_size = size
|
|
54
80
|
end
|
|
55
81
|
|
|
@@ -98,7 +124,7 @@ module RubyReactor
|
|
|
98
124
|
batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
|
|
99
125
|
collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
|
|
100
126
|
fail_fast: { source: RubyReactor::Template::Value.new(@fail_fast) },
|
|
101
|
-
|
|
127
|
+
fan_out: { source: RubyReactor::Template::Value.new(@fan_out) }
|
|
102
128
|
},
|
|
103
129
|
run_block: nil,
|
|
104
130
|
compensate_block: nil,
|
|
@@ -107,6 +107,15 @@ module RubyReactor
|
|
|
107
107
|
)
|
|
108
108
|
|
|
109
109
|
parent_context.current_step = nil
|
|
110
|
+
|
|
111
|
+
# Mark the context as a worker before resuming: resume_execution runs
|
|
112
|
+
# the collector's own worker (this call), not the reactor's normal
|
|
113
|
+
# caller. Without this, StepExecutor#handoff_at? (gated on
|
|
114
|
+
# `inline_async_execution`) re-evaluates any later `background`
|
|
115
|
+
# before:/after: cut point and enqueues a second hand-off, or runs an
|
|
116
|
+
# `after:` target here in the collector instead of the original
|
|
117
|
+
# dispatching worker.
|
|
118
|
+
parent_context.inline_async_execution = true
|
|
110
119
|
executor.resume_execution
|
|
111
120
|
end
|
|
112
121
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module RSpec
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Entry-point helpers (`test_reactor`), auto-included into examples tagged
|
|
6
|
+
# `type: :reactor`. Specs that want them outside that tag should
|
|
7
|
+
# `include RubyReactor::RSpec::Helpers` explicitly. Sidekiq-coupled helpers
|
|
8
|
+
# live in `SidekiqHelpers`, scoped the same way.
|
|
8
9
|
module Helpers
|
|
9
10
|
# Build a `TestSubject` around a reactor invocation. Captures the run for
|
|
10
11
|
# later introspection via matchers; runs the reactor lazily on first
|
|
@@ -48,44 +48,93 @@ module RubyReactor
|
|
|
48
48
|
self
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Fluent API for mocking nested map steps
|
|
52
|
-
#
|
|
53
|
-
#
|
|
51
|
+
# Fluent/scoped API for mocking nested map steps.
|
|
52
|
+
#
|
|
53
|
+
# Without a block, returns a proxy scoped to this map step — chain
|
|
54
|
+
# `mock_step`/`failing_at`/`map`/`composed` on it to configure several
|
|
55
|
+
# things inside the same nested reactor:
|
|
56
|
+
# reactor.map(:my_map).mock_step(:a) { ... }.mock_step(:b) { ... }
|
|
57
|
+
#
|
|
58
|
+
# With a block, the proxy is yielded and the *outer* subject is
|
|
59
|
+
# returned, so sibling nested reactors can be configured in the same
|
|
60
|
+
# chain without leaking scope between them:
|
|
61
|
+
# test_reactor(Parent, params)
|
|
62
|
+
# .map(:my_map) { |m| m.mock_step(:a) { ... } }
|
|
63
|
+
# .composed(:other_child) { |c| c.mock_step(:b) { ... } }
|
|
54
64
|
def map(step_name)
|
|
55
|
-
StepProxy.new(self, step_name)
|
|
65
|
+
proxy = StepProxy.new(self, [step_name])
|
|
66
|
+
return proxy unless block_given?
|
|
67
|
+
|
|
68
|
+
yield proxy
|
|
69
|
+
self
|
|
56
70
|
end
|
|
57
71
|
|
|
58
|
-
# Fluent API for mocking nested compose steps
|
|
72
|
+
# Fluent/scoped API for mocking nested compose steps. See `#map` for
|
|
73
|
+
# the block vs. no-block behavior.
|
|
59
74
|
# @example
|
|
60
|
-
# reactor.
|
|
75
|
+
# reactor.composed(:my_sub_reactor).mock_step(:inner_step) { ... }
|
|
61
76
|
def composed(step_name)
|
|
62
77
|
# If already executed, return the traversed subject
|
|
63
78
|
return traverse_composed(step_name) if @executed
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
proxy = StepProxy.new(self, [step_name])
|
|
81
|
+
return proxy unless block_given?
|
|
82
|
+
|
|
83
|
+
yield proxy
|
|
84
|
+
self
|
|
67
85
|
end
|
|
68
86
|
alias compose composed
|
|
69
87
|
|
|
70
|
-
# Proxy
|
|
88
|
+
# Proxy scoped to a nested step path (e.g. [:parent_compose, :child_map]).
|
|
89
|
+
# All mutating calls stay scoped to this path: `mock_step`/`failing_at`
|
|
90
|
+
# return the proxy itself (not the outer subject) so several inner
|
|
91
|
+
# steps of the *same* nested reactor can be chained without resetting
|
|
92
|
+
# scope back to the parent.
|
|
71
93
|
class StepProxy
|
|
72
|
-
def initialize(subject,
|
|
94
|
+
def initialize(subject, step_path)
|
|
73
95
|
@subject = subject
|
|
74
|
-
@
|
|
96
|
+
@step_path = step_path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def mock_step(inner_step_name, *nested_steps, element_index: nil, &block)
|
|
100
|
+
@subject.mock_step(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
|
|
101
|
+
self
|
|
75
102
|
end
|
|
76
103
|
|
|
77
|
-
def
|
|
78
|
-
@subject.
|
|
79
|
-
|
|
104
|
+
def failing_at(inner_step_name, *nested_steps, element_index: nil, &block)
|
|
105
|
+
@subject.failing_at(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
|
|
106
|
+
self
|
|
80
107
|
end
|
|
81
108
|
|
|
82
|
-
# Support deep nesting?
|
|
83
109
|
def map(inner_step_name)
|
|
84
|
-
|
|
110
|
+
proxy = self.class.new(@subject, @step_path + [inner_step_name])
|
|
111
|
+
return proxy unless block_given?
|
|
112
|
+
|
|
113
|
+
yield proxy
|
|
114
|
+
self
|
|
85
115
|
end
|
|
86
116
|
|
|
87
117
|
def composed(inner_step_name)
|
|
88
|
-
|
|
118
|
+
proxy = self.class.new(@subject, @step_path + [inner_step_name])
|
|
119
|
+
return proxy unless block_given?
|
|
120
|
+
|
|
121
|
+
yield proxy
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
alias compose composed
|
|
125
|
+
|
|
126
|
+
# Delegate everything else (run, success?, result, step_result, ...)
|
|
127
|
+
# to the outer subject, so a proxy can stand in for it wherever a
|
|
128
|
+
# `TestSubject` is expected (e.g. `expect(subject).to be_success`
|
|
129
|
+
# right after a non-block `.composed(:x).mock_step(:y)` chain).
|
|
130
|
+
def method_missing(name, ...)
|
|
131
|
+
return @subject.public_send(name, ...) if @subject.respond_to?(name)
|
|
132
|
+
|
|
133
|
+
super
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def respond_to_missing?(name, include_private = false)
|
|
137
|
+
@subject.respond_to?(name, include_private) || super
|
|
89
138
|
end
|
|
90
139
|
end
|
|
91
140
|
|
|
@@ -491,12 +540,13 @@ module RubyReactor
|
|
|
491
540
|
end
|
|
492
541
|
|
|
493
542
|
def prepare_execution_class
|
|
543
|
+
@force_sync = @async == false
|
|
544
|
+
|
|
494
545
|
# Even if no interceptors, we might need to subclass to force the whole
|
|
495
546
|
# reactor to run in-process.
|
|
496
|
-
return @reactor_class if @interceptors.empty? &&
|
|
547
|
+
return @reactor_class if @interceptors.empty? && !@force_sync
|
|
497
548
|
|
|
498
549
|
interceptors = @interceptors
|
|
499
|
-
force_sync = @async == false
|
|
500
550
|
|
|
501
551
|
execution_class = Class.new(@reactor_class) do
|
|
502
552
|
# 1. Copy configuration from parent
|
|
@@ -514,19 +564,17 @@ module RubyReactor
|
|
|
514
564
|
unique_name = "#{superclass.name}Mock#{object_id}"
|
|
515
565
|
define_singleton_method(:name) { unique_name }
|
|
516
566
|
RubyReactor::Registry.register(unique_name, self)
|
|
567
|
+
end
|
|
517
568
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
@steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
528
|
-
end
|
|
529
|
-
end
|
|
569
|
+
# 3. `async: false` / `run_async(false)` means "run this reactor's full
|
|
570
|
+
# logic here, in one process" — all the way down. Suppress this
|
|
571
|
+
# class's own `background` hand-off and `async_step`/`async_reactor`
|
|
572
|
+
# dispatch, then recursively do the same for every `compose`d/`map`ped
|
|
573
|
+
# child reactor (`apply_nested_interceptors` below clones from
|
|
574
|
+
# whatever class ends up wired in here, so it inherits this).
|
|
575
|
+
if @force_sync
|
|
576
|
+
strip_background_and_async!(execution_class)
|
|
577
|
+
force_sync_nested_reactors!(execution_class)
|
|
530
578
|
end
|
|
531
579
|
|
|
532
580
|
# 4. Apply Interceptors
|
|
@@ -535,6 +583,79 @@ module RubyReactor
|
|
|
535
583
|
execution_class
|
|
536
584
|
end
|
|
537
585
|
|
|
586
|
+
# Clears the background hand-off and any `async_dispatch` flags on a
|
|
587
|
+
# class's OWN steps. Nested composed/map children are separate classes,
|
|
588
|
+
# handled by `force_sync_nested_reactors!`.
|
|
589
|
+
def strip_background_and_async!(klass)
|
|
590
|
+
klass.instance_variable_set(:@background_handoff, nil)
|
|
591
|
+
klass.steps.each do |name, config|
|
|
592
|
+
next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
|
|
593
|
+
|
|
594
|
+
klass.steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Rewrites every `compose`/`map` step to target a subclass that has
|
|
599
|
+
# itself been force-synced (background/async stripped, and recursively,
|
|
600
|
+
# its own nested composed/map children), so `run_async(false)` reaches
|
|
601
|
+
# every level of a reactor tree instead of only the top one.
|
|
602
|
+
def force_sync_nested_reactors!(klass)
|
|
603
|
+
klass.steps.each do |step_name, step_config|
|
|
604
|
+
arg_key, source = nested_reactor_source(step_config)
|
|
605
|
+
next unless source
|
|
606
|
+
|
|
607
|
+
forced_child = build_forced_sync_class(source.value)
|
|
608
|
+
|
|
609
|
+
new_args = step_config.arguments.dup
|
|
610
|
+
new_args[arg_key] = new_args[arg_key].merge(source: RubyReactor::Template::Value.new(forced_child))
|
|
611
|
+
# A fan-out map is NOT an `async_dispatch` step — its hand-off lives in
|
|
612
|
+
# the `fan_out` argument, so `strip_background_and_async!` never sees
|
|
613
|
+
# it. Turn it off here (on the clone, leaving the original config
|
|
614
|
+
# untouched for normal runs) or `run_async(false)` would still
|
|
615
|
+
# dispatch element jobs and leave the reactor parked at the map.
|
|
616
|
+
if arg_key == :mapped_reactor_class && new_args[:fan_out]
|
|
617
|
+
new_args[:fan_out] = new_args[:fan_out].merge(source: RubyReactor::Template::Value.new(false))
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
new_step_config = step_config.clone
|
|
621
|
+
new_step_config.instance_variable_set(:@arguments, new_args)
|
|
622
|
+
klass.steps[step_name] = new_step_config
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def nested_reactor_source(step_config)
|
|
627
|
+
return [nil, nil] unless step_config.respond_to?(:arguments)
|
|
628
|
+
|
|
629
|
+
args = step_config.arguments
|
|
630
|
+
# `async_reactor` is included: clearing its dispatch marker makes the
|
|
631
|
+
# child run inline, but inline means `child_class.run(...)` on the
|
|
632
|
+
# ORIGINAL class — whose own background/async steps would dispatch
|
|
633
|
+
# again. The child has to be force-synced too.
|
|
634
|
+
%i[mapped_reactor_class composed_reactor_class async_reactor_class].each do |arg_key|
|
|
635
|
+
source = args[arg_key]&.[](:source)
|
|
636
|
+
return [arg_key, source] if source.is_a?(RubyReactor::Template::Value)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
[nil, nil]
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def build_forced_sync_class(original_child_reactor)
|
|
643
|
+
child_class = Class.new(original_child_reactor) do
|
|
644
|
+
define_singleton_method(:name) { original_child_reactor.name }
|
|
645
|
+
@steps = superclass.steps.dup
|
|
646
|
+
@inputs = superclass.inputs.dup
|
|
647
|
+
@input_validations = superclass.input_validations.dup
|
|
648
|
+
@middlewares = superclass.middlewares.dup
|
|
649
|
+
@return_step = superclass.return_step
|
|
650
|
+
@background_handoff = superclass.background_handoff
|
|
651
|
+
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
strip_background_and_async!(child_class)
|
|
655
|
+
force_sync_nested_reactors!(child_class)
|
|
656
|
+
child_class
|
|
657
|
+
end
|
|
658
|
+
|
|
538
659
|
def apply_interceptors(klass, interceptors)
|
|
539
660
|
# Group interceptors by the current level step
|
|
540
661
|
grouped = interceptors.group_by { |i| i[:step_path].first }
|
|
@@ -564,7 +685,7 @@ module RubyReactor
|
|
|
564
685
|
direct_interceptors.each do |interceptor|
|
|
565
686
|
case interceptor[:type]
|
|
566
687
|
when :failure
|
|
567
|
-
apply_failure_interceptor(step_config, target_step)
|
|
688
|
+
apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
568
689
|
when :mock
|
|
569
690
|
apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
570
691
|
end
|
|
@@ -595,9 +716,19 @@ module RubyReactor
|
|
|
595
716
|
|
|
596
717
|
original_child_reactor = target_reactor_class_source.value
|
|
597
718
|
|
|
598
|
-
# Dynamically subclass the child reactor
|
|
719
|
+
# Dynamically subclass the child reactor.
|
|
720
|
+
#
|
|
721
|
+
# It needs a resolvable identity of its OWN: a fan-out map serializes
|
|
722
|
+
# only `mapped_reactor_class.name` into each element job, and the worker
|
|
723
|
+
# resolves that name back through `const_get` first. Keeping the
|
|
724
|
+
# original name would therefore resolve to the original, unmocked class
|
|
725
|
+
# in every element. Registering a unique name makes the fan-out payload
|
|
726
|
+
# (and the `element_reactor_class` that `#map_elements` traverses) point
|
|
727
|
+
# at THIS class.
|
|
599
728
|
mocked_child_reactor = Class.new(original_child_reactor) do
|
|
600
|
-
|
|
729
|
+
unique_name = "#{original_child_reactor.name || "AnonymousReactor"}Mock#{object_id}"
|
|
730
|
+
define_singleton_method(:name) { unique_name }
|
|
731
|
+
RubyReactor::Registry.register(unique_name, self)
|
|
601
732
|
# Copy configuration
|
|
602
733
|
@steps = superclass.steps.dup
|
|
603
734
|
@inputs = superclass.inputs.dup
|
|
@@ -629,9 +760,40 @@ module RubyReactor
|
|
|
629
760
|
step_config.arguments[arg_key][:source] = RubyReactor::Template::Value.new(mocked_child_reactor)
|
|
630
761
|
end
|
|
631
762
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
763
|
+
# The original run behavior for a step, used both as the fallback when
|
|
764
|
+
# `element_index` excludes the current map element and as the `original`
|
|
765
|
+
# callable a mock block can invoke.
|
|
766
|
+
def original_impl_for(step_config_orig, target_step)
|
|
767
|
+
if step_config_orig.has_run_block?
|
|
768
|
+
step_config_orig.run_block
|
|
769
|
+
elsif step_config_orig.has_impl?
|
|
770
|
+
->(args, ctx) { step_config_orig.impl.run(args, ctx) }
|
|
771
|
+
else
|
|
772
|
+
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# A map element's own context carries `map_metadata[:index]`. When an
|
|
777
|
+
# interceptor is scoped to one element via `element_index:`, only that
|
|
778
|
+
# element's context matches — everything else falls through to the
|
|
779
|
+
# original implementation.
|
|
780
|
+
def matches_element_index?(context, element_index)
|
|
781
|
+
return true if element_index.nil?
|
|
782
|
+
|
|
783
|
+
meta = context.map_metadata
|
|
784
|
+
meta && (meta[:index] == element_index || meta["index"] == element_index)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
788
|
+
element_index = interceptor[:conditions][:element_index]
|
|
789
|
+
original_impl = original_impl_for(step_config_orig, target_step)
|
|
790
|
+
|
|
791
|
+
failure_impl = lambda do |input, context|
|
|
792
|
+
if matches_element_index?(context, element_index)
|
|
793
|
+
RubyReactor::Failure("Simulated failure at #{target_step}")
|
|
794
|
+
else
|
|
795
|
+
original_impl.call(input, context)
|
|
796
|
+
end
|
|
635
797
|
end
|
|
636
798
|
|
|
637
799
|
step_config.instance_variable_set(:@run_block, failure_impl)
|
|
@@ -639,18 +801,13 @@ module RubyReactor
|
|
|
639
801
|
|
|
640
802
|
def apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
641
803
|
mock_block = interceptor[:conditions][:block]
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
original_impl = if step_config_orig.has_run_block?
|
|
645
|
-
step_config_orig.run_block
|
|
646
|
-
elsif step_config_orig.has_impl?
|
|
647
|
-
->(args, ctx) { step_config_orig.impl.run(args, ctx) }
|
|
648
|
-
else
|
|
649
|
-
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
650
|
-
end
|
|
804
|
+
element_index = interceptor[:conditions][:element_index]
|
|
805
|
+
original_impl = original_impl_for(step_config_orig, target_step)
|
|
651
806
|
|
|
652
807
|
# Create the new implementation that wraps the user block
|
|
653
808
|
wrapper_impl = lambda do |args, context|
|
|
809
|
+
next original_impl.call(args, context) unless matches_element_index?(context, element_index)
|
|
810
|
+
|
|
654
811
|
if mock_block.arity == 3
|
|
655
812
|
mock_block.call(args, context, original_impl)
|
|
656
813
|
else
|
data/lib/ruby_reactor/rspec.rb
CHANGED
|
@@ -21,7 +21,14 @@ module RubyReactor
|
|
|
21
21
|
def self.configure(config)
|
|
22
22
|
require_relative "rspec/step_executor_patch"
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# `test_reactor` and the async-job helpers are only auto-included into
|
|
25
|
+
# examples tagged `type: :reactor`, so a host app's other specs keep a
|
|
26
|
+
# clean namespace. Specs that want them without the tag can
|
|
27
|
+
# `include RubyReactor::RSpec::Helpers` explicitly.
|
|
28
|
+
#
|
|
29
|
+
# Matchers stay unconditional: `::RSpec::Matchers.define` registers them
|
|
30
|
+
# globally at load time, so gating this include would not scope anything.
|
|
31
|
+
config.include RubyReactor::RSpec::Helpers, REACTOR_METADATA
|
|
25
32
|
config.include RubyReactor::RSpec::Matchers
|
|
26
33
|
config.include RubyReactor::RSpec::SidekiqHelpers, REACTOR_METADATA
|
|
27
34
|
|
|
@@ -9,7 +9,7 @@ module RubyReactor
|
|
|
9
9
|
# Initialize map state in context if not present
|
|
10
10
|
context.map_operations ||= {}
|
|
11
11
|
|
|
12
|
-
if
|
|
12
|
+
if fan_out?
|
|
13
13
|
run_async(context.current_step)
|
|
14
14
|
else
|
|
15
15
|
run_inline
|
|
@@ -69,10 +69,15 @@ module RubyReactor
|
|
|
69
69
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
# Fans out anywhere except inside a map element: an element's result and
|
|
73
|
+
# the map's completion counter are tracked by its own ElementExecutor job,
|
|
74
|
+
# so a nested hand-off there would escape that tracking. A reactor worker
|
|
75
|
+
# (`background`, collector resume, `async_reactor` child) fans out as the
|
|
76
|
+
# caller would — the collector resumes the reactor in a worker either way.
|
|
77
|
+
def fan_out?
|
|
78
|
+
return false if context.map_metadata || context.root_context&.map_metadata
|
|
74
79
|
|
|
75
|
-
inputs[:
|
|
80
|
+
inputs[:fan_out]
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
def run_inline
|
|
@@ -86,8 +91,8 @@ module RubyReactor
|
|
|
86
91
|
results = []
|
|
87
92
|
fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
|
|
88
93
|
|
|
89
|
-
inputs[:source].
|
|
90
|
-
result = execute_single_element(element)
|
|
94
|
+
inputs[:source].each_with_index do |element, index|
|
|
95
|
+
result = execute_single_element(element, index)
|
|
91
96
|
|
|
92
97
|
# An element-level Halt propagates as a run halt: stop immediately
|
|
93
98
|
# rather than being collected as a (nil) value.
|
|
@@ -104,7 +109,7 @@ module RubyReactor
|
|
|
104
109
|
results
|
|
105
110
|
end
|
|
106
111
|
|
|
107
|
-
def execute_single_element(element)
|
|
112
|
+
def execute_single_element(element, index)
|
|
108
113
|
mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
|
|
109
114
|
child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
|
|
110
115
|
|
|
@@ -118,7 +123,7 @@ module RubyReactor
|
|
|
118
123
|
child_context.map_metadata = {
|
|
119
124
|
map_id: map_id,
|
|
120
125
|
parent_reactor_class_name: context.reactor_class.name,
|
|
121
|
-
index:
|
|
126
|
+
index: index
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
# Store reference in composed_contexts so the UI knows where to find elements
|
data/lib/ruby_reactor/version.rb
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Scoping the RSpec matchers to `type: :reactor`
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
`RubyReactor::RSpec.configure(config)` should add **nothing** to a host app's
|
|
6
|
+
example groups unless they opt in with `type: :reactor`. Helpers already work
|
|
7
|
+
that way. The 19 matchers do not — they are global the moment
|
|
8
|
+
`ruby_reactor/rspec` is required, and moving them behind the tag needs a
|
|
9
|
+
one-line-per-matcher change plus a migration.
|
|
10
|
+
|
|
11
|
+
## Current state
|
|
12
|
+
|
|
13
|
+
[lib/ruby_reactor/rspec.rb:20-42](../lib/ruby_reactor/rspec.rb#L20-L42):
|
|
14
|
+
|
|
15
|
+
| Piece | Scoped to `type: :reactor`? |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `Helpers` (`test_reactor`) | yes — `config.include …, REACTOR_METADATA` |
|
|
18
|
+
| `SidekiqHelpers` (`drain_async_jobs`, `pending_async_jobs`) | yes |
|
|
19
|
+
| `before(:each)` setup (fake mode, storage wipe, snooze reset) | yes |
|
|
20
|
+
| `Matchers` (19 matchers) | **no** |
|
|
21
|
+
| `StorageReset.install!`, `StepExecutor.prepend` | no — load-time monkeypatches, out of scope for this doc |
|
|
22
|
+
|
|
23
|
+
## Why the `config.include` line does nothing today
|
|
24
|
+
|
|
25
|
+
[matchers.rb](../lib/ruby_reactor/rspec/matchers.rb) declares every matcher as:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
module Matchers
|
|
29
|
+
::RSpec::Matchers.define :be_success do
|
|
30
|
+
...
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`define` is [`RSpec::Matchers::DSL#define`](https://github.com/rspec/rspec-expectations/blob/main/lib/rspec/matchers/dsl.rb),
|
|
36
|
+
and its entire body is a `define_method` **on the receiver**:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
def define(name, &declarations)
|
|
40
|
+
warn_about_block_args(name, declarations)
|
|
41
|
+
define_method name do |*expected, &block_arg|
|
|
42
|
+
RSpec::Matchers::DSL::Matcher.new(name, declarations, self, *expected, &block_arg)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
alias_method :matcher, :define
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The receiver is `::RSpec::Matchers`, which RSpec includes into *every* example
|
|
49
|
+
group. So the matcher methods land in the global spec namespace at require
|
|
50
|
+
time, and `RubyReactor::RSpec::Matchers` ends up an empty module — the
|
|
51
|
+
`config.include RubyReactor::RSpec::Matchers` line in
|
|
52
|
+
[rspec.rb:32](../lib/ruby_reactor/rspec.rb#L32) includes nothing. Adding
|
|
53
|
+
`REACTOR_METADATA` to that line scopes nothing either.
|
|
54
|
+
|
|
55
|
+
## The change
|
|
56
|
+
|
|
57
|
+
Extend the DSL into **our** module, so `define_method` targets
|
|
58
|
+
`RubyReactor::RSpec::Matchers`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
module RubyReactor
|
|
62
|
+
module RSpec
|
|
63
|
+
module Matchers
|
|
64
|
+
extend ::RSpec::Matchers::DSL # <- add
|
|
65
|
+
|
|
66
|
+
matcher :be_success do # <- was ::RSpec::Matchers.define
|
|
67
|
+
...
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then gate the include:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
config.include RubyReactor::RSpec::Matchers, REACTOR_METADATA
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Mechanically: one `extend` line, 19 `::RSpec::Matchers.define` →
|
|
81
|
+
`matcher` rewrites (`sed` handles it), one changed line in `rspec.rb`, and the
|
|
82
|
+
comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30) — which
|
|
83
|
+
currently explains why matchers *can't* be scoped — deleted.
|
|
84
|
+
|
|
85
|
+
Nothing inside the matcher bodies changes. `Matchers.coordination_adapter`
|
|
86
|
+
([matchers.rb:260](../lib/ruby_reactor/rspec/matchers.rb#L260)) is called with
|
|
87
|
+
an explicit receiver and resolves lexically at match time, so it is unaffected.
|
|
88
|
+
|
|
89
|
+
### Verified
|
|
90
|
+
|
|
91
|
+
A probe against this repo's `spec_helper`, with one matcher declared each way:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
UNTAGGED scoped: expected :nope to respond to `probe_scoped?` # not included -> falls through
|
|
95
|
+
UNTAGGED global: GLOBAL-MATCHER-RAN: expected :ok, got :nope # still global
|
|
96
|
+
TAGGED scoped: SCOPED-MATCHER-RAN: expected :ok, got :nope # real matcher, chains and all
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Note the first line: scoping a matcher out does **not** produce a
|
|
100
|
+
`NoMethodError`. See below.
|
|
101
|
+
|
|
102
|
+
## The trap: `be_*` / `have_*` fall through to a predicate matcher
|
|
103
|
+
|
|
104
|
+
All 19 matcher names start with `be_` or `have_`, which is exactly the prefix
|
|
105
|
+
RSpec's dynamic predicate matchers claim. Once a matcher is no longer in scope,
|
|
106
|
+
`expect(x).to be_success` stops meaning "RubyReactor's `be_success`" and starts
|
|
107
|
+
meaning "call `x.success?`". Two consequences:
|
|
108
|
+
|
|
109
|
+
1. **`respond_to?(:be_success)` is not a scoping test.** It returns `true` in
|
|
110
|
+
any example group for any `be_*`/`have_*` name, because
|
|
111
|
+
`RSpec::Matchers#respond_to_missing?` claims the whole prefix. Test scoping
|
|
112
|
+
by running an expectation and reading the failure message (as the probe
|
|
113
|
+
above does), not by probing `respond_to?`.
|
|
114
|
+
2. **Some call sites degrade silently rather than breaking.** `TestSubject` and
|
|
115
|
+
the result objects define `success?`, `failure?`, `paused?`, `halted?` and
|
|
116
|
+
`skipped?` ([test_subject.rb:388-405](../lib/ruby_reactor/rspec/test_subject.rb#L388-L405),
|
|
117
|
+
[ruby_reactor.rb:50-62](../lib/ruby_reactor.rb#L50-L62)), so an untagged
|
|
118
|
+
`expect(result).to be_success` keeps passing via the predicate — with a
|
|
119
|
+
generic failure message instead of the rich one in
|
|
120
|
+
[matchers.rb:14-23](../lib/ruby_reactor/rspec/matchers.rb#L14-L23), and
|
|
121
|
+
without the `ensure_executed!` nudge the real matcher does. Anything with a
|
|
122
|
+
chain (`have_run_step(:x).returning(y)`, `be_halted.because(:period)`,
|
|
123
|
+
`be_locked.by(owner)`) or a non-predicate name fails loudly instead.
|
|
124
|
+
|
|
125
|
+
So the migration cost is not "find the red specs" — a suite can go green while
|
|
126
|
+
quietly asserting something weaker. Grep for the matcher names rather than
|
|
127
|
+
trusting a passing run.
|
|
128
|
+
|
|
129
|
+
## Migration
|
|
130
|
+
|
|
131
|
+
In this repo, 34 spec files use these matchers; 24 of them are not tagged
|
|
132
|
+
`type: :reactor`. Most only need the tag added to the `RSpec.describe` line, the
|
|
133
|
+
same edit made for `test_reactor` in `0a3ebdab`. Order of work:
|
|
134
|
+
|
|
135
|
+
1. Rewrite `matchers.rb` to the DSL-extend form, leave the include ungated —
|
|
136
|
+
suite must stay green (proves the rewrite alone changes nothing).
|
|
137
|
+
2. Gate the include, run the suite, tag the files that fail.
|
|
138
|
+
3. Grep the 24 untagged files for matcher names and tag the rest by hand —
|
|
139
|
+
step 2 will not catch the silent-degradation cases above.
|
|
140
|
+
4. Update [documentation/testing.md](../documentation/testing.md#L17-L35),
|
|
141
|
+
whose Setup section currently says matchers are available everywhere, and
|
|
142
|
+
the comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30).
|
|
143
|
+
|
|
144
|
+
## Breaking change
|
|
145
|
+
|
|
146
|
+
Downstream suites calling these matchers in untagged groups are affected, with
|
|
147
|
+
the same silent-degradation caveat: a user's suite may stay green while
|
|
148
|
+
asserting less. This warrants a `BREAKING CHANGE:` footer and a release note
|
|
149
|
+
that names the fallback behavior explicitly, not just "matchers are now
|
|
150
|
+
scoped".
|
|
151
|
+
|
|
152
|
+
## Alternative considered
|
|
153
|
+
|
|
154
|
+
Keep matchers global, on the grounds that `be_success` / `have_run_step` are
|
|
155
|
+
already ambiguous with RSpec's predicate matchers and that a host app gains
|
|
156
|
+
little from gating them. This costs nothing and breaks nobody — worth taking if
|
|
157
|
+
the namespace complaint is theoretical rather than something a host app has
|
|
158
|
+
actually hit.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_reactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artur
|
|
@@ -289,6 +289,7 @@ files:
|
|
|
289
289
|
- specs/deferred-003-step-lock-declarations/spec.md
|
|
290
290
|
- specs/deferred-003-step-lock-declarations/tasks.md
|
|
291
291
|
- specs/possible_feature.md
|
|
292
|
+
- specs/scoped_rspec_matchers.md
|
|
292
293
|
- teley/Dockerfile
|
|
293
294
|
homepage: https://github.com/arturictus/ruby_reactor
|
|
294
295
|
licenses: []
|