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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +98 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/README.md +570 -0
  6. data/Rakefile +12 -0
  7. data/documentation/DAG.md +457 -0
  8. data/documentation/README.md +123 -0
  9. data/documentation/async_reactors.md +369 -0
  10. data/documentation/composition.md +199 -0
  11. data/documentation/core_concepts.md +662 -0
  12. data/documentation/data_pipelines.md +224 -0
  13. data/documentation/examples/inventory_management.md +749 -0
  14. data/documentation/examples/order_processing.md +365 -0
  15. data/documentation/examples/payment_processing.md +654 -0
  16. data/documentation/getting_started.md +224 -0
  17. data/documentation/retry_configuration.md +357 -0
  18. data/lib/ruby_reactor/async_router.rb +91 -0
  19. data/lib/ruby_reactor/configuration.rb +41 -0
  20. data/lib/ruby_reactor/context.rb +169 -0
  21. data/lib/ruby_reactor/context_serializer.rb +164 -0
  22. data/lib/ruby_reactor/dependency_graph.rb +126 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
  24. data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
  25. data/lib/ruby_reactor/dsl/reactor.rb +151 -0
  26. data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
  27. data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
  28. data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
  29. data/lib/ruby_reactor/error/base.rb +16 -0
  30. data/lib/ruby_reactor/error/compensation_error.rb +8 -0
  31. data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
  32. data/lib/ruby_reactor/error/dependency_error.rb +8 -0
  33. data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
  34. data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
  35. data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
  36. data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
  37. data/lib/ruby_reactor/error/undo_error.rb +8 -0
  38. data/lib/ruby_reactor/error/validation_error.rb +8 -0
  39. data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
  40. data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
  41. data/lib/ruby_reactor/executor/input_validator.rb +39 -0
  42. data/lib/ruby_reactor/executor/result_handler.rb +103 -0
  43. data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
  44. data/lib/ruby_reactor/executor/step_executor.rb +319 -0
  45. data/lib/ruby_reactor/executor.rb +123 -0
  46. data/lib/ruby_reactor/map/collector.rb +65 -0
  47. data/lib/ruby_reactor/map/element_executor.rb +154 -0
  48. data/lib/ruby_reactor/map/execution.rb +60 -0
  49. data/lib/ruby_reactor/map/helpers.rb +67 -0
  50. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
  51. data/lib/ruby_reactor/reactor.rb +75 -0
  52. data/lib/ruby_reactor/retry_context.rb +92 -0
  53. data/lib/ruby_reactor/retry_queued_result.rb +26 -0
  54. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
  55. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
  56. data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
  57. data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
  58. data/lib/ruby_reactor/step/compose_step.rb +107 -0
  59. data/lib/ruby_reactor/step/map_step.rb +234 -0
  60. data/lib/ruby_reactor/step.rb +33 -0
  61. data/lib/ruby_reactor/storage/adapter.rb +51 -0
  62. data/lib/ruby_reactor/storage/configuration.rb +15 -0
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
  64. data/lib/ruby_reactor/template/base.rb +15 -0
  65. data/lib/ruby_reactor/template/element.rb +25 -0
  66. data/lib/ruby_reactor/template/input.rb +48 -0
  67. data/lib/ruby_reactor/template/result.rb +48 -0
  68. data/lib/ruby_reactor/template/value.rb +22 -0
  69. data/lib/ruby_reactor/validation/base.rb +26 -0
  70. data/lib/ruby_reactor/validation/input_validator.rb +62 -0
  71. data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
  72. data/lib/ruby_reactor/version.rb +5 -0
  73. data/lib/ruby_reactor.rb +159 -0
  74. data/sig/ruby_reactor.rbs +4 -0
  75. 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).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]