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
data/README.md
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# RubyReactor
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **DAG-based Execution**: Steps are executed based on their dependencies, allowing for parallel execution of independent steps.
|
|
8
|
+
- **Async Execution**: Steps can be executed asynchronously in the background using Sidekiq.
|
|
9
|
+
- **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
|
|
10
|
+
- **Retries**: Configurable retry logic for failed steps, with exponential backoff.
|
|
11
|
+
- **Compensation**: Automatic rollback of completed steps when a failure occurs.
|
|
12
|
+
- **Input Validation**: Integrated with `dry-validation` for robust input checking.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'ruby_reactor'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
And then execute:
|
|
23
|
+
|
|
24
|
+
$ bundle install
|
|
25
|
+
|
|
26
|
+
Or install it yourself as:
|
|
27
|
+
|
|
28
|
+
$ gem install ruby_reactor
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
Configure RubyReactor with your Sidekiq and Redis settings:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
RubyReactor.configure do |config|
|
|
36
|
+
# Redis configuration for state persistence
|
|
37
|
+
config.storage.adapter = :redis
|
|
38
|
+
config.storage.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
|
|
39
|
+
config.storage.redis_options = { timeout: 1 }
|
|
40
|
+
|
|
41
|
+
# Sidekiq configuration for async execution
|
|
42
|
+
config.sidekiq_queue = :default
|
|
43
|
+
config.sidekiq_retry_count = 3
|
|
44
|
+
|
|
45
|
+
# Logger configuration
|
|
46
|
+
config.logger = Logger.new($stdout)
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
RubyReactor allows you to define complex workflows as "reactors" with steps that can depend on each other, handle failures with compensations, and validate inputs.
|
|
53
|
+
|
|
54
|
+
### Basic Example: User Registration
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require 'ruby_reactor'
|
|
58
|
+
|
|
59
|
+
class UserRegistrationReactor < RubyReactor::Reactor
|
|
60
|
+
# Define inputs with optional validation
|
|
61
|
+
input :email
|
|
62
|
+
input :password
|
|
63
|
+
|
|
64
|
+
# Define steps with their dependencies
|
|
65
|
+
step :validate_email do
|
|
66
|
+
argument :email, input(:email)
|
|
67
|
+
|
|
68
|
+
run do |args, context|
|
|
69
|
+
if args[:email] && args[:email].include?('@')
|
|
70
|
+
Success(args[:email].trim)
|
|
71
|
+
else
|
|
72
|
+
Failure("Email must contain @")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
step :hash_password do
|
|
78
|
+
argument :password, input(:password)
|
|
79
|
+
|
|
80
|
+
run do |args, context|
|
|
81
|
+
require 'digest'
|
|
82
|
+
hashed = Digest::SHA256.hexdigest(args[:password])
|
|
83
|
+
Success(hashed)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
step :create_user do
|
|
88
|
+
# Arguments can reference results from other steps
|
|
89
|
+
argument :email, result(:validate_email)
|
|
90
|
+
argument :password_hash, result(:hash_password)
|
|
91
|
+
|
|
92
|
+
run do |args, context|
|
|
93
|
+
user = {
|
|
94
|
+
id: rand(10000),
|
|
95
|
+
email: args[:email],
|
|
96
|
+
password_hash: args[:password_hash],
|
|
97
|
+
created_at: Time.now
|
|
98
|
+
}
|
|
99
|
+
Success(user)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
conpensate do |error, args, context|
|
|
103
|
+
Notify.to(args[:email])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
step :notify_user do
|
|
108
|
+
argument :email, result(:validate_email)
|
|
109
|
+
wait_for :create_user
|
|
110
|
+
|
|
111
|
+
run do |args, _context|
|
|
112
|
+
Email.sent!(args[:email], "verify your email")
|
|
113
|
+
Success()
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
compensate do |error, args, context|
|
|
117
|
+
Email.send("support@acme.com", "Email verification for #{args[:email]} couldn't be sent")
|
|
118
|
+
Success()
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
# Specify which step's result to return
|
|
122
|
+
returns :create_user
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Run the reactor
|
|
126
|
+
result = UserRegistrationReactor.run(
|
|
127
|
+
email: 'alice@example.com',
|
|
128
|
+
password: 'secret123'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if result.success?
|
|
132
|
+
puts "User created: #{result.value[:email]}"
|
|
133
|
+
else
|
|
134
|
+
puts "Failed: #{result.error}"
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Async Execution
|
|
139
|
+
|
|
140
|
+
Execute reactors in the background using Sidekiq.
|
|
141
|
+
|
|
142
|
+
#### Full Reactor Async
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class AsyncReactor < RubyReactor::Reactor
|
|
146
|
+
async true # Entire reactor runs in background
|
|
147
|
+
|
|
148
|
+
step :long_running_task do
|
|
149
|
+
run { perform_heavy_work }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Returns immediately with AsyncResult
|
|
154
|
+
result = AsyncReactor.run(params)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Step-Level Async
|
|
158
|
+
|
|
159
|
+
You can also mark individual steps as async. Execution will proceed synchronously until the first async step is encountered, at which point the reactor execution is offloaded to a background job.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class CreateUserReactor < RubyReactor::Reactor
|
|
163
|
+
input :params
|
|
164
|
+
|
|
165
|
+
step :validate_inputs do
|
|
166
|
+
run { |args| validate(args[:params]) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
step :create_user do
|
|
170
|
+
argument :params, result(:validate_inputs)
|
|
171
|
+
run { |args| User.create(args[:params]) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# From here on will run async
|
|
175
|
+
step :open_account do
|
|
176
|
+
async true
|
|
177
|
+
argument :user, result(:create_user)
|
|
178
|
+
run { |args| Bank.open_account(args[:user]) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
step :report_new_user do
|
|
182
|
+
async true
|
|
183
|
+
argument :user, result(:create_user)
|
|
184
|
+
wait_for :open_account
|
|
185
|
+
run { |args| Analytics.track(args[:user]) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Usage
|
|
190
|
+
def create(params)
|
|
191
|
+
# Returns an AsyncResult immediately when 'open_account' is reached
|
|
192
|
+
result = CreateUserReactor.run(params)
|
|
193
|
+
|
|
194
|
+
# Access synchronous results immediately
|
|
195
|
+
user = result.intermediate_results[:create_user]
|
|
196
|
+
|
|
197
|
+
# do something with user
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Map & Parallel Execution
|
|
202
|
+
|
|
203
|
+
Process collections in parallel using the `map` step:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
class DataProcessingReactor < RubyReactor::Reactor
|
|
207
|
+
input :items
|
|
208
|
+
|
|
209
|
+
map :process_items do
|
|
210
|
+
source input(:items)
|
|
211
|
+
argument :item, element(:process_items)
|
|
212
|
+
|
|
213
|
+
# Enable async execution with batching
|
|
214
|
+
async true, batch_size: 50
|
|
215
|
+
|
|
216
|
+
step :transform do
|
|
217
|
+
argument :item, input(:item)
|
|
218
|
+
run { |args| transform_item(args[:item]) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
returns :transform
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Input Validation
|
|
227
|
+
|
|
228
|
+
RubyReactor integrates with dry-validation for input validation:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class ValidatedUserReactor < RubyReactor::Reactor
|
|
232
|
+
input :name do
|
|
233
|
+
required(:name).filled(:string, min_size?: 2)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
input :email do
|
|
237
|
+
required(:email).filled(:string)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
input :age do
|
|
241
|
+
required(:age).filled(:integer, gteq?: 18)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Optional inputs
|
|
245
|
+
input :bio, optional: true do
|
|
246
|
+
optional(:bio).maybe(:string, max_size?: 100)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
step :create_profile do
|
|
250
|
+
argument :name, input(:name)
|
|
251
|
+
argument :email, input(:email)
|
|
252
|
+
argument :age, input(:age)
|
|
253
|
+
argument :bio, input(:bio)
|
|
254
|
+
|
|
255
|
+
run do |args, context|
|
|
256
|
+
profile = {
|
|
257
|
+
name: args[:name],
|
|
258
|
+
email: args[:email],
|
|
259
|
+
age: args[:age],
|
|
260
|
+
bio: args[:bio] || "No bio provided",
|
|
261
|
+
created_at: Time.now
|
|
262
|
+
}
|
|
263
|
+
Success(profile)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
returns :create_profile
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Valid input
|
|
271
|
+
result = ValidatedUserReactor.run(
|
|
272
|
+
name: "Alice Johnson",
|
|
273
|
+
email: "alice@example.com",
|
|
274
|
+
age: 25,
|
|
275
|
+
bio: "Software developer"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Invalid input - will return validation errors
|
|
279
|
+
result = ValidatedUserReactor.run(
|
|
280
|
+
name: "A", # Too short
|
|
281
|
+
email: "", # Empty
|
|
282
|
+
age: 15 # Too young
|
|
283
|
+
)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Complex Workflows with Dependencies
|
|
287
|
+
|
|
288
|
+
Steps can depend on results from multiple other steps:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
class OrderProcessingReactor < RubyReactor::Reactor
|
|
292
|
+
input :user_id
|
|
293
|
+
input :product_ids, validate: ->(ids) { ids.is_a?(Array) && ids.any? }
|
|
294
|
+
|
|
295
|
+
step :validate_user do
|
|
296
|
+
argument :user_id, input(:user_id)
|
|
297
|
+
|
|
298
|
+
run do |args, context|
|
|
299
|
+
# Check if user exists and has permission to purchase
|
|
300
|
+
user = find_user(args[:user_id])
|
|
301
|
+
user ? Success(user) : Failure("User not found")
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
step :validate_products do
|
|
306
|
+
argument :product_ids, input(:product_ids)
|
|
307
|
+
|
|
308
|
+
run do |args, context|
|
|
309
|
+
products = args[:product_ids].map { |id| find_product(id) }
|
|
310
|
+
if products.all?
|
|
311
|
+
Success(products)
|
|
312
|
+
else
|
|
313
|
+
Failure("Some products not found")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
step :calculate_total do
|
|
319
|
+
argument :products, result(:validate_products)
|
|
320
|
+
|
|
321
|
+
run do |args, context|
|
|
322
|
+
total = args[:products].sum { |p| p[:price] }
|
|
323
|
+
Success(total)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
step :check_inventory do
|
|
328
|
+
argument :products, result(:validate_products)
|
|
329
|
+
|
|
330
|
+
run do |args, context|
|
|
331
|
+
available = args[:products].all? { |p| p[:stock] > 0 }
|
|
332
|
+
available ? Success(true) : Failure("Out of stock")
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
step :process_payment do
|
|
337
|
+
argument :user, result(:validate_user)
|
|
338
|
+
argument :total, result(:calculate_total)
|
|
339
|
+
|
|
340
|
+
run do |args, context|
|
|
341
|
+
# Process payment logic here
|
|
342
|
+
payment_id = process_payment(args[:user][:id], args[:total])
|
|
343
|
+
Success(payment_id)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
undo do |error, args, context|
|
|
347
|
+
# Refund payment on failure
|
|
348
|
+
refund_payment(args[:payment_id])
|
|
349
|
+
Success()
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
step :create_order do
|
|
354
|
+
argument :user, result(:validate_user)
|
|
355
|
+
argument :products, result(:validate_products)
|
|
356
|
+
argument :payment_id, result(:process_payment)
|
|
357
|
+
|
|
358
|
+
run do |args, context|
|
|
359
|
+
order = create_order_record(args[:user], args[:products], args[:payment_id])
|
|
360
|
+
Success(order)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
undo do |error, args, context|
|
|
364
|
+
# Cancel order and update inventory
|
|
365
|
+
cancel_order(args[:order][:id])
|
|
366
|
+
Success()
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
step :update_inventory do
|
|
371
|
+
argument :products, result(:validate_products)
|
|
372
|
+
|
|
373
|
+
run do |args, context|
|
|
374
|
+
args[:products].each { |p| decrement_stock(p[:id]) }
|
|
375
|
+
Success(true)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
undo do |error, args, context|
|
|
379
|
+
# Restock products
|
|
380
|
+
args[:products].each { |p| increment_stock(p[:id]) }
|
|
381
|
+
Success()
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
step :send_confirmation do
|
|
386
|
+
argument :user, result(:validate_user)
|
|
387
|
+
argument :order, result(:create_order)
|
|
388
|
+
|
|
389
|
+
run do |args, context|
|
|
390
|
+
send_email(args[:user][:email], "Order confirmed", order_details(args[:order]))
|
|
391
|
+
Success(true)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
returns :send_confirmation
|
|
396
|
+
end
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Error Handling and Compensation
|
|
400
|
+
|
|
401
|
+
When a step fails, RubyReactor automatically undoes completed steps in reverse order, compensate only runs in the failing step and backwalks the executed steps undo blocks:
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
class TransactionReactor < RubyReactor::Reactor
|
|
405
|
+
input :from_account
|
|
406
|
+
input :to_account
|
|
407
|
+
input :amount
|
|
408
|
+
|
|
409
|
+
step :validate_accounts do
|
|
410
|
+
argument :from_account, input(:from_account)
|
|
411
|
+
argument :to_account, input(:to_account)
|
|
412
|
+
|
|
413
|
+
run do |args, context|
|
|
414
|
+
from = find_account(args[:from_account])
|
|
415
|
+
to = find_account(args[:to_account])
|
|
416
|
+
|
|
417
|
+
if from && to && from != to
|
|
418
|
+
Success({from: from, to: to})
|
|
419
|
+
else
|
|
420
|
+
Failure("Invalid accounts")
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
step :check_balance do
|
|
426
|
+
argument :accounts, result(:validate_accounts)
|
|
427
|
+
argument :amount, input(:amount)
|
|
428
|
+
|
|
429
|
+
run do |args, context|
|
|
430
|
+
if args[:accounts][:from][:balance] >= args[:amount]
|
|
431
|
+
Success(args[:accounts])
|
|
432
|
+
else
|
|
433
|
+
Failure("Insufficient funds")
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
step :debit_account do
|
|
439
|
+
argument :accounts, result(:check_balance)
|
|
440
|
+
argument :amount, input(:amount)
|
|
441
|
+
|
|
442
|
+
run do |args, context|
|
|
443
|
+
debit(args[:accounts][:from][:id], args[:amount])
|
|
444
|
+
Success(args[:accounts])
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
undo do |error, args, context|
|
|
448
|
+
# Credit the amount back
|
|
449
|
+
credit(args[:accounts][:from][:id], args[:amount])
|
|
450
|
+
Success()
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
step :credit_account do
|
|
455
|
+
argument :accounts, result(:debit_account)
|
|
456
|
+
argument :amount, input(:amount)
|
|
457
|
+
|
|
458
|
+
run do |args, context|
|
|
459
|
+
credit(args[:accounts][:to][:id], args[:amount])
|
|
460
|
+
Success({transaction_id: generate_transaction_id()})
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
undo do |error, args, context|
|
|
464
|
+
# Debit the amount back from recipient
|
|
465
|
+
debit(args[:accounts][:to][:id], args[:amount])
|
|
466
|
+
Success()
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
step :notify do
|
|
471
|
+
argument :accounts, result(:validate_accounts)
|
|
472
|
+
wait_for :credit_account, :debit_account
|
|
473
|
+
|
|
474
|
+
run do |args, context|
|
|
475
|
+
Notify.to(args[:accounts][:from])
|
|
476
|
+
Notify.to(args[:accounts][:to])
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
returns :credit_account
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# If credit_account fails, RubyReactor will:
|
|
485
|
+
# 1. Compensate credit_account (debit the recipient)
|
|
486
|
+
# 2. Undo debit_account (credit the sender)
|
|
487
|
+
# Result: Complete rollback of the transaction
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Using Pre-defined Schemas
|
|
491
|
+
|
|
492
|
+
You can use existing dry-validation schemas:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
require 'dry/schema'
|
|
496
|
+
|
|
497
|
+
user_schema = Dry::Schema.Params do
|
|
498
|
+
required(:user).hash do
|
|
499
|
+
required(:name).filled(:string, min_size?: 2)
|
|
500
|
+
required(:email).filled(:string)
|
|
501
|
+
optional(:phone).maybe(:string)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
class SchemaValidatedReactor < RubyReactor::Reactor
|
|
506
|
+
input :user, validate: user_schema
|
|
507
|
+
|
|
508
|
+
step :process_user do
|
|
509
|
+
argument :user, input(:user)
|
|
510
|
+
|
|
511
|
+
run do |args, context|
|
|
512
|
+
Success(args[:user])
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
returns :process_user
|
|
517
|
+
end
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
## Documentation
|
|
522
|
+
|
|
523
|
+
For detailed documentation, see the following guides:
|
|
524
|
+
|
|
525
|
+
### [Core Concepts](documentation/core_concepts.md)
|
|
526
|
+
Learn about the fundamental building blocks of RubyReactor: Reactors, Steps, Context, and Results. Understand how steps are defined, how data flows between them, and how the context maintains state throughout execution.
|
|
527
|
+
|
|
528
|
+
### [DAG (Directed Acyclic Graph)](documentation/DAG.md)
|
|
529
|
+
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.
|
|
530
|
+
|
|
531
|
+
### [Async Reactors](documentation/async_reactors.md)
|
|
532
|
+
Explore the two asynchronous execution models: Full Reactor Async and Step-Level Async. Learn how RubyReactor leverages Sidekiq for background processing, non-blocking execution, and scalable worker management.
|
|
533
|
+
|
|
534
|
+
### [Composition](documentation/composition.md)
|
|
535
|
+
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.
|
|
536
|
+
|
|
537
|
+
### [Data Pipelines](documentation/data_pipelines.md)
|
|
538
|
+
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.
|
|
539
|
+
|
|
540
|
+
### [Retry Configuration](documentation/retry_configuration.md)
|
|
541
|
+
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.
|
|
542
|
+
|
|
543
|
+
### Examples
|
|
544
|
+
- [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
|
|
545
|
+
- [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
|
|
546
|
+
- [Inventory Management](documentation/examples/inventory_management.md) - Inventory management system example
|
|
547
|
+
|
|
548
|
+
## Future improvements
|
|
549
|
+
|
|
550
|
+
- [X] Global id to serialize ActiveRecord classes
|
|
551
|
+
- [ ] Middlewares
|
|
552
|
+
- [ ] Descriptive errors
|
|
553
|
+
- [X] `map` step to iterate over arrays in parallel
|
|
554
|
+
- [X] `compose` special step to execute reactors as step
|
|
555
|
+
- [ ] Async ruby to parallelize same level steps
|
|
556
|
+
- [ ] Dedicated interface to inspect reactor results and errors
|
|
557
|
+
|
|
558
|
+
## Development
|
|
559
|
+
|
|
560
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
561
|
+
|
|
562
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
563
|
+
|
|
564
|
+
## Contributing
|
|
565
|
+
|
|
566
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/arturictus/ruby_reactor. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/arturictus/ruby_reactor/blob/main/CODE_OF_CONDUCT.md).
|
|
567
|
+
|
|
568
|
+
## Code of Conduct
|
|
569
|
+
|
|
570
|
+
Everyone interacting in the RubyReactor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/arturictus/ruby_reactor/blob/main/CODE_OF_CONDUCT.md).
|