ruby_reactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Getting Started with RubyReactor
|
|
2
|
+
|
|
3
|
+
This guide will help you get started with RubyReactor, from installation to your first reactor.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add RubyReactor to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'ruby_reactor'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install ruby_reactor
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Your First Reactor
|
|
20
|
+
|
|
21
|
+
Let's create a simple order processing reactor:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require 'ruby_reactor'
|
|
25
|
+
|
|
26
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
27
|
+
step :validate_order do
|
|
28
|
+
validate_args do
|
|
29
|
+
required(:order_id).filled(:string)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
run do |order_id|
|
|
33
|
+
# Validate the order exists and is in correct state
|
|
34
|
+
order = Order.find(order_id)
|
|
35
|
+
raise "Order not found" unless order
|
|
36
|
+
raise "Order already processed" if order.processed?
|
|
37
|
+
|
|
38
|
+
Success({ order: order })
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
step :process_payment do
|
|
43
|
+
run do |order:, **|
|
|
44
|
+
# Process payment for the order
|
|
45
|
+
payment_result = PaymentService.charge(order.total, order.customer.card_token)
|
|
46
|
+
raise "Payment failed" unless payment_result.success?
|
|
47
|
+
|
|
48
|
+
Success({ payment_id: payment_result.id })
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
step :update_inventory do
|
|
53
|
+
run do |order:, **|
|
|
54
|
+
# Update inventory for each item
|
|
55
|
+
order.items.each do |item|
|
|
56
|
+
InventoryService.decrement(item.product_id, item.quantity)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Success({ inventory_updated: true })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
step :send_confirmation do
|
|
64
|
+
run do |order:, payment_id:, **|
|
|
65
|
+
# Send confirmation email
|
|
66
|
+
email_result = EmailService.send_confirmation(
|
|
67
|
+
order.customer.email,
|
|
68
|
+
order_id: order.id,
|
|
69
|
+
payment_id: payment_id
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
Success({ confirmation_sent: email_result.success? })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Executing a Reactor
|
|
79
|
+
|
|
80
|
+
### Synchronous Execution
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Run the reactor synchronously
|
|
84
|
+
result = OrderProcessingReactor.run(order_id: 123)
|
|
85
|
+
|
|
86
|
+
if result.success?
|
|
87
|
+
puts "Order processed successfully!"
|
|
88
|
+
puts "Results: #{result.step_results}"
|
|
89
|
+
else
|
|
90
|
+
puts "Order processing failed: #{result.error}"
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Asynchronous Execution
|
|
95
|
+
|
|
96
|
+
For async execution, you need Sidekiq configured. Mark the reactor as async:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
100
|
+
async true # Enable full reactor async
|
|
101
|
+
|
|
102
|
+
# ... steps defined above
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Run asynchronously
|
|
106
|
+
async_result = OrderProcessingReactor.run(order_id: 123)
|
|
107
|
+
# Returns immediately with AsyncResult
|
|
108
|
+
# Check status later with async_result.status
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Understanding Results
|
|
112
|
+
|
|
113
|
+
RubyReactor returns detailed execution results:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
result = OrderProcessingReactor.run(order_id: 123)
|
|
117
|
+
|
|
118
|
+
# Check overall success
|
|
119
|
+
result.success? # => true/false
|
|
120
|
+
|
|
121
|
+
# Access step results
|
|
122
|
+
result.step_results[:validate_order] # => { order: #<Order> }
|
|
123
|
+
result.step_results[:process_payment] # => { payment_id: "pay_123" }
|
|
124
|
+
|
|
125
|
+
# Access intermediate results
|
|
126
|
+
result.intermediate_results # => Hash of all step outputs
|
|
127
|
+
|
|
128
|
+
# Check completed steps
|
|
129
|
+
result.completed_steps # => Set of completed step names
|
|
130
|
+
|
|
131
|
+
# Error information (if failed)
|
|
132
|
+
result.error # => Exception that caused failure
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Step Dependencies
|
|
136
|
+
|
|
137
|
+
Steps can depend on each other using the `argument` method with `result()`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class ComplexReactor < RubyReactor::Reactor
|
|
141
|
+
step :validate_order do
|
|
142
|
+
run { validate_order_logic }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
step :check_inventory do
|
|
146
|
+
argument :order, result(:validate_order)
|
|
147
|
+
|
|
148
|
+
run do |args, _context|
|
|
149
|
+
check_inventory_for_order(args[:order])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
step :process_payment do
|
|
154
|
+
argument :order, result(:check_inventory)
|
|
155
|
+
|
|
156
|
+
run do |args, _context|
|
|
157
|
+
process_payment_for_order(args[:order])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Error Handling and Compensation
|
|
164
|
+
|
|
165
|
+
RubyReactor automatically handles errors and provides compensation:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
169
|
+
step :validate_order do
|
|
170
|
+
run { validate_order_logic }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
step :process_payment do
|
|
174
|
+
run { process_payment_logic }
|
|
175
|
+
|
|
176
|
+
undo do |payment_id:, **|
|
|
177
|
+
# Undo the payment if something fails later
|
|
178
|
+
PaymentService.refund(payment_id)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
step :update_inventory do
|
|
183
|
+
run { update_inventory_logic }
|
|
184
|
+
|
|
185
|
+
compensate do |order:, **|
|
|
186
|
+
# Restore inventory if something fails later
|
|
187
|
+
order.items.each do |item|
|
|
188
|
+
InventoryService.increment(item.product_id, item.quantity)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
If `update_inventory` fails, RubyReactor will:
|
|
196
|
+
1. Run the `update_inventory` compensate block
|
|
197
|
+
2. Run the `process_payment` undo block
|
|
198
|
+
3. Return a failure result
|
|
199
|
+
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
### Sidekiq Setup (for Async)
|
|
203
|
+
|
|
204
|
+
Add to your Sidekiq configuration:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# config/sidekiq.rb
|
|
208
|
+
require 'ruby_reactor/worker'
|
|
209
|
+
|
|
210
|
+
# Configure RubyReactor
|
|
211
|
+
RubyReactor.configure do |config|
|
|
212
|
+
config.sidekiq_queue = :default
|
|
213
|
+
config.sidekiq_retry_count = 3
|
|
214
|
+
config.logger = Logger.new('log/ruby_reactor.log')
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
## Next Steps
|
|
220
|
+
|
|
221
|
+
- Learn about [async reactors](async_reactors.md)
|
|
222
|
+
- Configure [retry policies](retry_configuration.md)
|
|
223
|
+
- See [examples](examples/) for more patterns
|
|
224
|
+
- Check the [API reference](api_reference.md) for detailed documentation
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Retry Configuration
|
|
2
|
+
|
|
3
|
+
RubyReactor provides flexible, non-blocking retry mechanisms that requeue jobs instead of blocking worker threads. Retry policies can be configured at both reactor and step levels.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The retry system offers:
|
|
8
|
+
|
|
9
|
+
- **Non-blocking retries on Async**: Jobs are requeued with calculated delays
|
|
10
|
+
- **Multiple backoff strategies**: Exponential, linear, and fixed delays
|
|
11
|
+
- **Step-level control**: Different retry policies for different steps
|
|
12
|
+
- **Full observability**: Complete visibility into retry attempts
|
|
13
|
+
|
|
14
|
+
### Retry Flow Architecture
|
|
15
|
+
|
|
16
|
+
```mermaid
|
|
17
|
+
graph TD
|
|
18
|
+
A[Step Execution] --> B{Step<br/>Succeeds?}
|
|
19
|
+
B -->|Yes| C[Continue to Next Step]
|
|
20
|
+
B -->|No| D{Can Retry?<br/>attempts < max_attempts}
|
|
21
|
+
D -->|No| E[Final Failure<br/>Run Compensation]
|
|
22
|
+
D -->|Yes| F[Calculate Backoff Delay<br/>exponential/linear/fixed]
|
|
23
|
+
F --> G[Serialize Context<br/>with Retry State]
|
|
24
|
+
G --> H[Queue Job for Retry<br/>with Calculated Delay]
|
|
25
|
+
H --> I[Worker Freed<br/>No Thread Blocking]
|
|
26
|
+
I --> J[Delay Elapses]
|
|
27
|
+
J --> K[Worker Picks Up<br/>Retry Job]
|
|
28
|
+
K --> L[Deserialize Context]
|
|
29
|
+
L --> M[Resume Execution<br/>from Failed Step]
|
|
30
|
+
M --> A
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Basic Retry Configuration
|
|
34
|
+
|
|
35
|
+
### Step-Level Retry
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class PaymentReactor < RubyReactor::Reactor
|
|
39
|
+
async true
|
|
40
|
+
|
|
41
|
+
step :charge_card do
|
|
42
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
|
|
43
|
+
run { PaymentService.charge(card_token, amount) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Reactor-Level Defaults
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class PaymentReactor < RubyReactor::Reactor
|
|
52
|
+
async true
|
|
53
|
+
|
|
54
|
+
# All steps inherit these defaults
|
|
55
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 2.seconds
|
|
56
|
+
|
|
57
|
+
step :validate_card do
|
|
58
|
+
# Uses reactor defaults
|
|
59
|
+
run { validate_card_details }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
step :charge_card do
|
|
63
|
+
# Override for this specific step
|
|
64
|
+
retries max_attempts: 5, backoff: :linear, base_delay: 10.seconds
|
|
65
|
+
run { PaymentService.charge(card_token, amount) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Retry Parameters
|
|
71
|
+
|
|
72
|
+
### max_attempts
|
|
73
|
+
Maximum number of execution attempts (including the initial attempt).
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
retries max_attempts: 5 # 1 initial + 4 retries = 5 total attempts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### backoff
|
|
80
|
+
The backoff strategy for calculating delays between retry attempts.
|
|
81
|
+
|
|
82
|
+
**Options:**
|
|
83
|
+
- `:exponential` (default): Delay doubles with each attempt
|
|
84
|
+
- `:linear`: Delay increases linearly
|
|
85
|
+
- `:fixed`: Same delay for each attempt
|
|
86
|
+
|
|
87
|
+
### base_delay
|
|
88
|
+
The base delay for retry calculations. Can be a number (seconds) or ActiveSupport duration.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
retry base_delay: 5.seconds
|
|
92
|
+
retry base_delay: 300 # 5 minutes in seconds
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Backoff Strategies
|
|
96
|
+
|
|
97
|
+
### Exponential Backoff
|
|
98
|
+
|
|
99
|
+
Delay doubles with each retry attempt. Best for external services that may be temporarily overloaded.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
retries max_attempts: 4, backoff: :exponential, base_delay: 1.second
|
|
103
|
+
# Delays: 1s, 2s, 4s (total: 7 seconds)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Use cases:**
|
|
107
|
+
- API rate limiting
|
|
108
|
+
- Temporary service unavailability
|
|
109
|
+
- Network timeouts
|
|
110
|
+
|
|
111
|
+
### Linear Backoff
|
|
112
|
+
|
|
113
|
+
Delay increases linearly with each attempt. Provides predictable, gradually increasing delays.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
retries max_attempts: 4, backoff: :linear, base_delay: 5.seconds
|
|
117
|
+
# Delays: 5s, 10s, 15s (total: 30 seconds)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Use cases:**
|
|
121
|
+
- Database connection issues
|
|
122
|
+
- Resource contention
|
|
123
|
+
- Gradual backpressure
|
|
124
|
+
|
|
125
|
+
### Fixed Backoff
|
|
126
|
+
|
|
127
|
+
Same delay between each retry attempt. Simplest strategy with predictable timing.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
retries max_attempts: 4, backoff: :fixed, base_delay: 10.seconds
|
|
131
|
+
# Delays: 10s, 10s, 10s (total: 30 seconds)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Use cases:**
|
|
135
|
+
- Simple retry scenarios
|
|
136
|
+
- When timing precision matters
|
|
137
|
+
- Testing environments
|
|
138
|
+
|
|
139
|
+
## Idempotency
|
|
140
|
+
|
|
141
|
+
### What is Idempotency?
|
|
142
|
+
|
|
143
|
+
An operation is idempotent if executing it multiple times produces the same result as executing it once.
|
|
144
|
+
|
|
145
|
+
### Idempotent vs Non-Idempotent Operations
|
|
146
|
+
|
|
147
|
+
**Idempotent operations (safe to retry):**
|
|
148
|
+
- Reading data
|
|
149
|
+
- Updating records with same values
|
|
150
|
+
- Sending notifications (with deduplication)
|
|
151
|
+
- Idempotent API calls
|
|
152
|
+
|
|
153
|
+
**Non-idempotent operations (unsafe to retry):**
|
|
154
|
+
- Creating new records
|
|
155
|
+
- Charging payments (without deduplication)
|
|
156
|
+
- Sending unique messages
|
|
157
|
+
- File system operations
|
|
158
|
+
|
|
159
|
+
### Best Practices
|
|
160
|
+
|
|
161
|
+
1. **Design for idempotency**: Structure operations to be safely retryable
|
|
162
|
+
2. **Use idempotency keys**: For payments, orders, etc.
|
|
163
|
+
3. **Test thoroughly**: Verify retry behavior doesn't cause issues
|
|
164
|
+
|
|
165
|
+
## Advanced Configuration
|
|
166
|
+
|
|
167
|
+
### Complex Retry Scenarios
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
171
|
+
async true
|
|
172
|
+
|
|
173
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 2.seconds
|
|
174
|
+
|
|
175
|
+
step :validate_order do
|
|
176
|
+
# Quick validation - no retry needed
|
|
177
|
+
run { validate_order_exists(order_id) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
step :check_inventory do
|
|
181
|
+
# Inventory checks can be retried
|
|
182
|
+
retries max_attempts: 5, backoff: :linear, base_delay: 1.second
|
|
183
|
+
run { check_inventory_availability(order) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
step :reserve_inventory do
|
|
187
|
+
# Inventory reservation - must be idempotent
|
|
188
|
+
retries max_attempts: 3, backoff: :fixed, base_delay: 5.seconds
|
|
189
|
+
run { InventoryService.reserve_items(order.items) }
|
|
190
|
+
|
|
191
|
+
compensate do
|
|
192
|
+
# Release reservation on failure
|
|
193
|
+
InventoryService.release_reservation(order.items)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
step :process_payment do
|
|
198
|
+
# Payment processing - critical, fewer retries
|
|
199
|
+
retries max_attempts: 2, backoff: :exponential, base_delay: 10.seconds
|
|
200
|
+
run { PaymentService.charge(order.total, order.card_token) }
|
|
201
|
+
|
|
202
|
+
compensate do |payment_id:|
|
|
203
|
+
# Refund on failure
|
|
204
|
+
PaymentService.refund(payment_id) if payment_id
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
step :confirm_order do
|
|
209
|
+
# Final confirmation - must succeed
|
|
210
|
+
run { OrderService.mark_completed(order_id) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Conditional Retry Logic
|
|
216
|
+
|
|
217
|
+
For more complex retry logic, you can implement custom retry handlers:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
class CustomRetryReactor < RubyReactor::Reactor
|
|
221
|
+
async true
|
|
222
|
+
|
|
223
|
+
step :call_external_api do
|
|
224
|
+
retries max_attempts: 5, backoff: :exponential, base_delay: 1.second
|
|
225
|
+
run do
|
|
226
|
+
result = ExternalAPI.call
|
|
227
|
+
# Raise specific errors based on response
|
|
228
|
+
case result.status
|
|
229
|
+
when 429 # Rate limited
|
|
230
|
+
Failure(RateLimitError.new(result) retryable: true)
|
|
231
|
+
when 500 # Server error
|
|
232
|
+
Failure(ServerError.new(result) retryable: true)
|
|
233
|
+
when 400 # Bad request
|
|
234
|
+
Failure(ValidationError.new(result) retryable: false)
|
|
235
|
+
else
|
|
236
|
+
result
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Monitoring and Observability
|
|
244
|
+
|
|
245
|
+
### Retry Metrics
|
|
246
|
+
|
|
247
|
+
Track these important metrics:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# In your monitoring system
|
|
251
|
+
retry_attempt_count(step_name)
|
|
252
|
+
retry_success_rate(step_name)
|
|
253
|
+
average_retry_delay(step_name)
|
|
254
|
+
retry_timeout_count(step_name)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
### Sidekiq Web UI
|
|
259
|
+
|
|
260
|
+
Retry jobs are visible in Sidekiq web interface with:
|
|
261
|
+
- Step name and attempt number
|
|
262
|
+
- Failure reason and stack trace
|
|
263
|
+
- Scheduled retry time
|
|
264
|
+
- Job arguments and context
|
|
265
|
+
|
|
266
|
+
## Performance Considerations
|
|
267
|
+
|
|
268
|
+
### Retry Storm Prevention
|
|
269
|
+
|
|
270
|
+
Avoid retry storms by:
|
|
271
|
+
|
|
272
|
+
1. **Reasonable delays**: Don't use very short base delays
|
|
273
|
+
2. **Limited attempts**: Set appropriate max_attempts limits
|
|
274
|
+
3. **Circuit breakers**: Implement circuit breaker patterns for external services
|
|
275
|
+
4. **Rate limiting**: Consider rate limiting at the application level
|
|
276
|
+
|
|
277
|
+
### Resource Usage
|
|
278
|
+
|
|
279
|
+
- **Worker threads**: Retries don't block workers, improving utilization
|
|
280
|
+
- **Memory**: Context serialization adds memory overhead
|
|
281
|
+
- **Redis**: Job storage and queue management
|
|
282
|
+
- **Database**: Potential increased load from idempotent operations
|
|
283
|
+
|
|
284
|
+
### Tuning Guidelines
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# Fast-retry scenario (API calls)
|
|
288
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 1.second
|
|
289
|
+
|
|
290
|
+
# Slow-retry scenario (batch processing)
|
|
291
|
+
retries max_attempts: 5, backoff: :linear, base_delay: 5.minutes
|
|
292
|
+
|
|
293
|
+
# Critical operations (payments)
|
|
294
|
+
retries max_attempts: 2, backoff: :fixed, base_delay: 30.seconds
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Error Types and Handling
|
|
298
|
+
|
|
299
|
+
### Retryable Errors
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
class NetworkTimeoutError < StandardError
|
|
303
|
+
def retryable?
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
class ValidationError < StandardError
|
|
309
|
+
def retryable?
|
|
310
|
+
false # Don't retry validation errors
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Testing Retry Behavior
|
|
316
|
+
|
|
317
|
+
### Unit Testing
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
RSpec.describe PaymentReactor do
|
|
321
|
+
it "retries failed payment with exponential backoff" do
|
|
322
|
+
allow(PaymentService).to receive(:charge)
|
|
323
|
+
.and_raise(NetworkError.new("Timeout"))
|
|
324
|
+
.and_raise(NetworkError.new("Timeout"))
|
|
325
|
+
.and_return(payment_result)
|
|
326
|
+
|
|
327
|
+
expect(PaymentService).to receive(:charge).exactly(3).times
|
|
328
|
+
|
|
329
|
+
result = PaymentReactor.run(card_token: "tok_123", amount: 100)
|
|
330
|
+
|
|
331
|
+
expect(result).to be_success
|
|
332
|
+
expect(result.step_results[:charge_card][:payment_id]).to eq("pay_123")
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Integration Testing
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
describe "Retry integration" do
|
|
341
|
+
it "handles real Sidekiq retry scenarios" do
|
|
342
|
+
# Test with actual Sidekiq worker
|
|
343
|
+
Sidekiq::Testing.fake! do
|
|
344
|
+
result = FailingReactor.run(input: "test")
|
|
345
|
+
|
|
346
|
+
# Verify job was queued for retry
|
|
347
|
+
expect(RubyReactor::SidekiqWorkers::Worker.jobs.size).to eq(1)
|
|
348
|
+
|
|
349
|
+
# Process the retry
|
|
350
|
+
RubyReactor::SidekiqWorkers::Worker.drain
|
|
351
|
+
|
|
352
|
+
# Verify final success
|
|
353
|
+
expect(result).to be_success
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class AsyncRouter
|
|
5
|
+
def self.perform_async(serialized_context, reactor_class_name = nil, intermediate_results: {})
|
|
6
|
+
job_id = SidekiqWorkers::Worker.perform_async(serialized_context, reactor_class_name)
|
|
7
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.perform_in(delay, serialized_context, reactor_class_name = nil, intermediate_results: {})
|
|
11
|
+
job_id = SidekiqWorkers::Worker.perform_in(delay, serialized_context, reactor_class_name)
|
|
12
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# rubocop:disable Metrics/ParameterLists
|
|
16
|
+
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
17
|
+
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
18
|
+
batch_size: nil, serialized_context: nil)
|
|
19
|
+
RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
20
|
+
{
|
|
21
|
+
"map_id" => map_id,
|
|
22
|
+
"element_id" => element_id,
|
|
23
|
+
"index" => index,
|
|
24
|
+
"serialized_inputs" => serialized_inputs,
|
|
25
|
+
"reactor_class_info" => reactor_class_info,
|
|
26
|
+
"strict_ordering" => strict_ordering,
|
|
27
|
+
"parent_context_id" => parent_context_id,
|
|
28
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
29
|
+
"step_name" => step_name,
|
|
30
|
+
"batch_size" => batch_size,
|
|
31
|
+
"serialized_context" => serialized_context
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
37
|
+
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
38
|
+
batch_size: nil, serialized_context: nil)
|
|
39
|
+
RubyReactor::SidekiqWorkers::MapElementWorker.perform_in(
|
|
40
|
+
delay,
|
|
41
|
+
{
|
|
42
|
+
"map_id" => map_id,
|
|
43
|
+
"element_id" => element_id,
|
|
44
|
+
"index" => index,
|
|
45
|
+
"serialized_inputs" => serialized_inputs,
|
|
46
|
+
"reactor_class_info" => reactor_class_info,
|
|
47
|
+
"strict_ordering" => strict_ordering,
|
|
48
|
+
"parent_context_id" => parent_context_id,
|
|
49
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
50
|
+
"step_name" => step_name,
|
|
51
|
+
"batch_size" => batch_size,
|
|
52
|
+
"serialized_context" => serialized_context
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Metrics/ParameterLists
|
|
57
|
+
|
|
58
|
+
# rubocop:disable Metrics/ParameterLists
|
|
59
|
+
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
60
|
+
strict_ordering:, timeout:)
|
|
61
|
+
RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
62
|
+
{
|
|
63
|
+
"parent_context_id" => parent_context_id,
|
|
64
|
+
"map_id" => map_id,
|
|
65
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
66
|
+
"step_name" => step_name,
|
|
67
|
+
"strict_ordering" => strict_ordering,
|
|
68
|
+
"timeout" => timeout
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
# rubocop:enable Metrics/ParameterLists
|
|
73
|
+
|
|
74
|
+
# rubocop:disable Metrics/ParameterLists
|
|
75
|
+
def self.perform_map_execution_async(map_id:, serialized_inputs:, reactor_class_info:, strict_ordering:,
|
|
76
|
+
parent_context_id:, parent_reactor_class_name:, step_name:)
|
|
77
|
+
RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
|
|
78
|
+
{
|
|
79
|
+
"map_id" => map_id,
|
|
80
|
+
"serialized_inputs" => serialized_inputs,
|
|
81
|
+
"reactor_class_info" => reactor_class_info,
|
|
82
|
+
"strict_ordering" => strict_ordering,
|
|
83
|
+
"parent_context_id" => parent_context_id,
|
|
84
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
85
|
+
"step_name" => step_name
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
# rubocop:enable Metrics/ParameterLists
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
# Configuration class for RubyReactor settings
|
|
7
|
+
class Configuration
|
|
8
|
+
include Singleton
|
|
9
|
+
|
|
10
|
+
attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router
|
|
11
|
+
|
|
12
|
+
def sidekiq_queue
|
|
13
|
+
@sidekiq_queue ||= :default
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sidekiq_retry_count
|
|
17
|
+
@sidekiq_retry_count ||= 3
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def logger
|
|
21
|
+
@logger ||= Logger.new($stderr)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def async_router
|
|
25
|
+
@async_router ||= RubyReactor::AsyncRouter
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def storage
|
|
29
|
+
@storage ||= RubyReactor::Storage::Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def storage_adapter
|
|
33
|
+
@storage_adapter ||= case storage.adapter
|
|
34
|
+
when :redis
|
|
35
|
+
RubyReactor::Storage::RedisAdapter.new(url: storage.redis_url, **storage.redis_options)
|
|
36
|
+
else
|
|
37
|
+
raise "Unknown storage adapter: #{storage.adapter}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|