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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "executor/input_validator"
4
+ require_relative "executor/graph_manager"
5
+ require_relative "executor/retry_manager"
6
+ require_relative "executor/compensation_manager"
7
+ require_relative "executor/result_handler"
8
+ require_relative "executor/step_executor"
9
+
10
+ module RubyReactor
11
+ class Executor
12
+ attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
13
+ :step_executor, :result
14
+
15
+ def initialize(reactor_class, inputs = {}, context = nil)
16
+ @reactor_class = reactor_class
17
+ @context = context || Context.new(inputs, reactor_class)
18
+ @dependency_graph = DependencyGraph.new
19
+ @compensation_manager = CompensationManager.new(@context)
20
+ @retry_manager = RetryManager.new(@context)
21
+ @result_handler = ResultHandler.new(
22
+ context: @context,
23
+ compensation_manager: @compensation_manager,
24
+ dependency_graph: @dependency_graph
25
+ )
26
+ @step_executor = StepExecutor.new(
27
+ context: @context,
28
+ dependency_graph: @dependency_graph,
29
+ reactor_class: @reactor_class,
30
+ managers: {
31
+ retry_manager: @retry_manager,
32
+ result_handler: @result_handler,
33
+ compensation_manager: @compensation_manager
34
+ }
35
+ )
36
+ @result = nil
37
+ end
38
+
39
+ def execute
40
+ input_validator = InputValidator.new(@reactor_class, @context)
41
+ input_validator.validate!
42
+
43
+ graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
44
+ graph_manager.build_and_validate!
45
+
46
+ @result = @step_executor.execute_all_steps
47
+ rescue StandardError => e
48
+ @result = @result_handler.handle_execution_error(e)
49
+ end
50
+
51
+ def resume_execution
52
+ prepare_for_resume
53
+
54
+ if @context.current_step
55
+ execute_current_step_and_continue
56
+ else
57
+ execute_remaining_steps
58
+ end
59
+ rescue StandardError => e
60
+ handle_resume_error(e)
61
+ end
62
+
63
+ def undo_stack
64
+ @compensation_manager.undo_stack
65
+ end
66
+
67
+ def undo_trace
68
+ @compensation_manager.undo_trace
69
+ end
70
+
71
+ def execution_trace
72
+ @context.execution_trace
73
+ end
74
+
75
+ private
76
+
77
+ def prepare_for_resume
78
+ # Build dependency graph and mark completed steps
79
+ graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
80
+ graph_manager.build_and_validate!
81
+ graph_manager.mark_completed_steps_from_context
82
+ end
83
+
84
+ def execute_current_step_and_continue
85
+ step_config = @reactor_class.steps[@context.current_step]
86
+ return RubyReactor::Failure("Step '#{@context.current_step}' not found in reactor") unless step_config
87
+
88
+ # Use execute_step (not execute_step_with_retry) so that async steps can be handled properly in inline mode
89
+ result = @step_executor.execute_step(step_config)
90
+
91
+ # execute_step returns nil for inline async, meaning continue execution
92
+ if result.nil?
93
+ @result = @step_executor.execute_all_steps
94
+ else
95
+ case result
96
+ when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult
97
+ # Step was requeued, failed, or handed off to async - return the result
98
+ @result = result
99
+ when RubyReactor::Success
100
+ # Step succeeded, continue with remaining steps
101
+ @result = @step_executor.execute_all_steps
102
+ end
103
+ end
104
+ @result
105
+ end
106
+
107
+ def execute_remaining_steps
108
+ @result = @step_executor.execute_all_steps
109
+ @result
110
+ end
111
+
112
+ def handle_resume_error(error)
113
+ # Only handle errors that haven't already triggered compensation
114
+ # StepFailureError means compensation already happened, just convert to Failure
115
+ @result = if error.is_a?(Error::StepFailureError)
116
+ RubyReactor.Failure(error.message)
117
+ else
118
+ @result_handler.handle_execution_error(error)
119
+ end
120
+ @result
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ class Collector
6
+ extend Helpers
7
+
8
+ # rubocop:disable Metrics/MethodLength
9
+ def self.perform(arguments)
10
+ arguments = arguments.transform_keys(&:to_sym)
11
+ parent_context_id = arguments[:parent_context_id]
12
+ map_id = arguments[:map_id]
13
+ parent_reactor_class_name = arguments[:parent_reactor_class_name]
14
+ step_name = arguments[:step_name]
15
+ strict_ordering = arguments[:strict_ordering]
16
+
17
+ storage = RubyReactor.configuration.storage_adapter
18
+
19
+ # Retrieve parent context
20
+ parent_context = load_parent_context_from_storage(
21
+ parent_context_id,
22
+ parent_reactor_class_name,
23
+ storage
24
+ )
25
+
26
+ # Retrieve results
27
+ serialized_results = storage.retrieve_map_results(map_id, parent_reactor_class_name,
28
+ strict_ordering: strict_ordering)
29
+
30
+ results = serialized_results.map do |r|
31
+ if r.is_a?(Hash) && r.key?("_error")
32
+ RubyReactor::Failure(r["_error"])
33
+ else
34
+ RubyReactor::Success(r)
35
+ end
36
+ end
37
+
38
+ # Get step config to check for collect block and other options
39
+ parent_class = Object.const_get(parent_reactor_class_name)
40
+ step_config = parent_class.steps[step_name.to_sym]
41
+
42
+ collect_block = step_config.arguments[:collect_block][:source].value
43
+ # TODO: Check allow_partial_failure option
44
+
45
+ final_result = if collect_block
46
+ begin
47
+ # Pass all results (Success and Failure) to collect block
48
+ collected = collect_block.call(results)
49
+ RubyReactor::Success(collected)
50
+ rescue StandardError => e
51
+ RubyReactor::Failure(e)
52
+ end
53
+ else
54
+ # Default behavior: fail if any failure
55
+ first_failure = results.find(&:failure?)
56
+ first_failure || RubyReactor::Success(results.map(&:value))
57
+ end
58
+
59
+ # Resume execution
60
+ resume_parent_execution(parent_context, step_name, final_result, storage)
61
+ end
62
+ # rubocop:enable Metrics/MethodLength
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ class ElementExecutor
6
+ extend Helpers
7
+
8
+ # rubocop:disable Metrics/MethodLength
9
+ def self.perform(arguments)
10
+ arguments = arguments.transform_keys(&:to_sym)
11
+ map_id = arguments[:map_id]
12
+ _element_id = arguments[:element_id]
13
+ index = arguments[:index]
14
+ serialized_inputs = arguments[:serialized_inputs]
15
+ reactor_class_info = arguments[:reactor_class_info]
16
+ strict_ordering = arguments[:strict_ordering]
17
+ parent_context_id = arguments[:parent_context_id]
18
+ parent_reactor_class_name = arguments[:parent_reactor_class_name]
19
+ step_name = arguments[:step_name]
20
+ batch_size = arguments[:batch_size]
21
+ # rubocop:enable Metrics/MethodLength
22
+ serialized_context = arguments[:serialized_context]
23
+
24
+ if serialized_context
25
+ context = ContextSerializer.deserialize(serialized_context)
26
+ context.map_metadata = arguments
27
+ reactor_class = context.reactor_class
28
+ else
29
+ # Deserialize inputs
30
+ inputs = ContextSerializer.deserialize_value(serialized_inputs)
31
+
32
+ # Resolve reactor class
33
+ reactor_class = resolve_reactor_class(reactor_class_info)
34
+
35
+ # Create context
36
+ context = Context.new(inputs, reactor_class)
37
+ context.map_metadata = arguments
38
+ end
39
+ storage = RubyReactor.configuration.storage_adapter
40
+
41
+ # Execute
42
+ executor = Executor.new(reactor_class, {}, context)
43
+
44
+ if serialized_context
45
+ executor.resume_execution
46
+ else
47
+ executor.execute
48
+ end
49
+
50
+ result = executor.result
51
+
52
+ if result.is_a?(RetryQueuedResult)
53
+ queue_next_batch(arguments) if batch_size
54
+ return
55
+ end
56
+
57
+ # Store result
58
+
59
+ # Store result
60
+
61
+ if result.success?
62
+ storage.store_map_result(map_id, index, result.value, parent_reactor_class_name,
63
+ strict_ordering: strict_ordering)
64
+ else
65
+ # Store error
66
+ storage.store_map_result(map_id, index, { _error: result.error }, parent_reactor_class_name,
67
+ strict_ordering: strict_ordering)
68
+ end
69
+
70
+ # Decrement counter
71
+ new_count = storage.decrement_map_counter(map_id, parent_reactor_class_name)
72
+
73
+ queue_next_batch(arguments) if batch_size
74
+
75
+ return unless new_count.zero?
76
+
77
+ # Trigger collection
78
+ RubyReactor.configuration.async_router.perform_map_collection_async(
79
+ parent_context_id: parent_context_id,
80
+ map_id: map_id,
81
+ parent_reactor_class_name: parent_reactor_class_name,
82
+ step_name: step_name,
83
+ strict_ordering: strict_ordering,
84
+ timeout: 3600
85
+ )
86
+ end
87
+
88
+ def self.queue_next_batch(arguments)
89
+ storage = RubyReactor.configuration.storage_adapter
90
+ map_id = arguments[:map_id]
91
+ reactor_class_name = arguments[:parent_reactor_class_name]
92
+
93
+ next_index = storage.increment_last_queued_index(map_id, reactor_class_name)
94
+ total_count = storage.retrieve_map_metadata(map_id, reactor_class_name)["count"]
95
+
96
+ return unless next_index < total_count
97
+
98
+ parent_context = load_parent_context(arguments, reactor_class_name, storage)
99
+ element = resolve_next_element(arguments, parent_context, next_index)
100
+ serialized_inputs = build_serialized_inputs(arguments, parent_context, element)
101
+
102
+ queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
103
+ end
104
+
105
+ def self.load_parent_context(arguments, reactor_class_name, storage)
106
+ parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
107
+ parent_reactor_class = Object.const_get(reactor_class_name)
108
+ parent_context = Context.new(
109
+ ContextSerializer.deserialize_value(parent_context_data["inputs"]),
110
+ parent_reactor_class
111
+ )
112
+ parent_context.context_id = arguments[:parent_context_id]
113
+ parent_context
114
+ end
115
+
116
+ def self.resolve_next_element(arguments, parent_context, next_index)
117
+ parent_reactor_class = parent_context.reactor_class
118
+ step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
119
+
120
+ source_template = step_config.arguments[:source][:source]
121
+ source = source_template.resolve(parent_context)
122
+ source[next_index]
123
+ end
124
+
125
+ def self.build_serialized_inputs(arguments, parent_context, element)
126
+ parent_reactor_class = parent_context.reactor_class
127
+ step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
128
+
129
+ mappings_template = step_config.arguments[:argument_mappings][:source]
130
+ mappings = mappings_template.resolve(parent_context) || {}
131
+
132
+ mapped_inputs = build_element_inputs(mappings, parent_context, element)
133
+ ContextSerializer.serialize_value(mapped_inputs)
134
+ end
135
+
136
+ def self.queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
137
+ RubyReactor.configuration.async_router.perform_map_element_async(
138
+ map_id: map_id,
139
+ element_id: "#{map_id}:#{next_index}",
140
+ index: next_index,
141
+ serialized_inputs: serialized_inputs,
142
+ reactor_class_info: arguments[:reactor_class_info],
143
+ strict_ordering: arguments[:strict_ordering],
144
+ parent_context_id: arguments[:parent_context_id],
145
+ parent_reactor_class_name: reactor_class_name,
146
+ step_name: arguments[:step_name],
147
+ batch_size: arguments[:batch_size]
148
+ )
149
+ end
150
+ private_class_method :queue_next_batch, :load_parent_context,
151
+ :resolve_next_element, :build_serialized_inputs, :queue_element_job
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ class Execution
6
+ extend Helpers
7
+
8
+ def self.perform(arguments)
9
+ arguments = arguments.transform_keys(&:to_sym)
10
+ storage = RubyReactor.configuration.storage_adapter
11
+
12
+ parent_context = load_parent_context_from_storage(
13
+ arguments[:parent_context_id], arguments[:parent_reactor_class_name], storage
14
+ )
15
+ reactor_class = resolve_reactor_class(arguments[:reactor_class_info])
16
+ inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
17
+
18
+ results = execute_all_elements(
19
+ source: inputs[:source], mappings: inputs[:mappings],
20
+ reactor_class: reactor_class, parent_context: parent_context,
21
+ storage_options: {
22
+ map_id: arguments[:map_id], storage: storage,
23
+ parent_reactor_class_name: arguments[:parent_reactor_class_name],
24
+ strict_ordering: arguments[:strict_ordering]
25
+ }
26
+ )
27
+
28
+ finalize_execution(results, parent_context, arguments[:step_name], arguments[:parent_reactor_class_name],
29
+ storage)
30
+ end
31
+
32
+ def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
33
+ source.map.with_index do |element, index|
34
+ element_inputs = build_element_inputs(mappings, parent_context, element)
35
+ result = reactor_class.run(element_inputs)
36
+
37
+ store_result(result, index, storage_options)
38
+
39
+ result
40
+ end
41
+ end
42
+
43
+ def self.store_result(result, index, options)
44
+ value = result.success? ? result.value : { _error: result.error }
45
+ options[:storage].store_map_result(
46
+ options[:map_id], index, value, options[:parent_reactor_class_name],
47
+ strict_ordering: options[:strict_ordering]
48
+ )
49
+ end
50
+
51
+ def self.finalize_execution(results, parent_context, step_name, parent_reactor_class_name, storage)
52
+ step_config = Object.const_get(parent_reactor_class_name).steps[step_name.to_sym]
53
+ final_result = apply_collect_block(results, step_config)
54
+ resume_parent_execution(parent_context, step_name, final_result, storage)
55
+ end
56
+
57
+ private_class_method :execute_all_elements, :store_result, :finalize_execution
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # Shared helper methods for Map executors
6
+ module Helpers
7
+ # Resolves the reactor class from reactor_class_info
8
+ def resolve_reactor_class(info)
9
+ if info["type"] == "class"
10
+ Object.const_get(info["name"])
11
+ elsif info["type"] == "inline"
12
+ parent_class = Object.const_get(info["parent"])
13
+ step_config = parent_class.steps[info["step"].to_sym]
14
+ step_config.arguments[:mapped_reactor_class][:source].value
15
+ else
16
+ raise "Unknown reactor class info: #{info}"
17
+ end
18
+ end
19
+
20
+ # Loads parent context from storage
21
+ def load_parent_context_from_storage(parent_context_id, reactor_class_name, storage)
22
+ parent_context_data = storage.retrieve_context(parent_context_id, reactor_class_name)
23
+ RubyReactor::Context.deserialize_from_retry(parent_context_data)
24
+ end
25
+
26
+ # Builds mapped inputs for a single element
27
+ def build_element_inputs(mappings, parent_context, element)
28
+ RubyReactor::Step::MapStep.build_mapped_inputs(mappings, parent_context, element)
29
+ end
30
+
31
+ # Applies collect block to results
32
+ def apply_collect_block(results, step_config)
33
+ collect_block = step_config.arguments[:collect_block][:source].value
34
+
35
+ if collect_block
36
+ # Pass all results (Success and Failure) to collect block
37
+ begin
38
+ collected = collect_block.call(results)
39
+ RubyReactor::Success(collected)
40
+ rescue StandardError => e
41
+ RubyReactor::Failure(e)
42
+ end
43
+ else
44
+ # Default behavior: fail if any failure
45
+ first_failure = results.find(&:failure?)
46
+ first_failure || RubyReactor::Success(results.map(&:value))
47
+ end
48
+ end
49
+
50
+ # Resumes parent reactor execution after map completion
51
+ def resume_parent_execution(parent_context, step_name, final_result, storage)
52
+ value = final_result.success? ? final_result.value : final_result
53
+ parent_context.set_result(step_name.to_sym, value)
54
+ parent_context.current_step = nil
55
+
56
+ executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
+ executor.resume_execution
58
+
59
+ storage.store_context(
60
+ parent_context.context_id,
61
+ ContextSerializer.serialize(parent_context),
62
+ parent_context.reactor_class.name
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class MaxRetriesExhaustedFailure < Failure
5
+ attr_reader :attempts, :original_error
6
+
7
+ # rubocop:disable Metrics/ParameterLists
8
+ def initialize(message, step:, attempts:, original_error: nil,
9
+ inputs: {}, backtrace: nil, redact_inputs: [],
10
+ reactor_name: nil, step_arguments: {})
11
+ # rubocop:enable Metrics/ParameterLists
12
+ super(message,
13
+ step_name: step, inputs: inputs, backtrace: backtrace,
14
+ redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments)
15
+ @attempts = attempts
16
+ @original_error = original_error
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Reactor
5
+ include RubyReactor::Dsl::Reactor
6
+
7
+ attr_reader :context, :result, :undo_trace, :execution_trace
8
+
9
+ def initialize(context = {})
10
+ @context = context
11
+ @result = :unexecuted
12
+ @undo_trace = []
13
+ @execution_trace = []
14
+ end
15
+
16
+ def run(inputs = {})
17
+ if self.class.async?
18
+ # For async reactors, enqueue the job and return immediately
19
+ context = Context.new(inputs, self.class)
20
+ serialized_context = ContextSerializer.serialize(context)
21
+ configuration.async_router.perform_async(serialized_context)
22
+ else
23
+ # For sync reactors (potentially with async steps), execute normally
24
+ executor = Executor.new(self.class, inputs)
25
+ @result = executor.execute
26
+
27
+ @context = executor.context
28
+
29
+ @undo_trace = executor.undo_trace
30
+ @execution_trace = executor.execution_trace
31
+
32
+ # If execution returned an AsyncResult (from step-level async), return it
33
+ return @result if @result.is_a?(RubyReactor::AsyncResult)
34
+
35
+ @result
36
+ end
37
+ end
38
+
39
+ def validate!
40
+ # Validate reactor configuration
41
+ validate_steps!
42
+ validate_return_step!
43
+ validate_dependencies!
44
+ end
45
+
46
+ private
47
+
48
+ def configuration
49
+ RubyReactor::Configuration.instance
50
+ end
51
+
52
+ def validate_steps!
53
+ return unless self.class.steps.empty?
54
+
55
+ raise Error::ValidationError, "Reactor must have at least one step"
56
+ end
57
+
58
+ def validate_return_step!
59
+ return unless self.class.return_step
60
+
61
+ return if self.class.steps.key?(self.class.return_step)
62
+
63
+ raise Error::ValidationError, "Return step '#{self.class.return_step}' is not defined"
64
+ end
65
+
66
+ def validate_dependencies!
67
+ graph = DependencyGraph.new
68
+ self.class.steps.each_value { |config| graph.add_step(config) }
69
+
70
+ return unless graph.has_cycles?
71
+
72
+ raise Error::DependencyError, "Dependency graph contains cycles"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Tracks retry attempts and state for steps in async execution
5
+ class RetryContext
6
+ attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at
7
+
8
+ def initialize
9
+ @step_attempts = {}
10
+ @current_step = nil
11
+ @failure_reason = nil
12
+ @next_retry_at = nil
13
+ end
14
+
15
+ def increment_attempt_for_step(step_name)
16
+ step_name = step_name.to_s
17
+ @step_attempts[step_name] ||= 0
18
+ @step_attempts[step_name] += 1
19
+ end
20
+
21
+ def attempts_for_step(step_name)
22
+ step_name = step_name.to_s
23
+ @step_attempts[step_name] || 0
24
+ end
25
+
26
+ def can_retry_step?(step_name, max_attempts)
27
+ attempts = attempts_for_step(step_name)
28
+ attempts < max_attempts
29
+ end
30
+
31
+ def reset
32
+ @step_attempts = {}
33
+ @current_step = nil
34
+ @failure_reason = nil
35
+ @next_retry_at = nil
36
+ end
37
+
38
+ def serialize_for_retry
39
+ {
40
+ step_attempts: @step_attempts,
41
+ current_step: @current_step,
42
+ failure_reason: serialize_error(@failure_reason),
43
+ next_retry_at: @next_retry_at&.iso8601
44
+ }
45
+ end
46
+
47
+ def self.deserialize_from_retry(data)
48
+ context = new
49
+ context.step_attempts = data["step_attempts"] || {}
50
+ context.current_step = data["current_step"]
51
+ context.failure_reason = deserialize_error(data["failure_reason"])
52
+ context.next_retry_at = data["next_retry_at"] ? Time.iso8601(data["next_retry_at"]) : nil
53
+ context
54
+ end
55
+
56
+ def self.calculate_backoff_delay(attempt_number, backoff_strategy, base_delay)
57
+ case backoff_strategy
58
+ when :exponential
59
+ base_delay * (2**(attempt_number - 1))
60
+ when :linear
61
+ base_delay * attempt_number
62
+ when :fixed
63
+ base_delay
64
+ else
65
+ raise ArgumentError, "Unknown backoff strategy: #{backoff_strategy}"
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def serialize_error(error)
72
+ return nil unless error
73
+
74
+ {
75
+ class: error.class.name,
76
+ message: error.message,
77
+ backtrace: error.backtrace
78
+ }
79
+ end
80
+
81
+ def self.deserialize_error(data)
82
+ return nil unless data
83
+
84
+ error_class = data["class"] ? Object.const_get(data["class"]) : StandardError
85
+ error = error_class.new(data["message"])
86
+ error.set_backtrace(data["backtrace"]) if data["backtrace"]
87
+ error
88
+ end
89
+
90
+ private_class_method :deserialize_error
91
+ end
92
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Result returned when a step retry has been queued for async execution
5
+ class RetryQueuedResult
6
+ attr_reader :step_name, :attempt_number, :next_retry_at
7
+
8
+ def initialize(step_name, attempt_number, next_retry_at)
9
+ @step_name = step_name
10
+ @attempt_number = attempt_number
11
+ @next_retry_at = next_retry_at
12
+ end
13
+
14
+ def retry_queued?
15
+ true
16
+ end
17
+
18
+ def success?
19
+ false
20
+ end
21
+
22
+ def failure?
23
+ false
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module SidekiqWorkers
5
+ class MapCollectorWorker
6
+ include ::Sidekiq::Worker
7
+
8
+ def perform(arguments)
9
+ RubyReactor::Map::Collector.perform(arguments)
10
+ end
11
+ end
12
+ end
13
+ end