ruby_reactor 0.1.0 → 0.3.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +177 -3
  4. data/Rakefile +25 -0
  5. data/documentation/data_pipelines.md +90 -84
  6. data/documentation/images/failed_order_processing.png +0 -0
  7. data/documentation/images/payment_workflow.png +0 -0
  8. data/documentation/interrupts.md +161 -0
  9. data/gui/.gitignore +24 -0
  10. data/gui/README.md +73 -0
  11. data/gui/eslint.config.js +23 -0
  12. data/gui/index.html +13 -0
  13. data/gui/package-lock.json +5925 -0
  14. data/gui/package.json +46 -0
  15. data/gui/postcss.config.js +6 -0
  16. data/gui/public/vite.svg +1 -0
  17. data/gui/src/App.css +42 -0
  18. data/gui/src/App.tsx +51 -0
  19. data/gui/src/assets/react.svg +1 -0
  20. data/gui/src/components/DagVisualizer.tsx +424 -0
  21. data/gui/src/components/Dashboard.tsx +163 -0
  22. data/gui/src/components/ErrorBoundary.tsx +47 -0
  23. data/gui/src/components/ReactorDetail.tsx +135 -0
  24. data/gui/src/components/StepInspector.tsx +492 -0
  25. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  26. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  27. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  28. data/gui/src/globals.d.ts +7 -0
  29. data/gui/src/index.css +14 -0
  30. data/gui/src/lib/utils.ts +13 -0
  31. data/gui/src/main.tsx +14 -0
  32. data/gui/src/test/setup.ts +11 -0
  33. data/gui/tailwind.config.js +11 -0
  34. data/gui/tsconfig.app.json +28 -0
  35. data/gui/tsconfig.json +7 -0
  36. data/gui/tsconfig.node.json +26 -0
  37. data/gui/vite.config.ts +8 -0
  38. data/gui/vitest.config.ts +13 -0
  39. data/lib/ruby_reactor/async_router.rb +12 -8
  40. data/lib/ruby_reactor/context.rb +35 -9
  41. data/lib/ruby_reactor/context_serializer.rb +15 -0
  42. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  43. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  44. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  45. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  46. data/lib/ruby_reactor/dsl/map_builder.rb +14 -2
  47. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  48. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  49. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  50. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  51. data/lib/ruby_reactor/executor/result_handler.rb +118 -39
  52. data/lib/ruby_reactor/executor/retry_manager.rb +12 -1
  53. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  54. data/lib/ruby_reactor/executor.rb +86 -13
  55. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  56. data/lib/ruby_reactor/map/collector.rb +71 -35
  57. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  58. data/lib/ruby_reactor/map/element_executor.rb +62 -56
  59. data/lib/ruby_reactor/map/execution.rb +44 -4
  60. data/lib/ruby_reactor/map/helpers.rb +44 -6
  61. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  62. data/lib/ruby_reactor/reactor.rb +187 -1
  63. data/lib/ruby_reactor/registry.rb +25 -0
  64. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  65. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  66. data/lib/ruby_reactor/step/map_step.rb +78 -19
  67. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  68. data/lib/ruby_reactor/storage/redis_adapter.rb +213 -11
  69. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  70. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +206 -0
  73. data/lib/ruby_reactor/web/application.rb +53 -0
  74. data/lib/ruby_reactor/web/config.ru +5 -0
  75. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  76. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  77. data/lib/ruby_reactor/web/public/index.html +14 -0
  78. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  79. data/lib/ruby_reactor.rb +94 -28
  80. data/llms-full.txt +66 -0
  81. data/llms.txt +7 -0
  82. metadata +66 -2
@@ -15,62 +15,30 @@ module RubyReactor
15
15
  def handle_step_result(step_config, result, resolved_arguments)
16
16
  case result
17
17
  when RubyReactor::Success
18
- validate_step_output(step_config, result.value, resolved_arguments)
19
- @step_results[step_config.name] = result
20
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
21
- @context.set_result(step_config.name, result.value)
22
- @dependency_graph.complete_step(step_config.name)
18
+ handle_success(step_config, result, resolved_arguments)
23
19
  when RubyReactor::MaxRetriesExhaustedFailure
24
- # For MaxRetriesExhaustedFailure, use the original error to avoid double-wrapping the message
25
- # The error message from MaxRetriesExhaustedFailure already includes "failed after N attempts"
26
- @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
27
- # Use the MaxRetriesExhaustedFailure error message for the final error
28
- raise Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
29
- step_arguments: resolved_arguments)
20
+ handle_retries_exhausted(step_config, result, resolved_arguments)
30
21
  when RubyReactor::Failure
31
- failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
32
- raise Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
33
- step_arguments: resolved_arguments)
22
+ handle_failure(step_config, result, resolved_arguments)
34
23
  else
35
- # Treat non-Success/Failure results as success with that value
36
- validate_step_output(step_config, result, resolved_arguments)
37
- success_result = RubyReactor.Success(result)
38
- @step_results[step_config.name] = success_result
39
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
40
- result: success_result })
41
- @context.set_result(step_config.name, result)
42
- @dependency_graph.complete_step(step_config.name)
24
+ handle_unknown_result(step_config, result, resolved_arguments)
43
25
  end
44
26
  end
45
27
 
46
28
  def handle_execution_error(error)
47
29
  case error
48
30
  when Error::StepFailureError
49
- # Step failure has already been handled (compensation and rollback for the failed step)
50
- # But we need to rollback all completed steps
51
- @compensation_manager.rollback_completed_steps
52
-
53
- redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
54
-
55
- RubyReactor::Failure(
56
- error.message,
57
- step_name: error.step,
58
- inputs: error.context.inputs,
59
- redact_inputs: redact_inputs,
60
- backtrace: error.backtrace,
61
- reactor_name: error.context.reactor_class.name,
62
- step_arguments: error.step_arguments
63
- )
31
+ handle_step_failure_error(error)
64
32
  when Error::InputValidationError
65
33
  # Preserve validation errors as-is for proper error handling
66
34
  RubyReactor.Failure(error)
67
35
  when Error::Base
68
36
  # Other errors need rollback
69
37
  @compensation_manager.rollback_completed_steps
70
- RubyReactor.Failure("Execution error: #{error.message}")
38
+ RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name)
71
39
  else
72
40
  # Unknown errors - don't rollback as they may not be reactor-related
73
- RubyReactor.Failure("Execution failed: #{error.message}")
41
+ RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name)
74
42
  end
75
43
  end
76
44
 
@@ -85,6 +53,102 @@ module RubyReactor
85
53
 
86
54
  private
87
55
 
56
+ def handle_success(step_config, result, resolved_arguments)
57
+ validate_step_output(step_config, result.value, resolved_arguments)
58
+ @step_results[step_config.name] = result
59
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
60
+ @context.set_result(step_config.name, result.value)
61
+ @dependency_graph.complete_step(step_config.name)
62
+ end
63
+
64
+ def handle_retries_exhausted(step_config, result, resolved_arguments)
65
+ @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
66
+ orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
67
+ error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
68
+ original_error: orig_err,
69
+ step_arguments: resolved_arguments)
70
+ if result.respond_to?(:backtrace) && result.backtrace
71
+ error.set_backtrace(result.backtrace)
72
+ elsif orig_err
73
+ error.set_backtrace(orig_err.backtrace)
74
+ end
75
+ raise error
76
+ end
77
+
78
+ def handle_failure(step_config, result, resolved_arguments)
79
+ failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
80
+ orig_err = result.error.is_a?(Exception) ? result.error : nil
81
+ error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
82
+ original_error: orig_err,
83
+ step_arguments: resolved_arguments)
84
+ if result.respond_to?(:backtrace) && result.backtrace
85
+ error.set_backtrace(result.backtrace)
86
+ elsif orig_err
87
+ error.set_backtrace(orig_err.backtrace)
88
+ end
89
+ raise error
90
+ end
91
+
92
+ def handle_unknown_result(step_config, result, resolved_arguments)
93
+ validate_step_output(step_config, result, resolved_arguments)
94
+ success_result = RubyReactor.Success(result)
95
+ @step_results[step_config.name] = success_result
96
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
97
+ result: success_result })
98
+ @context.set_result(step_config.name, result)
99
+ @dependency_graph.complete_step(step_config.name)
100
+ end
101
+
102
+ def handle_step_failure_error(error)
103
+ current_context = error.context || @context
104
+ current_context.current_step = error.step
105
+
106
+ store_failed_map_context(current_context) if current_context.map_metadata
107
+
108
+ @compensation_manager.rollback_completed_steps
109
+
110
+ redact_inputs = []
111
+ if error.context&.reactor_class
112
+ redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
113
+ end
114
+
115
+ create_failure_from_error(error, redact_inputs)
116
+ end
117
+
118
+ def store_failed_map_context(context)
119
+ return unless context.map_metadata && context.map_metadata[:map_id]
120
+ return unless context.map_metadata[:fail_fast]
121
+
122
+ storage = RubyReactor.configuration.storage_adapter
123
+ storage.store_map_failed_context_id(
124
+ context.map_metadata[:map_id],
125
+ context.context_id,
126
+ context.map_metadata[:parent_reactor_class_name]
127
+ )
128
+ end
129
+
130
+ def create_failure_from_error(error, redact_inputs)
131
+ original_error = error.original_error
132
+ exception_class = original_error&.class&.name
133
+ backtrace = original_error&.backtrace || error.backtrace
134
+ file_path, line_number = extract_location(backtrace)
135
+ code_snippet = RubyReactor::Utils::CodeExtractor.extract(file_path, line_number) if file_path
136
+
137
+ RubyReactor.Failure(
138
+ error.message,
139
+ step_name: error.step,
140
+ inputs: error.context.inputs,
141
+ redact_inputs: redact_inputs,
142
+ backtrace: backtrace,
143
+ reactor_name: error.context.reactor_class.name,
144
+ step_arguments: error.step_arguments,
145
+ exception_class: exception_class,
146
+ file_path: file_path,
147
+ line_number: line_number,
148
+ code_snippet: code_snippet
149
+ )
150
+ end
151
+
88
152
  def validate_step_output(step_config, value, resolved_arguments = {})
89
153
  return unless step_config.output_validator
90
154
 
@@ -98,6 +162,21 @@ module RubyReactor
98
162
  step_arguments: resolved_arguments
99
163
  )
100
164
  end
165
+
166
+ def extract_location(backtrace)
167
+ return [nil, nil] unless backtrace && !backtrace.empty?
168
+
169
+ # Filter out internal reactor frames if needed, or just take the first one
170
+ # For now, let's take the first line of the backtrace which should be the error source
171
+ # But we might want to skip our own internal frames if we want to point to user code
172
+ # Let's start with the top frame, assuming backtrace is already correct (from original error)
173
+
174
+ first_line = backtrace.first
175
+ match = first_line.match(/^(.+?):(\d+)(?::in `.*')?$/)
176
+ return [nil, nil] unless match
177
+
178
+ [match[1], match[2].to_i]
179
+ end
101
180
  end
102
181
  end
103
182
  end
@@ -34,11 +34,22 @@ module RubyReactor
34
34
  end
35
35
 
36
36
  def requeue_job_for_step_retry(step_config, error, reactor_class)
37
+ @context.current_step = step_config.name
37
38
  delay = calculate_backoff_delay(step_config, error, reactor_class)
38
39
 
39
40
  # Serialize context and requeue the job
40
41
  # Use root context if available to ensure we serialize the full tree
41
- context_to_serialize = @context.root_context || @context
42
+ # BUT for map elements (which have map_metadata), we must serialize the element context itself
43
+
44
+ context_to_serialize = if @context.map_metadata
45
+ @context
46
+ else
47
+ @context.root_context || @context
48
+ end
49
+
50
+ puts "SERIALIZING CONTEXT: #{context_to_serialize.reactor_class.name}"
51
+ puts "INPUTS KEYS: #{context_to_serialize.inputs.keys}" if context_to_serialize.respond_to?(:inputs)
52
+
42
53
  reactor_class_name = context_to_serialize.reactor_class.name
43
54
 
44
55
  serialized_context = ContextSerializer.serialize(context_to_serialize)
@@ -36,6 +36,9 @@ module RubyReactor
36
36
  # If a step returns Failure, we need to stop execution and return it
37
37
  return result if result.is_a?(RubyReactor::Failure)
38
38
 
39
+ # If a step returns InterruptResult, we need to stop execution and return it
40
+ return result if result.is_a?(RubyReactor::InterruptResult)
41
+
39
42
  # If result is nil, it means async was executed inline (test mode), continue
40
43
  next if result.nil?
41
44
  end
@@ -49,7 +52,13 @@ module RubyReactor
49
52
  # If we're already in inline async execution mode (inside Worker),
50
53
  # treat async steps as sync to avoid infinite recursion
51
54
 
52
- if step_config.async? && !@context.inline_async_execution
55
+ if @dependency_graph.completed.include?(step_config.name)
56
+ return RubyReactor.Success(@context.get_result(step_config.name))
57
+ end
58
+
59
+ if step_config.interrupt?
60
+ handle_interrupt_step(step_config)
61
+ elsif step_config.async? && !@context.inline_async_execution
53
62
  handle_async_step(step_config)
54
63
  else
55
64
  execute_step_with_retry(step_config)
@@ -77,8 +86,10 @@ module RubyReactor
77
86
  # Update retry context
78
87
  @context.retry_context = other_executor.context.retry_context
79
88
 
80
- # Clear current_step since we've completed it
81
- @context.current_step = nil
89
+ # Update current_step:
90
+ # If the other executor has a current_step, it means it paused/interrupted there. We should adopt it.
91
+ # If it's nil, it means it completed successfully, so we clear our current_step (which was the async step).
92
+ @context.current_step = other_executor.context.current_step
82
93
 
83
94
  # Update our dependency graph to reflect completed steps
84
95
  other_executor.context.intermediate_results.each_key do |step_name|
@@ -91,7 +102,8 @@ module RubyReactor
91
102
  # Merge any undo stack items
92
103
  other_executor.undo_stack.each do |item|
93
104
  # Avoid duplicates by checking if this step is already in the undo stack
94
- unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name == item[:step].name }
105
+ # Use string comparison for step names to avoid symbol/string mismatch issues
106
+ unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name.to_s == item[:step].name.to_s }
95
107
  @compensation_manager.add_to_undo_stack(item)
96
108
  end
97
109
  end
@@ -248,6 +260,28 @@ module RubyReactor
248
260
  result.result
249
261
  end
250
262
 
263
+ def handle_interrupt_step(step_config)
264
+ # Check if we have a result for this step (resuming)
265
+ if @context.intermediate_results.key?(step_config.name)
266
+ # We are resuming
267
+ result = @context.get_result(step_config.name)
268
+ return RubyReactor.Success(result)
269
+ end
270
+
271
+ # We are pausing
272
+ correlation_id = nil
273
+ correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
274
+
275
+ # Store current step as the one we are paused at
276
+ @context.current_step = step_config.name
277
+
278
+ RubyReactor::InterruptResult.new(
279
+ execution_id: @context.context_id,
280
+ correlation_id: correlation_id,
281
+ intermediate_results: @context.intermediate_results
282
+ )
283
+ end
284
+
251
285
  def configuration
252
286
  RubyReactor::Configuration.instance
253
287
  end
@@ -40,24 +40,49 @@ module RubyReactor
40
40
  input_validator = InputValidator.new(@reactor_class, @context)
41
41
  input_validator.validate!
42
42
 
43
+ save_context
44
+
43
45
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
44
46
  graph_manager.build_and_validate!
47
+ graph_manager.mark_completed_steps_from_context
45
48
 
46
49
  @result = @step_executor.execute_all_steps
50
+ update_context_status(@result)
51
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
52
+ @result
47
53
  rescue StandardError => e
48
54
  @result = @result_handler.handle_execution_error(e)
55
+ update_context_status(@result)
56
+ @result
57
+ ensure
58
+ save_context
49
59
  end
50
60
 
51
61
  def resume_execution
52
62
  prepare_for_resume
63
+ save_context
53
64
 
54
- if @context.current_step
55
- execute_current_step_and_continue
56
- else
57
- execute_remaining_steps
58
- end
65
+ @result = if @context.current_step
66
+ execute_current_step_and_continue
67
+ else
68
+ execute_remaining_steps
69
+ end
70
+
71
+ update_context_status(@result)
72
+
73
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
74
+
75
+ @result
59
76
  rescue StandardError => e
60
77
  handle_resume_error(e)
78
+ update_context_status(@result)
79
+ @result
80
+ ensure
81
+ save_context
82
+ end
83
+
84
+ def undo_all
85
+ @compensation_manager.rollback_completed_steps
61
86
  end
62
87
 
63
88
  def undo_stack
@@ -72,8 +97,45 @@ module RubyReactor
72
97
  @context.execution_trace
73
98
  end
74
99
 
100
+ def save_context
101
+ storage = RubyReactor::Configuration.instance.storage_adapter
102
+ reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
103
+
104
+ # Serialize context
105
+ serialized_context = ContextSerializer.serialize(@context)
106
+ storage.store_context(@context.context_id, serialized_context, reactor_class_name)
107
+ end
108
+
75
109
  private
76
110
 
111
+ def update_context_status(result)
112
+ return unless result
113
+
114
+ case result
115
+ when RubyReactor::AsyncResult
116
+ @context.status = :running
117
+ when RubyReactor::Success
118
+ @context.status = :completed
119
+ when RubyReactor::Failure
120
+ @context.status = :failed
121
+ @context.failure_reason = {
122
+ message: result.error.is_a?(Exception) ? result.error.message : result.error.to_s,
123
+ step_name: result.step_name,
124
+ inputs: result.inputs,
125
+ backtrace: result.backtrace,
126
+ reactor_name: result.reactor_name,
127
+ step_arguments: result.step_arguments,
128
+ exception_class: result.exception_class,
129
+ file_path: result.file_path,
130
+ line_number: result.line_number,
131
+ code_snippet: result.code_snippet,
132
+ validation_errors: result.validation_errors
133
+ }
134
+ when RubyReactor::InterruptResult
135
+ @context.status = :paused
136
+ end
137
+ end
138
+
77
139
  def prepare_for_resume
78
140
  # Build dependency graph and mark completed steps
79
141
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
@@ -85,6 +147,9 @@ module RubyReactor
85
147
  step_config = @reactor_class.steps[@context.current_step]
86
148
  return RubyReactor::Failure("Step '#{@context.current_step}' not found in reactor") unless step_config
87
149
 
150
+ # If current step is already in intermediate_results, skip directly to execute_all_steps
151
+ return @step_executor.execute_all_steps if @context.intermediate_results.key?(@context.current_step.to_sym)
152
+
88
153
  # Use execute_step (not execute_step_with_retry) so that async steps can be handled properly in inline mode
89
154
  result = @step_executor.execute_step(step_config)
90
155
 
@@ -93,7 +158,7 @@ module RubyReactor
93
158
  @result = @step_executor.execute_all_steps
94
159
  else
95
160
  case result
96
- when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult
161
+ when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult, RubyReactor::InterruptResult
97
162
  # Step was requeued, failed, or handed off to async - return the result
98
163
  @result = result
99
164
  when RubyReactor::Success
@@ -110,14 +175,22 @@ module RubyReactor
110
175
  end
111
176
 
112
177
  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
178
+ @result = @result_handler.handle_execution_error(error)
120
179
  @result
121
180
  end
181
+
182
+ def handle_interrupt(interrupt_result)
183
+ save_context
184
+
185
+ # Store correlation ID mapping if present
186
+ return unless interrupt_result.correlation_id
187
+
188
+ storage = RubyReactor::Configuration.instance.storage_adapter
189
+ storage.store_correlation_id(
190
+ interrupt_result.correlation_id,
191
+ @context.context_id,
192
+ @reactor_class.name
193
+ )
194
+ end
122
195
  end
123
196
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class InterruptResult
5
+ attr_reader :execution_id, :correlation_id, :status, :timeout_at, :intermediate_results, :error
6
+
7
+ def initialize(execution_id:, correlation_id: nil, timeout_at: nil, intermediate_results: {}, error: nil)
8
+ @execution_id = execution_id
9
+ @correlation_id = correlation_id
10
+ @status = :paused
11
+ @timeout_at = timeout_at
12
+ @intermediate_results = intermediate_results
13
+ @error = error
14
+ end
15
+
16
+ def paused?
17
+ true
18
+ end
19
+ end
20
+ end
@@ -5,61 +5,97 @@ module RubyReactor
5
5
  class Collector
6
6
  extend Helpers
7
7
 
8
- # rubocop:disable Metrics/MethodLength
9
8
  def self.perform(arguments)
10
9
  arguments = arguments.transform_keys(&:to_sym)
11
- parent_context_id = arguments[:parent_context_id]
12
10
  map_id = arguments[:map_id]
11
+ parent_context_id = arguments[:parent_context_id]
13
12
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
14
13
  step_name = arguments[:step_name]
15
14
  strict_ordering = arguments[:strict_ordering]
15
+ # timeout = arguments[:timeout]
16
16
 
17
17
  storage = RubyReactor.configuration.storage_adapter
18
+ parent_context_data = storage.retrieve_context(parent_context_id, parent_reactor_class_name)
19
+ parent_context = RubyReactor::Context.deserialize_from_retry(parent_context_data)
20
+
21
+ # Check if all tasks are completed
22
+ metadata = storage.retrieve_map_metadata(map_id, parent_reactor_class_name)
23
+ total_count = metadata ? metadata["count"].to_i : 0
24
+
25
+ results_count = storage.count_map_results(map_id, parent_reactor_class_name)
26
+
27
+ # Not done yet, requeue or wait?
28
+ # Actually Collector currently assumes we only call it when we expect completion or check progress
29
+ # Since map_offset tracks dispatching progress and might exceed count due to batching reservation,
30
+ # we must strictly check against the total count of elements.
31
+ # Check for fail_fast failure FIRST
32
+ failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
33
+ if failed_context_id
34
+ # Resolve the class of the mapped reactor to retrieve its context
35
+ reactor_class = resolve_reactor_class(metadata["reactor_class_info"])
18
36
 
19
- # Retrieve parent context
20
- parent_context = load_parent_context_from_storage(
21
- parent_context_id,
37
+ failed_context_data = storage.retrieve_context(failed_context_id, reactor_class.name)
38
+
39
+ if failed_context_data
40
+ failed_context = RubyReactor::Context.deserialize_from_retry(failed_context_data)
41
+
42
+ # Resume parent execution (which marks step as failed)
43
+ resume_parent_execution(parent_context, step_name, RubyReactor::Failure(failed_context.failure_reason),
44
+ storage)
45
+ return
46
+ end
47
+ end
48
+
49
+ return if results_count < total_count
50
+
51
+ # Retrieve results lazily
52
+ results = RubyReactor::Map::ResultEnumerator.new(
53
+ map_id,
22
54
  parent_reactor_class_name,
23
- storage
55
+ strict_ordering: strict_ordering
24
56
  )
25
57
 
26
- # Retrieve results
27
- serialized_results = storage.retrieve_map_results(map_id, parent_reactor_class_name,
28
- strict_ordering: strict_ordering)
58
+ # Apply collect block (or default collection)
59
+ step_config = parent_context.reactor_class.steps[step_name.to_sym]
29
60
 
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)
61
+ begin
62
+ final_result = apply_collect_block(results, step_config)
63
+
64
+ if final_result.failure?
65
+ # Optionally log failure internally or just rely on context status update
35
66
  end
67
+ rescue StandardError => e
68
+ final_result = RubyReactor::Failure(e)
36
69
  end
37
70
 
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]
71
+ # Resume parent execution
72
+ resume_parent_execution(parent_context, step_name, final_result, storage)
73
+ rescue StandardError => e
74
+ puts "COLLECTOR CRASH: #{e.message}"
75
+ puts e.backtrace
76
+ raise e
77
+ end
41
78
 
42
- collect_block = step_config.arguments[:collect_block][:source].value
79
+ def self.apply_collect_block(results, step_config)
80
+ collect_block = step_config.arguments[:collect_block][:source].value if step_config.arguments[:collect_block]
43
81
  # TODO: Check allow_partial_failure option
44
82
 
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)
83
+ if collect_block
84
+ begin
85
+ # Pass Enumerator to collect block
86
+ collected = collect_block.call(results)
87
+ RubyReactor::Success(collected)
88
+ rescue StandardError => e
89
+ puts "COLLECTOR INNER EXCEPTION: #{e.message}"
90
+ puts e.backtrace
91
+ RubyReactor::Failure(e)
92
+ end
93
+ else
94
+ # Default behavior: Return Success(Enumerator).
95
+ # Logic for checking failures is deferred to the consumer of the enumerator.
96
+ RubyReactor::Success(results)
97
+ end
61
98
  end
62
- # rubocop:enable Metrics/MethodLength
63
99
  end
64
100
  end
65
101
  end