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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e671a274b5dc3854189ab1f3c40ff4d7e307a567ed960dbad0be1405f05ff58
4
- data.tar.gz: 0a44e9cf8dbed99b72762381300c4c503500d3025f21288e473eef377a28dbec
3
+ metadata.gz: 31b3ac286396e1d29f1bad40e87f4f55b8ecc3e6c715e6cfd2ca0496d9c8dd0c
4
+ data.tar.gz: d518e5aceb0e5fa6dd43007e1f324612c84a53528e73a76463db7ecb652f0a95
5
5
  SHA512:
6
- metadata.gz: a39666bdba5d7c911589da0f30a12e29a9f5187c31a9a1bfb9ddc0c55aba26cac201db3e8083854f79858d6845ed98097589979873a0a4aa7cc8dfdaca635856
7
- data.tar.gz: a5069c91a3942f1697a317baee2223b790b4262ca6d90f61a1b3aa0cd8b61ff9b199b7a05f58f791df11bd7b4336611907a6abd3cbce6ebfd007d9bc92949c4e
6
+ metadata.gz: 2e91db7e2c548b834f285fe514747bc407b29fbb4cdb10e4aef44b05ff556d518ccf77ad4d02fc3e097907d7298843bc32b7e5a57d4f4a8ae687795dc702fa71
7
+ data.tar.gz: 6a2252e7fdf4740f1d297f2855bb60f07c4b66e0a551c8d90e4394fd1acafd829098323d40afb4f9bc650c76a53818d76566c70f65c89c4b60ed11f9018a3d48
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.0"
2
+ ".": "0.8.2"
3
3
  }
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
- Not affected: the map-internal `async` element dispatch option
202
- (`map :items do async true, batch_size: 2 end`) — a different mechanism that
203
- keeps working unchanged.
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 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 background execution and **Redis** for state persistence.
9
9
 
10
10
  ![Payment workflow reactor](documentation/images/payment_workflow.png)
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
- - **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).
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. Async jobs snooze on contention with smart `retry_after` instead of consuming retry budget.
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
- | Async (Sidekiq/AJ) | Yes | No | Limited | Yes |
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
- - [Async Execution](#async-execution)
61
- - [Full Reactor Async](#full-reactor-async)
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 async worker. Default: :default.
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
- ## Async router. Default: RubyReactor::Adapters::Sidekiq::Router. Swap in the
184
- ## built-in ActiveJob adapter (see "Async Execution" below), or point at any
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
- ### Async Execution
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 (full-reactor async, step-level
414
- async, durability, retries, snoozing) works identically on either backend.
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 Reactor Async
419
+ #### Full Background
417
420
 
418
421
  ```ruby
419
- class AsyncReactor < RubyReactor::Reactor
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 = AsyncReactor.run(params)
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 Reactors](documentation/async_reactors.md) for the full
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
- Async reactors are durable: state lives in Redis, not in the job payload. Before
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
- # Async workers snooze for exactly retry_after seconds instead of
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
- async
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 async snooze behavior is identical.
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
- - **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).
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
- # Enable async execution with batching
851
- async true, batch_size: 50
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
- By using `async true` 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).
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
- `batch_size` is optional: with `async true` 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 [Async Without `batch_size`](documentation/data_pipelines.md#async-without-batch_size).
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
- async true, batch_size: 100
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 Reactors](documentation/async_reactors.md)
1430
- Explore the ways to move work off the calling process: Full Reactor Async, the `background` hand-off, `async_step`, and `async_reactor`. Learn how RubyReactor leverages Sidekiq or ActiveJob for background processing, non-blocking execution, and scalable worker management.
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 async retries work without blocking workers.
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-async 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.
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, async 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 async/retry boundaries, and custom exporters.
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 Async adapters
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
- execution_id: context_id)
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
- execution_id: context_id)
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
- execution_id: context_id)
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
- execution_id: context_id)
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
- @async = false
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
- def async(async = true, batch_size: nil)
44
- @async = async
45
- @batch_size = batch_size if batch_size
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
- async: { source: RubyReactor::Template::Value.new(@async) }
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
- # Globally-included helpers. Only methods whose names clearly belong to
6
- # RubyReactor's test surface live here (`test_reactor`). Sidekiq-coupled
7
- # helpers live in `SidekiqHelpers` and are scoped to `type: :reactor`.
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
- # @example
53
- # reactor.map(:my_map).mock_step(:inner_step) { ... }
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.compose(:my_sub_reactor).mock_step(:inner_step) { ... }
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
- # Otherwise return a configuration proxy
66
- StepProxy.new(self, step_name)
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 class for fluent mocking configuration
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, step_name)
94
+ def initialize(subject, step_path)
73
95
  @subject = subject
74
- @step_name = step_name
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 mock_step(inner_step_name, *nested_steps, &block)
78
- @subject.mock_step(@step_name, inner_step_name, *nested_steps, &block)
79
- @subject # Return subject to allow chaining or calling run
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
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
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
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
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? && @async != false
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
- # 3. `async: false` / `run_async(false)` means "run this reactor's full
519
- # logic here, in one process". Under the new DSL that is three things:
520
- # suppress the `background` hand-off, and run `async_step` /
521
- # `async_reactor` units inline instead of dispatching them.
522
- if force_sync
523
- @background_handoff = nil
524
- @steps.each do |name, config|
525
- next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
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
- define_singleton_method(:name) { original_child_reactor.name }
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
- def apply_failure_interceptor(step_config, target_step)
633
- failure_impl = lambda do |_input, _context|
634
- RubyReactor::Failure("Simulated failure at #{target_step}")
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
- # Prepare original implementation call
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
@@ -21,7 +21,14 @@ module RubyReactor
21
21
  def self.configure(config)
22
22
  require_relative "rspec/step_executor_patch"
23
23
 
24
- config.include RubyReactor::RSpec::Helpers
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 should_run_async?
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
- def should_run_async?
73
- return false if context.inline_async_execution
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[:async]
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].each do |element|
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: nil # Inline map execution doesn't track index in metadata currently, but could
126
+ index: index
122
127
  }
123
128
 
124
129
  # Store reference in composed_contexts so the UI knows where to find elements
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.2"
5
5
  end
@@ -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.0
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: []