ruby_reactor 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -0
  3. data/Rakefile +2 -2
  4. data/documentation/data_pipelines.md +90 -84
  5. data/documentation/testing.md +812 -0
  6. data/lib/ruby_reactor/configuration.rb +1 -1
  7. data/lib/ruby_reactor/context.rb +13 -5
  8. data/lib/ruby_reactor/context_serializer.rb +70 -4
  9. data/lib/ruby_reactor/dsl/map_builder.rb +6 -2
  10. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  11. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  12. data/lib/ruby_reactor/executor/result_handler.rb +9 -2
  13. data/lib/ruby_reactor/executor/retry_manager.rb +26 -8
  14. data/lib/ruby_reactor/executor/step_executor.rb +24 -99
  15. data/lib/ruby_reactor/executor.rb +3 -13
  16. data/lib/ruby_reactor/map/collector.rb +72 -33
  17. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  18. data/lib/ruby_reactor/map/element_executor.rb +103 -114
  19. data/lib/ruby_reactor/map/execution.rb +18 -4
  20. data/lib/ruby_reactor/map/helpers.rb +4 -3
  21. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  22. data/lib/ruby_reactor/reactor.rb +174 -16
  23. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  24. data/lib/ruby_reactor/rspec/matchers.rb +256 -0
  25. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  26. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  27. data/lib/ruby_reactor/rspec.rb +18 -0
  28. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +15 -10
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -3
  30. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  31. data/lib/ruby_reactor/step/map_step.rb +52 -27
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +59 -0
  33. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +32 -24
  36. data/lib/ruby_reactor.rb +70 -10
  37. metadata +12 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 598c9e49f00183da45e7b370c394445e9efddfe5952e3aabd701bbf954aa9712
4
- data.tar.gz: 949ed81bd787d7bee47284e7f7b5905af3de4ae4057a90a4d0afa707959f5cbb
3
+ metadata.gz: d3ddfd9b6e65d03e5c00d7980563da39b4132fc1d5f0dbe14e5b782d039c7f68
4
+ data.tar.gz: 785cc8ac8d426433a00f378eafb8f312b343950ecf496e2af0a6fe9e48115c29
5
5
  SHA512:
6
- metadata.gz: ae8f8f8a2916254068757d79d352df0a0148d4693f92238b456cecc1cb90d9f6cc77087f2b486e3359f5f0042779aecc9112df1da9898fa8efba6c9ed425ef39
7
- data.tar.gz: 14fcbf6f880396176503008239e6517415977b19193f3f0d8600226acb7cf53d54a18bb79a42012646a1746d3202a62b73479715b6d088f69c208ce1c143aa92
6
+ metadata.gz: 549a49deff5d63e129c0b4c043cbc3987dbbb09b054907a5acb8941af85fedb5f9706bad8d56948bbc7ea889eba19174254962ca987e7835b4cd0a7bf3f9e165
7
+ data.tar.gz: 319399c2854cccb505fc7e8f6c964eeeb527bad375ed513b1a2c2ea89b77445283f287bbff5a0892902a56a5a56b5a9aff105a0164006abf407473ad3780a58d
data/README.md CHANGED
@@ -1,9 +1,20 @@
1
+ [![Gem Version](https://badge.fury.io/rb/ruby_reactor.svg)](https://badge.fury.io/rb/ruby_reactor)
2
+ [![Build Status](https://github.com/arturictus/ruby_reactor/actions/workflows/main.yml/badge.svg)](https://github.com/arturictus/ruby_reactor/actions) <!-- if you have CI -->
3
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
4
+ [![Downloads](https://img.shields.io/gem/dt/ruby_reactor.svg)](https://rubygems.org/gems/ruby_reactor)
5
+
1
6
  # RubyReactor
2
7
 
3
8
  A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor implements the Saga pattern with compensation-based error handling and DAG-based execution planning. It leverages **Sidekiq** for asynchronous execution and **Redis** for state persistence.
4
9
 
5
10
  ![Payment workflow reactor](documentation/images/payment_workflow.png)
6
11
 
12
+ ## Why Ruby Reactor?
13
+
14
+ Building complex business transactions often results in spaghetti code or brittle "god classes." Ruby Reactor solves this by implementing the **Saga Pattern** in a lightweight, developer-friendly package. It lets you define workflows as clear, dependency-driven steps without the boilerplate of heavy enterprise frameworks.
15
+
16
+ The key value is **Reliability**: if any part of your workflow fails, Ruby Reactor automatically triggers compensation logic to undo previous steps, ensuring your system never ends up in a corrupted half-state. Whether you're coordinating microservices or monolith modules, you get atomic-like consistency with background processing built-in.
17
+
7
18
  ## Features
8
19
 
9
20
  - **DAG-based Execution**: Steps are executed based on their dependencies, allowing for parallel execution of independent steps.
@@ -14,6 +25,50 @@ A dynamic, dependency-resolving saga orchestrator for Ruby. Ruby Reactor impleme
14
25
  - **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
15
26
  - **Input Validation**: Integrated with `dry-validation` for robust input checking.
16
27
 
28
+ ## Comparison
29
+
30
+ | Feature | Ruby Reactor | dry-transaction | Trailblazer | Custom Sidekiq Jobs |
31
+ |--------------------------|--------------|-----------------|-------------|---------------------|
32
+ | DAG/Parallel execution | Yes | No | Limited | Manual |
33
+ | Auto compensation/undo | Yes | No | Manual | Manual |
34
+ | Interrupts (pause/resume)| Yes | No | No | Manual |
35
+ | Built-in web dashboard | Yes | No | No | No |
36
+ | Async with Sidekiq | Yes | No | Limited | Yes |
37
+
38
+ ## Real-World Use Cases
39
+
40
+ - **E-commerce Checkout**: Orchestrate inventory reservation, payment authorization, and shipping label generation. If payment fails, automatically release inventory and cancel the shipping request.
41
+ - **Data Import Pipelines**: Ingest optional massive CSVs using `map` steps to validate and upsert records in parallel. If data validation fails for a chunk, fail fast or collect errors while letting valid chunks succeed.
42
+ - **Subscription Billing**: Coordinate Stripe charges, invoice email generation, and internal entitlement updates. Use interrupts to pause the workflow when 3rd-party APIs are required to continue the workflow or when specific customer approval is needed.
43
+
44
+ ## Table of Contents
45
+ - [Features](#features)
46
+ - [Comparison](#comparison)
47
+ - [Real-World Use Cases](#real-world-use-cases)
48
+ - [Installation](#installation)
49
+ - [Configuration](#configuration)
50
+ - [Quick Start](#quick-start)
51
+ - [Web Dashboard](#web-dashboard)
52
+ - [Rails Installation](#rails-installation)
53
+ - [Usage](#usage)
54
+ - [Basic Example: User Registration](#basic-example-user-registration)
55
+ - [Async Execution](#async-execution)
56
+ - [Full Reactor Async](#full-reactor-async)
57
+ - [Step-Level Async](#step-level-async)
58
+ - [Interrupts (Pause & Resume)](#interrupts-pause--resume)
59
+ - [Map & Parallel Execution](#map--parallel-execution)
60
+ - [Map with Dynamic Source (ActiveRecord)](#map-with-dynamic-source-activerecord)
61
+ - [Input Validation](#input-validation)
62
+ - [Complex Workflows with Dependencies](#complex-workflows-with-dependencies)
63
+ - [Error Handling and Compensation](#error-handling-and-compensation)
64
+ - [Using Pre-defined Schemas](#using-pre-defined-schemas)
65
+ - [Testing](#testing)
66
+ - [Documentation](#documentation)
67
+ - [Future improvements](#future-improvements)
68
+ - [Development](#development)
69
+ - [Contributing](#contributing)
70
+ - [Code of Conduct](#code-of-conduct)
71
+
17
72
  ## Installation
18
73
 
19
74
  Add this line to your application's Gemfile:
@@ -50,6 +105,21 @@ RubyReactor.configure do |config|
50
105
  end
51
106
  ```
52
107
 
108
+
109
+ ## Quick Start
110
+
111
+ ```ruby
112
+ class HelloReactor < RubyReactor::Reactor
113
+ step :greet do
114
+ run { Success("Hello from Ruby Reactor!") }
115
+ end
116
+ returns :greet
117
+ end
118
+
119
+ result = HelloReactor.run
120
+ puts result.value # => "Hello from Ruby Reactor!"
121
+ ```
122
+
53
123
  ## Web Dashboard
54
124
 
55
125
  RubyReactor comes with a built-in web dashboard to inspect reactor executions, view logs, and retry failed steps.
@@ -281,6 +351,42 @@ class DataProcessingReactor < RubyReactor::Reactor
281
351
  end
282
352
  ```
283
353
 
354
+ 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).
355
+
356
+ #### Map with Dynamic Source (ActiveRecord)
357
+
358
+ You can use a block for `source` to dynamically fetch data, such as from ActiveRecord queries. The result is wrapped in a `ResultEnumerator` for easy access to successes and failures.
359
+
360
+ ```ruby
361
+ map :archive_old_users do
362
+ argument :days, input(:days)
363
+
364
+ # Dynamic source using ActiveRecord
365
+ source do |args|
366
+ User.where("last_login_at < ?", args[:days].days.ago)
367
+ end
368
+
369
+ argument :user, element(:archive_old_users)
370
+ async true, batch_size: 100
371
+
372
+ step :archive do
373
+ argument :user, input(:user)
374
+ run { |args| args[:user].archive! }
375
+ end
376
+
377
+ returns :archive
378
+ end
379
+
380
+ step :summary do
381
+ argument :results, result(:archive_old_users)
382
+ run do |args|
383
+ puts "Archived: #{args[:results].successes.count}"
384
+ puts "Failed: #{args[:results].failures.count}"
385
+ Success()
386
+ end
387
+ end
388
+ ```
389
+
284
390
  ### Input Validation
285
391
 
286
392
  RubyReactor integrates with dry-validation for input validation:
@@ -575,6 +681,29 @@ class SchemaValidatedReactor < RubyReactor::Reactor
575
681
  end
576
682
  ```
577
683
 
684
+ ### Testing
685
+
686
+ RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation.
687
+
688
+ ```ruby
689
+ RSpec.describe PaymentReactor do
690
+ it "processes payment successfully" do
691
+ subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
692
+
693
+ expect(subject).to be_success
694
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
695
+ expect(subject.step_result(:charge_card)).to include(status: "completed")
696
+ end
697
+
698
+ it "handles payment failures with mocked steps" do
699
+ subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
700
+ .mock_step(:charge_card) { Failure("Card declined") }
701
+
702
+ expect(subject).to be_failure
703
+ expect(subject.error).to include("Card declined")
704
+ end
705
+ end
706
+ ```
578
707
 
579
708
  ## Documentation
580
709
 
@@ -601,6 +730,9 @@ Configure robust retry policies for your steps. This guide details the available
601
730
  ### [Interrupts](documentation/interrupts.md)
602
731
  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.
603
732
 
733
+ ### [Testing with RSpec](documentation/testing.md)
734
+ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Learn about the `TestSubject` class for reactor execution and introspection, step mocking for isolating dependencies, testing nested and composed reactors, and custom RSpec matchers like `be_success`, `have_run_step`, and `have_retried_step`.
735
+
604
736
  ### Examples
605
737
  - [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
606
738
  - [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
data/Rakefile CHANGED
@@ -30,8 +30,8 @@ namespace :server do
30
30
  end
31
31
  end
32
32
 
33
- # require "rubocop/rake_task"
33
+ require "rubocop/rake_task"
34
34
 
35
- # RuboCop::RakeTask.new
35
+ RuboCop::RakeTask.new
36
36
 
37
37
  task default: %i[spec rubocop]
@@ -43,53 +43,35 @@ class UserTransformationReactor < RubyReactor::Reactor
43
43
  end
44
44
  ```
45
45
 
46
- ## Async Execution
46
+ ## Dynamic Sources & ActiveRecord
47
47
 
48
- For long-running or resource-intensive tasks, you can offload processing to background jobs using Sidekiq.
49
-
50
- To enable async execution, simply add the `async true` directive to your map definition.
48
+ The `map` step supports a dynamic `source` block, which is particularly useful when working with ActiveRecord or when the collection depends on input arguments. Instead of passing a static collection, you can define a block that returns an Enumerable or an `ActiveRecord::Relation`.
51
49
 
52
50
  ```ruby
53
- map :process_orders do
54
- source input(:orders)
55
- argument :order, element(:process_orders)
56
-
57
- # Enable async execution via Sidekiq
58
- async true
59
-
60
- step :charge_card do
61
- argument :order, input(:order)
62
- run { PaymentService.charge(args[:order]) }
51
+ map :process_products do
52
+ argument :filter, input(:filter)
53
+
54
+ # Dynamic source block
55
+ source do |args|
56
+ # This block executes at runtime
57
+ threshold = args[:filter][:stock]
58
+ Product.where("stock >= ?", threshold)
63
59
  end
64
60
 
65
- returns :charge_card
61
+ argument :product, element(:process_products)
62
+ async true, batch_size: 100
63
+
64
+ step :process do
65
+ # ...
66
+ end
67
+
68
+ returns :process
66
69
  end
67
70
  ```
68
71
 
69
- ### Execution Flow
70
-
71
- ```mermaid
72
- sequenceDiagram
73
- participant Reactor
74
- participant Redis
75
- participant Sidekiq
76
- participant Worker
77
-
78
- Reactor->>Redis: Store Context
79
- Reactor->>Sidekiq: Enqueue MapElementWorkers
80
- Note over Reactor: Returns AsyncResult immediately
81
-
82
- loop For each element
83
- Sidekiq->>Worker: Process Element
84
- Worker->>Redis: Update Element Result
85
- end
86
-
87
- Worker->>Sidekiq: Enqueue MapCollectorWorker (when done)
88
- Sidekiq->>Worker: Run Collector
89
- Worker->>Redis: Store Final Result
90
- ```
72
+ When an `ActiveRecord::Relation` is returned, RubyReactor efficiently batches the query using database-level `OFFSET` and `LIMIT` based on the configured `batch_size`, preventing memory bloat by not loading all records at once.
91
73
 
92
- ## Batch Processing
74
+ ## Batch Processing Mechanism
93
75
 
94
76
  When processing large datasets asynchronously, you can control the parallelism using `batch_size`. This limits how many Sidekiq jobs are enqueued simultaneously, preventing system overload.
95
77
 
@@ -107,10 +89,42 @@ map :bulk_import do
107
89
  end
108
90
  ```
109
91
 
110
- **How it works:**
111
- 1. The system initially enqueues `batch_size` jobs.
112
- 2. As each job completes, it triggers the next job in the queue.
113
- 3. This maintains a steady stream of processing without flooding the queue.
92
+ ### Back Pressure & Resource Management
93
+
94
+ When `async true` is used with a `batch_size`, RubyReactor implements an intelligent **back pressure** mechanism. Instead of flooding Redis and Sidekiq with millions of jobs immediately (which is the standard behavior for many background job systems), the system processes data in controlled chunks.
95
+
96
+ This approach provides critical benefits for stability and scalability:
97
+
98
+ 1. **Memory Efficiency**: By using `ActiveRecord` batching (`LIMIT` / `OFFSET`), only the current batch of records is loaded into memory. This allows processing datasets larger than available RAM.
99
+ 2. **Redis Protection**: Prevents "Queue Explosion". Only a small number of job arguments are stored in Redis at any time, preventing OOM errors in your Redis instance.
100
+ 3. **Database Stability**: Database load is distributed over time rather than spiking all at once.
101
+
102
+ **Visualizing the Flow:**
103
+
104
+ ```mermaid
105
+ graph TD
106
+ Start[Start Map] -->|Batch Size: N| BatchManager
107
+
108
+ subgraph "Back Pressure Loop"
109
+ BatchManager[Batch Manager] -->|Fetch N Items| DB[(Database)]
110
+ DB --> Records
111
+ Records -->|Enqueue N Jobs| Sidekiq
112
+
113
+ Sidekiq --> W1[Worker 1]
114
+ Sidekiq --> W2[Worker 2]
115
+
116
+ W1 -.->|Complete| Check{Batch Done?}
117
+ W2 -.->|Complete| Check
118
+
119
+ Check -->|No| Wait[Wait]
120
+ Check -->|Yes| Next[Trigger Next Batch]
121
+ Next --> BatchManager
122
+ end
123
+
124
+ BatchManager -->|No More Items| Finish[Aggregator]
125
+ ```
126
+
127
+ This ensures that the system works at the speed of your workers, not the speed of the enqueueing process, maintaining a constant and manageable resource footprint regardless of dataset size.
114
128
 
115
129
  ## Error Handling
116
130
 
@@ -128,9 +142,9 @@ map :strict_processing do
128
142
  end
129
143
  ```
130
144
 
131
- ### Collecting Partial Results
145
+ ### Collecting Results (Successes & Failures)
132
146
 
133
- If you want to process all elements regardless of failures, set `fail_fast false`. You can then use a `collect` block to handle successes and failures separately.
147
+ If you want to process all elements regardless of failures, set `fail_fast false`. The map step returns a `ResultEnumerator` that allows you to easily separate successful executions from failures.
134
148
 
135
149
  ```ruby
136
150
  map :resilient_processing do
@@ -145,18 +159,39 @@ map :resilient_processing do
145
159
  end
146
160
 
147
161
  returns :risky_operation
162
+ end
163
+
164
+ step :analyze_results do
165
+ argument :results, result(:resilient_processing)
166
+
167
+ run do |args|
168
+ col = args[:results]
169
+
170
+ # Iterate over successful results
171
+ col.successes.each do |value|
172
+ # 'value' is the direct return value of the map element
173
+ puts "Success: #{value}"
174
+ end
175
+
176
+ # Iterate over failures
177
+ col.failures.each do |error|
178
+ # 'error' is the failure object/message itself
179
+ puts "Error: #{error}"
180
+ end
181
+
182
+ # Note: Iterating the collection directly yields wrapped objects
183
+ col.each do |result|
184
+ if result.is_a?(RubyReactor::Success)
185
+ puts "Wrapped Value: #{result.value}"
186
+ else
187
+ puts "Wrapped Error: #{result.error}"
188
+ end
189
+ end
148
190
 
149
- # Aggregate results
150
- collect do |results|
151
- # results is an array of Result objects (Success or Failure)
152
- successful = results.select(&:success?).map(&:value)
153
- failed = results.select(&:failure?).map(&:error)
154
-
155
- {
156
- processed: successful,
157
- errors: failed,
158
- success_rate: successful.length.to_f / results.length
159
- }
191
+ Success({
192
+ success_count: col.successes.count,
193
+ failure_count: col.failures.count
194
+ })
160
195
  end
161
196
  end
162
197
  ```
@@ -192,33 +227,4 @@ end
192
227
  - **Async Mode**: Retries are handled by requeuing the Sidekiq job with a delay. This is non-blocking and efficient.
193
228
  - **Sync Mode**: Retries happen immediately within the execution thread (blocking).
194
229
 
195
- ## Visualization
196
230
 
197
- ### Async Batch Execution
198
-
199
- ```mermaid
200
- graph TD
201
- Start[Start Map] --> Init[Initialize Batch]
202
- Init --> Q1["Queue Initial Batch<br/>(Size N)"]
203
-
204
- subgraph Workers
205
- W1[Worker 1]
206
- W2[Worker 2]
207
- W3[Worker ...]
208
- end
209
-
210
- Q1 --> W1
211
- Q1 --> W2
212
-
213
- W1 -->|Complete| Next1{More Items?}
214
- W2 -->|Complete| Next2{More Items?}
215
-
216
- Next1 -->|Yes| Q2[Queue Next Item]
217
- Next2 -->|Yes| Q2
218
-
219
- Q2 --> W3
220
-
221
- Next1 -->|No| Check{All Done?}
222
- Check -->|Yes| Collect[Run Collector]
223
- Collect --> Finish[Final Result]
224
- ```