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
@@ -0,0 +1,749 @@
1
+ # Inventory Management Reactor Example
2
+
3
+ This example demonstrates inventory management with reservations, stock updates, and supplier integration.
4
+
5
+ ## Overview
6
+
7
+ The InventoryManagementReactor handles complex inventory operations:
8
+
9
+ 1. **Check Availability**: Verify stock levels
10
+ 2. **Reserve Stock**: Temporarily hold inventory
11
+ 3. **Update Quantities**: Modify stock levels
12
+ 4. **Log Transactions**: Record inventory changes
13
+ 5. **Trigger Replenishment**: Auto-order low stock items
14
+ 6. **Send Notifications**: Alert relevant parties
15
+
16
+ ### Inventory Management Workflow
17
+
18
+ ```mermaid
19
+ graph TD
20
+ A[Inventory Request] --> B[validate_request]
21
+ B --> C{Valid<br/>Request?}
22
+ C -->|No| D[Fail: Validation Error]
23
+ C -->|Yes| E[check_availability]
24
+ E --> F{Stock<br/>Available?}
25
+ F -->|No| G[Fail: Insufficient Stock]
26
+ F -->|Yes| H[acquire_lock]
27
+ H --> I{Lock<br/>Acquired?}
28
+ I -->|No| J[Fail: Lock Contention]
29
+ I -->|Yes| K[perform_operation]
30
+ K --> L{Operation<br/>Success?}
31
+ L -->|No| M[Compensate: Release Lock]
32
+ L -->|Yes| N[release_lock]
33
+ N --> O[log_transaction]
34
+ O --> P[check_replenishment]
35
+ P --> Q{Replenishment<br/>Needed?}
36
+ Q -->|Yes| R[Trigger Replenishment Job]
37
+ Q -->|No| S[send_notifications]
38
+ S --> T[Success: Operation Complete]
39
+
40
+ R --> S
41
+ ```
42
+
43
+ ## Implementation
44
+
45
+ ```ruby
46
+ class InventoryManagementReactor < RubyReactor::Reactor
47
+ async true
48
+
49
+ retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.second
50
+
51
+ step :validate_request do
52
+ validate_args do
53
+ required(:product_id).filled(:string)
54
+ required(:quantity).filled(:integer, gt?: 0)
55
+ required(:operation).filled(:string, included_in?: ['reserve', 'release', 'consume'])
56
+ end
57
+
58
+ run do |product_id:, quantity:, operation:, **|
59
+ product = Product.find_by(id: product_id)
60
+ raise "Product not found" unless product
61
+
62
+ Success({ product: product, operation: operation })
63
+ end
64
+ end
65
+
66
+ step :check_availability do
67
+ argument :request_data, result(:validate_request)
68
+
69
+ run do |args, _context|
70
+ product = args[:request_data][:product]
71
+ quantity = args[:request_data][:quantity]
72
+ operation = args[:request_data][:operation]
73
+
74
+ case operation
75
+ when 'consume'
76
+ raise "Insufficient stock" if product.inventory_count < quantity
77
+ when 'reserve'
78
+ available = product.inventory_count - product.reserved_count
79
+ raise "Insufficient available stock" if available < quantity
80
+ end
81
+
82
+ Success({ availability_checked: true })
83
+ end
84
+ end
85
+
86
+ step :acquire_lock do
87
+ argument :request_data, result(:validate_request)
88
+ argument :availability_data, result(:check_availability)
89
+
90
+ run do |args, _context|
91
+ product = args[:request_data][:product]
92
+
93
+ # Acquire distributed lock to prevent race conditions
94
+ lock_key = "inventory_lock:#{product.id}"
95
+
96
+ acquired = RedisLock.acquire(lock_key, ttl: 30.seconds)
97
+ raise "Failed to acquire inventory lock" unless acquired
98
+
99
+ Success({ lock_key: lock_key })
100
+ end
101
+
102
+ compensate do |args, _context|
103
+ lock_key = args[:availability_data][:lock_key] || args[:lock_key]
104
+ # Release lock on failure
105
+ RedisLock.release(lock_key) if lock_key
106
+ end
107
+ end
108
+
109
+ step :perform_operation do
110
+ argument :request_data, result(:validate_request)
111
+ argument :lock_data, result(:acquire_lock)
112
+
113
+ run do |args, _context|
114
+ product = args[:request_data][:product]
115
+ quantity = args[:request_data][:quantity]
116
+ operation = args[:request_data][:operation]
117
+
118
+ case operation
119
+ when 'reserve'
120
+ product.increment!(:reserved_count, quantity)
121
+ Success({ operation_completed: true, new_reserved: product.reserved_count })
122
+ when 'release'
123
+ product.decrement!(:reserved_count, quantity)
124
+ Success({ operation_completed: true, new_reserved: product.reserved_count })
125
+ when 'consume'
126
+ product.decrement!(:inventory_count, quantity)
127
+ Success({ operation_completed: true, new_inventory: product.inventory_count })
128
+ end
129
+ end
130
+
131
+ compensate do |args, _context|
132
+ product = args[:request_data][:product]
133
+ quantity = args[:request_data][:quantity]
134
+ operation = args[:request_data][:operation]
135
+
136
+ # Reverse the operation
137
+ case operation
138
+ when 'reserve'
139
+ product.decrement!(:reserved_count, quantity)
140
+ when 'release'
141
+ product.increment!(:reserved_count, quantity)
142
+ when 'consume'
143
+ product.increment!(:inventory_count, quantity)
144
+ end
145
+ end
146
+ end
147
+
148
+ step :release_lock do
149
+ argument :lock_data, result(:acquire_lock)
150
+ argument :operation_data, result(:perform_operation)
151
+
152
+ run do |args, _context|
153
+ lock_key = args[:lock_data][:lock_key]
154
+
155
+ RedisLock.release(lock_key)
156
+ Success({ lock_released: true })
157
+ end
158
+ end
159
+
160
+ step :log_transaction do
161
+ argument :request_data, result(:validate_request)
162
+ argument :release_data, result(:release_lock)
163
+
164
+ run do |args, _context|
165
+ product = args[:request_data][:product]
166
+ quantity = args[:request_data][:quantity]
167
+ operation = args[:request_data][:operation]
168
+
169
+ InventoryTransaction.create!(
170
+ product: product,
171
+ operation: operation,
172
+ quantity: quantity,
173
+ performed_at: Time.current
174
+ )
175
+
176
+ Success({ transaction_logged: true })
177
+ end
178
+ end
179
+
180
+ step :check_replenishment do
181
+ argument :request_data, result(:validate_request)
182
+ argument :log_data, result(:log_transaction)
183
+
184
+ run do |args, _context|
185
+ product = args[:request_data][:product]
186
+
187
+ if product.inventory_count <= product.reorder_point
188
+ # Trigger replenishment process
189
+ ReplenishmentJob.perform_later(product.id)
190
+ Success({ replenishment_triggered: true })
191
+ else
192
+ Success({ replenishment_checked: true })
193
+ end
194
+ end
195
+ end
196
+
197
+ step :send_notifications do
198
+ argument :request_data, result(:validate_request)
199
+ argument :replenishment_data, result(:check_replenishment)
200
+
201
+ idempotent true
202
+ retries max_attempts: 3, backoff: :linear, base_delay: 5.seconds
203
+
204
+ run do |args, _context|
205
+ product = args[:request_data][:product]
206
+ operation = args[:request_data][:operation]
207
+ quantity = args[:request_data][:quantity]
208
+ replenishment_triggered = args[:replenishment_data][:replenishment_triggered]
209
+
210
+ notifications = []
211
+
212
+ # Notify warehouse staff for significant changes
213
+ if quantity > 10
214
+ notifications << NotificationService.notify_warehouse(
215
+ quantity: quantity
216
+ )
217
+ end
218
+
219
+ # Notify managers for low stock
220
+ if replenishment_triggered
221
+ notifications << NotificationService.notify_managers(
222
+ product: product,
223
+ message: "Low stock alert: #{product.inventory_count} remaining"
224
+ )
225
+ end
226
+
227
+ Success({ notifications_sent: notifications.all?(&:success?) })
228
+ end
229
+ end
230
+ end
231
+ ```
232
+
233
+ ## Advanced Inventory Scenarios
234
+
235
+ ### Bulk Inventory Operations
236
+
237
+ ```ruby
238
+ class BulkInventoryReactor < RubyReactor::Reactor
239
+ async true
240
+
241
+ step :validate_bulk_request do
242
+ validate_args do
243
+ required(:operations).filled(:array, max_size?: 100) do
244
+ each do
245
+ hash do
246
+ required(:product_id).filled(:string)
247
+ required(:quantity).filled(:integer, gt?: 0)
248
+ required(:operation).filled(:string, included_in?: ['reserve', 'release', 'consume'])
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ run do |operations:, **|
255
+ validated_ops = operations.map do |op|
256
+ product = Product.find(op[:product_id])
257
+ raise "Product #{op[:product_id]} not found" unless product
258
+
259
+ op.merge(product: product)
260
+ end
261
+
262
+ Success({ validated_operations: validated_ops })
263
+ end
264
+ end
265
+
266
+ step :execute_operations do
267
+ argument :bulk_data, result(:validate_bulk_request)
268
+
269
+ run do |args, _context|
270
+ validated_operations = args[:bulk_data][:validated_operations]
271
+
272
+ results = []
273
+
274
+ validated_operations.each do |op|
275
+ begin
276
+ # Execute each operation in sequence to maintain consistency
277
+ result = InventoryManagementReactor.run(
278
+ product_id: op[:product_id],
279
+ quantity: op[:quantity],
280
+ operation: op[:operation]
281
+ )
282
+
283
+ if result.success?
284
+ results << { success: true, operation: op }
285
+ else
286
+ results << { success: false, operation: op, error: result.error.message }
287
+ end
288
+ rescue => e
289
+ results << { success: false, operation: op, error: e.message }
290
+ end
291
+ end
292
+
293
+ successful_count = results.count { |r| r[:success] }
294
+
295
+ if successful_count == 0
296
+ raise "All operations failed"
297
+ elsif successful_count < results.size
298
+ # Partial success - log warnings but don't fail
299
+ Rails.logger.warn("Bulk inventory: #{successful_count}/#{results.size} operations succeeded")
300
+ end
301
+
302
+ Success({ bulk_results: results, partial_success: successful_count < results.size })
303
+ end
304
+ end
305
+ end
306
+ ```
307
+
308
+ ### Inventory Transfer Between Locations
309
+
310
+ ```ruby
311
+ class InventoryTransferReactor < RubyReactor::Reactor
312
+ async true
313
+
314
+ step :validate_transfer do
315
+ validate_args do
316
+ required(:from_location_id).filled(:string)
317
+ required(:to_location_id).filled(:string)
318
+ required(:product_id).filled(:string)
319
+ required(:quantity).filled(:integer, gt?: 0)
320
+ end
321
+
322
+ run do |from_location_id:, to_location_id:, product_id:, quantity:, **|
323
+ from_location = Location.find(from_location_id)
324
+ to_location = Location.find(to_location_id)
325
+ product = Product.find(product_id)
326
+
327
+ raise "Invalid locations" unless from_location && to_location
328
+ raise "Same location" if from_location_id == to_location_id
329
+ raise "Product not found" unless product
330
+
331
+ # Check source location has enough stock
332
+ source_inventory = LocationInventory.find_by(
333
+ location: from_location,
334
+ product: product
335
+ )
336
+ raise "Insufficient stock at source" unless source_inventory&.quantity&.>= quantity
337
+
338
+ Success({
339
+ from_location: from_location,
340
+ to_location: to_location,
341
+ product: product,
342
+ quantity: quantity
343
+ })
344
+ end
345
+ end
346
+
347
+ step :reserve_source do
348
+ argument :transfer_data, result(:validate_transfer)
349
+
350
+ run do |args, _context|
351
+ from_location = args[:transfer_data][:from_location]
352
+ product = args[:transfer_data][:product]
353
+ quantity = args[:transfer_data][:quantity]
354
+
355
+ # Reserve inventory at source location
356
+ reservation = InventoryService.reserve_at_location(
357
+ location: from_location,
358
+ product: product,
359
+ quantity: quantity
360
+ )
361
+
362
+ raise "Source reservation failed" unless reservation
363
+
364
+ Success({ source_reservation_id: reservation.id })
365
+ end
366
+
367
+ compensate do |args, _context|
368
+ source_reservation_id = args[:transfer_data][:source_reservation_id] || args[:source_reservation_id]
369
+ InventoryService.release_reservation(source_reservation_id)
370
+ end
371
+ end
372
+
373
+ step :prepare_destination do
374
+ argument :transfer_data, result(:validate_transfer)
375
+ argument :reservation_data, result(:reserve_source)
376
+
377
+ run do |args, _context|
378
+ to_location = args[:transfer_data][:to_location]
379
+ product = args[:transfer_data][:product]
380
+ quantity = args[:transfer_data][:quantity]
381
+
382
+ # Ensure destination location can accept the inventory
383
+ destination_inventory = LocationInventory.find_or_create_by(
384
+ location: to_location,
385
+ product: product
386
+ )
387
+
388
+ Success({ destination_prepared: true })
389
+ end
390
+ end
391
+
392
+ step :execute_transfer do
393
+ argument :transfer_data, result(:validate_transfer)
394
+ argument :reservation_data, result(:reserve_source)
395
+ argument :destination_data, result(:prepare_destination)
396
+
397
+ run do |args, _context|
398
+ from_location = args[:transfer_data][:from_location]
399
+ to_location = args[:transfer_data][:to_location]
400
+ product = args[:transfer_data][:product]
401
+ quantity = args[:transfer_data][:quantity]
402
+ source_reservation_id = args[:reservation_data][:source_reservation_id]
403
+
404
+ # Atomically transfer inventory
405
+ InventoryService.transfer_inventory(
406
+ from_location: from_location,
407
+ to_location: to_location,
408
+ product: product,
409
+ quantity: quantity,
410
+ reservation_id: source_reservation_id
411
+ )
412
+
413
+ Success({ transfer_completed: true })
414
+ end
415
+
416
+ compensate do |args, _context|
417
+ from_location = args[:transfer_data][:from_location]
418
+ to_location = args[:transfer_data][:to_location]
419
+ product = args[:transfer_data][:product]
420
+ quantity = args[:transfer_data][:quantity]
421
+
422
+ # Reverse the transfer - this is complex and might require manual intervention
423
+ Rails.logger.error("Transfer compensation needed for #{product.id} x #{quantity}")
424
+ # Trigger manual review process
425
+ end
426
+ end
427
+
428
+ step :log_transfer do
429
+ argument :transfer_data, result(:validate_transfer)
430
+ argument :transfer_result, result(:execute_transfer)
431
+
432
+ run do |args, _context|
433
+ from_location = args[:transfer_data][:from_location]
434
+ to_location = args[:transfer_data][:to_location]
435
+ product = args[:transfer_data][:product]
436
+ quantity = args[:transfer_data][:quantity]
437
+
438
+ InventoryTransfer.create!(
439
+ from_location: from_location,
440
+ to_location: to_location,
441
+ product: product,
442
+ quantity: quantity,
443
+ transferred_at: Time.current
444
+ )
445
+
446
+ Success({ transfer_logged: true })
447
+ end
448
+ end
449
+ end
450
+ ```
451
+
452
+ ## Supplier Integration
453
+
454
+ ### Automatic Replenishment
455
+
456
+ ```ruby
457
+ class ReplenishmentReactor < RubyReactor::Reactor
458
+ async true
459
+
460
+ retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 5.minutes
461
+
462
+ step :check_supplier_availability do
463
+ validate_args do
464
+ required(:product_id).filled(:string)
465
+ end
466
+
467
+ run do |product_id:, **|
468
+ product = Product.find(product_id)
469
+ supplier = product.supplier
470
+
471
+ raise "No supplier configured" unless supplier
472
+ raise "Supplier inactive" unless supplier.active?
473
+
474
+ Success({ product: product, supplier: supplier })
475
+ end
476
+ end
477
+
478
+ step :calculate_order_quantity do
479
+ argument :supplier_data, result(:check_supplier_availability)
480
+
481
+ run do |args, _context|
482
+ product = args[:supplier_data][:product]
483
+ supplier = args[:supplier_data][:supplier]
484
+
485
+ # Calculate optimal order quantity
486
+ current_stock = product.inventory_count
487
+ reorder_point = product.reorder_point
488
+ max_stock = product.max_stock_level
489
+
490
+ # Order enough to reach max stock
491
+ order_quantity = [max_stock - current_stock, supplier.min_order_quantity].max
492
+
493
+ # Check supplier constraints
494
+ order_quantity = [order_quantity, supplier.max_order_quantity].min
495
+
496
+ raise "Invalid order quantity" if order_quantity <= 0
497
+
498
+ Success({ order_quantity: order_quantity })
499
+ end
500
+ end
501
+
502
+ step :check_supplier_inventory do
503
+ argument :supplier_data, result(:check_supplier_availability)
504
+ argument :quantity_data, result(:calculate_order_quantity)
505
+
506
+ run do |args, _context|
507
+ supplier = args[:supplier_data][:supplier]
508
+ product = args[:supplier_data][:product]
509
+ order_quantity = args[:quantity_data][:order_quantity]
510
+
511
+ # Query supplier API for availability
512
+ supplier_response = SupplierAPI.check_availability(
513
+ supplier_id: supplier.id,
514
+ product_sku: product.sku,
515
+ quantity: order_quantity
516
+ )
517
+
518
+ raise "Product not available from supplier" unless supplier_response.available?
519
+
520
+ actual_available = supplier_response.available_quantity
521
+ if actual_available < order_quantity
522
+ # Adjust order quantity
523
+ order_quantity = actual_available
524
+ end
525
+
526
+ Success({ adjusted_quantity: order_quantity, supplier_available: true })
527
+ end
528
+ end
529
+
530
+ step :place_supplier_order do
531
+ argument :supplier_data, result(:check_supplier_availability)
532
+ argument :inventory_data, result(:check_supplier_inventory)
533
+
534
+ run do |args, _context|
535
+ supplier = args[:supplier_data][:supplier]
536
+ product = args[:supplier_data][:product]
537
+ adjusted_quantity = args[:inventory_data][:adjusted_quantity]
538
+
539
+ order_response = SupplierAPI.place_order(
540
+ supplier_id: supplier.id,
541
+ items: [{
542
+ sku: product.sku,
543
+ quantity: adjusted_quantity,
544
+ unit_price: product.supplier_price
545
+ }]
546
+ )
547
+
548
+ raise "Supplier order failed" unless order_response.success?
549
+
550
+ Success({ supplier_order_id: order_response.order_id, order_placed: true })
551
+ end
552
+
553
+ compensate do |args, _context|
554
+ supplier_order_id = args[:inventory_data][:supplier_order_id] || args[:supplier_order_id]
555
+ # Cancel the supplier order
556
+ SupplierAPI.cancel_order(supplier_order_id) if supplier_order_id
557
+ end
558
+ end
559
+
560
+ step :record_purchase_order do
561
+ argument :supplier_data, result(:check_supplier_availability)
562
+ argument :inventory_data, result(:check_supplier_inventory)
563
+ argument :order_data, result(:place_supplier_order)
564
+
565
+ run do |args, _context|
566
+ product = args[:supplier_data][:product]
567
+ supplier = args[:supplier_data][:supplier]
568
+ adjusted_quantity = args[:inventory_data][:adjusted_quantity]
569
+ supplier_order_id = args[:order_data][:supplier_order_id]
570
+
571
+ purchase_order = PurchaseOrder.create!(
572
+ supplier: supplier,
573
+ supplier_order_id: supplier_order_id,
574
+ status: :ordered,
575
+ items_attributes: [{
576
+ product: product,
577
+ quantity: adjusted_quantity,
578
+ unit_price: product.supplier_price,
579
+ total_price: adjusted_quantity * product.supplier_price
580
+ }]
581
+ )
582
+
583
+ Success({ purchase_order_id: purchase_order.id })
584
+ end
585
+ end
586
+
587
+ step :schedule_delivery_tracking do
588
+ argument :purchase_data, result(:record_purchase_order)
589
+
590
+ run do |args, _context|
591
+ supplier_order_id = args[:order_data][:supplier_order_id]
592
+
593
+ # Schedule job to track delivery status
594
+ DeliveryTrackingJob.set(wait: 1.hour).perform_later(supplier_order_id)
595
+
596
+ Success({ tracking_scheduled: true })
597
+ end
598
+ end
599
+ end
600
+ ```
601
+
602
+ ## Testing Inventory Operations
603
+
604
+ ```ruby
605
+ RSpec.describe InventoryManagementReactor do
606
+ let(:product) { create(:product, inventory_count: 100, reserved_count: 0) }
607
+
608
+ context "successful reservation" do
609
+ it "reserves inventory correctly" do
610
+ result = InventoryManagementReactor.run(
611
+ product_id: product.id,
612
+ quantity: 10,
613
+ operation: 'reserve'
614
+ )
615
+
616
+ expect(result).to be_success
617
+ product.reload
618
+ expect(product.reserved_count).to eq(10)
619
+ expect(product.available_count).to eq(90)
620
+ end
621
+ end
622
+
623
+ context "insufficient stock" do
624
+ it "fails when trying to consume more than available" do
625
+ result = InventoryManagementReactor.run(
626
+ product_id: product.id,
627
+ quantity: 150,
628
+ operation: 'consume'
629
+ )
630
+
631
+ expect(result).to be_failure
632
+ expect(result.error.message).to include("Insufficient stock")
633
+ end
634
+ end
635
+
636
+ context "concurrent operations" do
637
+ it "handles race conditions with locking" do
638
+ # Simulate concurrent operations
639
+ threads = 5.times.map do
640
+ Thread.new do
641
+ InventoryManagementReactor.run(
642
+ product_id: product.id,
643
+ quantity: 1,
644
+ operation: 'reserve'
645
+ )
646
+ end
647
+ end
648
+
649
+ threads.each(&:join)
650
+
651
+ product.reload
652
+ expect(product.reserved_count).to eq(5) # All operations succeeded
653
+ end
654
+ end
655
+
656
+ context "compensation" do
657
+ it "releases lock and reverses operation on failure" do
658
+ allow_any_instance_of(InventoryManagementReactor).to receive(:perform_operation)
659
+ .and_raise("Simulated failure")
660
+
661
+ initial_reserved = product.reserved_count
662
+
663
+ result = InventoryManagementReactor.run(
664
+ product_id: product.id,
665
+ quantity: 5,
666
+ operation: 'reserve'
667
+ )
668
+
669
+ expect(result).to be_failure
670
+ product.reload
671
+ expect(product.reserved_count).to eq(initial_reserved) # No change
672
+ end
673
+ end
674
+ end
675
+ ```
676
+
677
+ ## Performance Optimization
678
+
679
+ ### Database Optimization
680
+
681
+ ```ruby
682
+ # Use database constraints for inventory integrity
683
+ class Product < ApplicationRecord
684
+ # Ensure inventory never goes negative
685
+ validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }
686
+
687
+ # Use optimistic locking
688
+ # validates :lock_version
689
+ end
690
+ ```
691
+
692
+ ### Caching Strategy
693
+
694
+ ```ruby
695
+ class InventoryCache
696
+ def self.get_availability(product_id)
697
+ Rails.cache.fetch("inventory:#{product_id}", expires_in: 5.minutes) do
698
+ product = Product.find(product_id)
699
+ {
700
+ inventory_count: product.inventory_count,
701
+ reserved_count: product.reserved_count,
702
+ available: product.inventory_count - product.reserved_count
703
+ }
704
+ end
705
+ end
706
+
707
+ def self.invalidate(product_id)
708
+ Rails.cache.delete("inventory:#{product_id}")
709
+ end
710
+ end
711
+ ```
712
+
713
+ ### Batch Operations
714
+
715
+ ```ruby
716
+ class BatchInventoryUpdateReactor < RubyReactor::Reactor
717
+ step :batch_update do
718
+ run do |updates:, **|
719
+ # Use single transaction for batch updates
720
+ Product.transaction do
721
+ updates.each do |update|
722
+ product = Product.find(update[:product_id])
723
+ product.increment!(:inventory_count, update[:quantity])
724
+ end
725
+ end
726
+
727
+ Success({ batch_completed: true })
728
+ end
729
+ end
730
+ end
731
+ ```
732
+
733
+ ## Monitoring and Alerting
734
+
735
+ ```ruby
736
+ INVENTORY_METRICS = {
737
+ stockout_rate: "Percentage of products out of stock",
738
+ reservation_failure_rate: "Rate of failed reservations",
739
+ average_operation_time: "Average time for inventory operations",
740
+ lock_contention_rate: "Percentage of operations hitting lock contention",
741
+ replenishment_delay: "Average time to replenish low stock items"
742
+ }
743
+
744
+ INVENTORY_ALERTS = {
745
+ high_stockout_rate: "Stockout rate above 5%",
746
+ high_lock_contention: "Lock contention above 10%",
747
+ replenishment_failures: "Failed replenishment orders in last hour"
748
+ }
749
+ ```