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,365 @@
|
|
|
1
|
+
# Order Processing Reactor Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates a complete order processing workflow with validation, payment processing, inventory management, and notifications.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The OrderProcessingReactor handles the complete order fulfillment process:
|
|
8
|
+
|
|
9
|
+
1. **Validate Order**: Ensure order exists and is processable
|
|
10
|
+
2. **Check Inventory**: Verify all items are available
|
|
11
|
+
3. **Reserve Inventory**: Temporarily reserve items
|
|
12
|
+
4. **Process Payment**: Charge the customer's payment method
|
|
13
|
+
5. **Update Inventory**: Permanently decrement inventory
|
|
14
|
+
6. **Send Confirmation**: Email order confirmation
|
|
15
|
+
|
|
16
|
+
### Order Processing Workflow
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
graph TD
|
|
20
|
+
A[Order Submitted] --> B[validate_order]
|
|
21
|
+
B --> C{Order<br/>Valid?}
|
|
22
|
+
C -->|No| D[Fail: Invalid Order]
|
|
23
|
+
C -->|Yes| E[check_inventory]
|
|
24
|
+
E --> F{Inventory<br/>Available?}
|
|
25
|
+
F -->|No| G[Fail: Insufficient Stock]
|
|
26
|
+
F -->|Yes| H[reserve_inventory]
|
|
27
|
+
H --> I{Reservation<br/>Successful?}
|
|
28
|
+
I -->|No| J[Fail: Reservation Error]
|
|
29
|
+
I -->|Yes| K[process_payment]
|
|
30
|
+
K --> L{Payment<br/>Successful?}
|
|
31
|
+
L -->|No| M[Compensate: Release Reservation]
|
|
32
|
+
L -->|Yes| N[update_inventory]
|
|
33
|
+
N --> O{Update<br/>Successful?}
|
|
34
|
+
O -->|No| P[Compensate: Refund + Release]
|
|
35
|
+
O -->|Yes| Q[update_order_status]
|
|
36
|
+
Q --> R[send_confirmation]
|
|
37
|
+
R --> S[Success: Order Complete]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Implementation
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
44
|
+
async true # Enable asynchronous execution
|
|
45
|
+
|
|
46
|
+
# Reactor-level retry defaults
|
|
47
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 2.seconds
|
|
48
|
+
|
|
49
|
+
step :validate_order do
|
|
50
|
+
validate_args do
|
|
51
|
+
required(:order_id).filled(:string)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
run do |order_id:|
|
|
55
|
+
order = Order.find_by(id: order_id)
|
|
56
|
+
raise "Order not found" unless order
|
|
57
|
+
raise "Order already processed" if order.processed?
|
|
58
|
+
raise "Order cancelled" if order.cancelled?
|
|
59
|
+
|
|
60
|
+
Success({ order: order })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
step :check_inventory do
|
|
65
|
+
argument :order, result(:validate_order)
|
|
66
|
+
|
|
67
|
+
run do |order:, **|
|
|
68
|
+
unavailable_items = []
|
|
69
|
+
|
|
70
|
+
order.items.each do |item|
|
|
71
|
+
product = Product.find(item.product_id)
|
|
72
|
+
if product.inventory_count < item.quantity
|
|
73
|
+
unavailable_items << {
|
|
74
|
+
product_id: item.product_id,
|
|
75
|
+
requested: item.quantity,
|
|
76
|
+
available: product.inventory_count
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
raise "Insufficient inventory: #{unavailable_items}" unless unavailable_items.empty?
|
|
82
|
+
|
|
83
|
+
Success({ inventory_checked: true })
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
step :reserve_inventory do
|
|
88
|
+
argument :order, result(:validate_order)
|
|
89
|
+
|
|
90
|
+
run do |order:, **|
|
|
91
|
+
reservation_id = InventoryService.reserve_items(order.items)
|
|
92
|
+
raise "Inventory reservation failed" unless reservation_id
|
|
93
|
+
|
|
94
|
+
Success({ reservation_id: reservation_id })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
undo do |reservation_id:, **|
|
|
98
|
+
# Release reservation on failure
|
|
99
|
+
InventoryService.release_reservation(reservation_id) if reservation_id
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
step :process_payment do
|
|
104
|
+
argument :order, result(:validate_order)
|
|
105
|
+
|
|
106
|
+
# Payment processing needs careful retry handling
|
|
107
|
+
retries max_attempts: 2, backoff: :fixed, base_delay: 30.seconds
|
|
108
|
+
|
|
109
|
+
run do |order:, **|
|
|
110
|
+
payment_result = PaymentService.charge(
|
|
111
|
+
amount: order.total,
|
|
112
|
+
currency: order.currency,
|
|
113
|
+
card_token: order.customer.card_token,
|
|
114
|
+
description: "Order ##{order.id}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
raise "Payment failed: #{payment_result.error}" unless payment_result.success?
|
|
118
|
+
|
|
119
|
+
Success({ payment_id: payment_result.id, payment_amount: order.total })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
undo do |payment_id:, **|
|
|
123
|
+
# Refund payment on failure
|
|
124
|
+
PaymentService.refund(payment_id) if payment_id
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
step :update_inventory do
|
|
129
|
+
argument :order, result(:validate_order)
|
|
130
|
+
argument :reservation_id, result(:reserve_inventory)
|
|
131
|
+
|
|
132
|
+
run do |order:, reservation_id:, **|
|
|
133
|
+
# Convert reservation to permanent inventory reduction
|
|
134
|
+
success = InventoryService.confirm_reservation(reservation_id)
|
|
135
|
+
raise "Inventory update failed" unless success
|
|
136
|
+
|
|
137
|
+
Success({ inventory_updated: true })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
undo do |order:, reservation_id:, **|
|
|
141
|
+
# This shouldn't normally happen since payment succeeded
|
|
142
|
+
# But if it does, we need to restore inventory
|
|
143
|
+
InventoryService.restore_from_reservation(reservation_id) if reservation_id
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
step :update_order_status do
|
|
148
|
+
argument :order, result(:validate_order)
|
|
149
|
+
argument :payment_id, result(:process_payment)
|
|
150
|
+
|
|
151
|
+
run do |order:, payment_id:, **|
|
|
152
|
+
order.update!(
|
|
153
|
+
status: :completed,
|
|
154
|
+
payment_id: payment_id,
|
|
155
|
+
processed_at: Time.current
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
Success({ order_completed: true })
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
step :send_confirmation do
|
|
163
|
+
argument :order, result(:validate_order)
|
|
164
|
+
argument :payment_id, result(:process_payment)
|
|
165
|
+
|
|
166
|
+
retries max_attempts: 3, backoff: :linear, base_delay: 10.seconds
|
|
167
|
+
|
|
168
|
+
run do |order:, payment_id:, **|
|
|
169
|
+
email_result = EmailService.send_order_confirmation(
|
|
170
|
+
to: order.customer.email,
|
|
171
|
+
order: order,
|
|
172
|
+
payment_id: payment_id
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
raise "Confirmation email failed" unless email_result.success?
|
|
176
|
+
|
|
177
|
+
Success({ confirmation_sent: true })
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Usage
|
|
184
|
+
|
|
185
|
+
### Asynchronous Execution
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# Start order processing asynchronously
|
|
189
|
+
async_result = OrderProcessingReactor.run(order_id: 12345)
|
|
190
|
+
|
|
191
|
+
# Check status later
|
|
192
|
+
case async_result.status
|
|
193
|
+
when :success
|
|
194
|
+
puts "Order processed successfully!"
|
|
195
|
+
result = async_result.result
|
|
196
|
+
puts "Payment ID: #{result.step_results[:process_payment][:payment_id]}"
|
|
197
|
+
when :failed
|
|
198
|
+
puts "Order processing failed: #{async_result.error.message}"
|
|
199
|
+
# Could trigger manual review process
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Synchronous Execution (for testing)
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# For testing or immediate processing
|
|
207
|
+
result = OrderProcessingReactor.run(order_id: 12345)
|
|
208
|
+
|
|
209
|
+
if result.success?
|
|
210
|
+
puts "Order completed!"
|
|
211
|
+
puts "Steps completed: #{result.completed_steps.to_a}"
|
|
212
|
+
else
|
|
213
|
+
puts "Failed at step: #{result.error.step_name}"
|
|
214
|
+
puts "Error: #{result.error.message}"
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Error Scenarios
|
|
219
|
+
|
|
220
|
+
### Insufficient Inventory
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
Step: check_inventory fails
|
|
224
|
+
→ Compensation: none (no state changes yet)
|
|
225
|
+
→ Result: failure with inventory details
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Payment Failure
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Step: process_payment fails
|
|
232
|
+
→ Compensation: release_inventory (reservation_id)
|
|
233
|
+
→ Result: failure with payment error
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Email Failure
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
Step: send_confirmation fails (after successful payment/inventory update)
|
|
240
|
+
→ Compensation: refund_payment → restore_inventory
|
|
241
|
+
→ Result: failure (but order is actually complete - manual confirmation may be needed)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Testing
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
RSpec.describe OrderProcessingReactor do
|
|
248
|
+
let(:order) { create(:order, :pending) }
|
|
249
|
+
|
|
250
|
+
context "successful order processing" do
|
|
251
|
+
it "completes all steps successfully" do
|
|
252
|
+
# Mock all external services
|
|
253
|
+
allow(Order).to receive(:find_by).and_return(order)
|
|
254
|
+
allow(InventoryService).to receive(:reserve_items).and_return("res_123")
|
|
255
|
+
allow(PaymentService).to receive(:charge).and_return(successful_payment)
|
|
256
|
+
allow(InventoryService).to receive(:confirm_reservation).and_return(true)
|
|
257
|
+
allow(EmailService).to receive(:send_order_confirmation).and_return(successful_email)
|
|
258
|
+
|
|
259
|
+
result = OrderProcessingReactor.run(order_id: order.id)
|
|
260
|
+
|
|
261
|
+
expect(result).to be_success
|
|
262
|
+
expect(result.completed_steps).to include(:send_confirmation)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
context "payment failure" do
|
|
267
|
+
it "compensates inventory reservation" do
|
|
268
|
+
allow(Order).to receive(:find_by).and_return(order)
|
|
269
|
+
allow(InventoryService).to receive(:reserve_items).and_return("res_123")
|
|
270
|
+
allow(PaymentService).to receive(:charge).and_return(failed_payment)
|
|
271
|
+
|
|
272
|
+
expect(InventoryService).to receive(:release_reservation).with("res_123")
|
|
273
|
+
|
|
274
|
+
result = OrderProcessingReactor.run(order_id: order.id)
|
|
275
|
+
|
|
276
|
+
expect(result).to be_failure
|
|
277
|
+
expect(result.error.message).to include("Payment failed")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Monitoring
|
|
284
|
+
|
|
285
|
+
Key metrics to track:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# Success rates
|
|
289
|
+
order_processing_success_rate
|
|
290
|
+
payment_success_rate
|
|
291
|
+
inventory_reservation_success_rate
|
|
292
|
+
|
|
293
|
+
# Performance
|
|
294
|
+
average_order_processing_time
|
|
295
|
+
payment_processing_latency
|
|
296
|
+
|
|
297
|
+
# Error rates
|
|
298
|
+
inventory_insufficient_rate
|
|
299
|
+
payment_failure_rate
|
|
300
|
+
email_delivery_failure_rate
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Scaling Considerations
|
|
304
|
+
|
|
305
|
+
- **High Volume**: Use async execution with multiple Sidekiq workers
|
|
306
|
+
- **Payment Processing**: Implement idempotency keys for payment providers
|
|
307
|
+
- **Inventory**: Use optimistic locking or database transactions
|
|
308
|
+
- **Email**: Queue emails separately to avoid blocking order completion
|
|
309
|
+
|
|
310
|
+
## Extensions
|
|
311
|
+
|
|
312
|
+
### Partial Order Processing
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
class PartialOrderProcessingReactor < OrderProcessingReactor
|
|
316
|
+
# Override to allow partial fulfillment
|
|
317
|
+
step :check_inventory do
|
|
318
|
+
run do |order:, **|
|
|
319
|
+
available_items, unavailable_items = partition_available_items(order.items)
|
|
320
|
+
|
|
321
|
+
if available_items.any? && unavailable_items.any?
|
|
322
|
+
# Create partial order for available items
|
|
323
|
+
partial_order = create_partial_order(order, available_items)
|
|
324
|
+
Success({ partial_order: partial_order, unavailable_items: unavailable_items })
|
|
325
|
+
elsif available_items.empty?
|
|
326
|
+
raise "No items available"
|
|
327
|
+
else
|
|
328
|
+
Success({ inventory_checked: true })
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Order Cancellation
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
class OrderCancellationReactor < RubyReactor::Reactor
|
|
339
|
+
step :load_order do
|
|
340
|
+
validate_args do
|
|
341
|
+
required(:order_id).filled(:string)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
run do |order_id:|
|
|
345
|
+
order = Order.find_by(id: order_id)
|
|
346
|
+
raise "Order not found" unless order
|
|
347
|
+
Success({ order: order })
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
step :cancel_order do
|
|
352
|
+
run do |order:, **|
|
|
353
|
+
# Only cancel if not already completed
|
|
354
|
+
if order.completed?
|
|
355
|
+
# Initiate refund and inventory restoration
|
|
356
|
+
PaymentService.refund(order.payment_id)
|
|
357
|
+
InventoryService.restore_order_items(order)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
order.update!(status: :cancelled)
|
|
361
|
+
Success({ cancelled: true })
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|