ruby_reactor 0.3.0 → 0.3.2

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. metadata +16 -3
@@ -34,12 +34,26 @@ module RubyReactor
34
34
  }
35
35
  )
36
36
  @result = nil
37
+ @acquired_lock = nil
38
+ @acquired_semaphore = nil
37
39
  end
38
40
 
39
41
  def execute
42
+ skipped = check_period_gate
43
+ if skipped
44
+ @result = skipped
45
+ update_context_status(@result)
46
+ save_context
47
+ return @result
48
+ end
49
+
50
+ check_rate_limit
51
+ acquire_locks
52
+
40
53
  input_validator = InputValidator.new(@reactor_class, @context)
41
54
  input_validator.validate!
42
55
 
56
+ @context.status = :running
43
57
  save_context
44
58
 
45
59
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
@@ -48,17 +62,25 @@ module RubyReactor
48
62
 
49
63
  @result = @step_executor.execute_all_steps
50
64
  update_context_status(@result)
65
+ mark_period_on_success(@result)
51
66
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
52
67
  @result
68
+ rescue RubyReactor::Lock::AcquisitionError,
69
+ RubyReactor::Semaphore::AcquisitionError,
70
+ RubyReactor::RateLimit::ExceededError => e
71
+ raise e
53
72
  rescue StandardError => e
54
73
  @result = @result_handler.handle_execution_error(e)
55
74
  update_context_status(@result)
56
75
  @result
57
76
  ensure
77
+ release_locks
58
78
  save_context
59
79
  end
60
80
 
61
81
  def resume_execution
82
+ @context.status = :running
83
+ acquire_locks
62
84
  prepare_for_resume
63
85
  save_context
64
86
 
@@ -69,15 +91,21 @@ module RubyReactor
69
91
  end
70
92
 
71
93
  update_context_status(@result)
94
+ mark_period_on_success(@result)
72
95
 
73
96
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
74
97
 
75
98
  @result
99
+ rescue RubyReactor::Lock::AcquisitionError,
100
+ RubyReactor::Semaphore::AcquisitionError,
101
+ RubyReactor::RateLimit::ExceededError => e
102
+ raise e
76
103
  rescue StandardError => e
77
104
  handle_resume_error(e)
78
105
  update_context_status(@result)
79
106
  @result
80
107
  ensure
108
+ release_locks
81
109
  save_context
82
110
  end
83
111
 
@@ -108,29 +136,127 @@ module RubyReactor
108
136
 
109
137
  private
110
138
 
139
+ def acquire_locks
140
+ acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
141
+ acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
142
+ end
143
+
144
+ # Consume one slot from each configured rate-limit window. Raises
145
+ # `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
146
+ # hint) if any window is full. Only consulted on initial `execute`; resumes
147
+ # never re-check (a paused reactor must not block itself on resume).
148
+ def check_rate_limit
149
+ return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
150
+
151
+ config = @reactor_class.rate_limit_config
152
+ key_base = config[:key_proc].call(@context.inputs)
153
+
154
+ RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
155
+ end
156
+
157
+ # Returns a Skipped result if the period bucket is already marked, else nil.
158
+ # Only consulted on initial `execute`; resumes never re-check (a paused run
159
+ # must not skip itself when its own marker eventually appears).
160
+ def check_period_gate
161
+ return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
162
+
163
+ config = @reactor_class.period_config
164
+ key = period_key(config)
165
+ return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
166
+
167
+ RubyReactor::Skipped.new(reason: :period, period_key: key)
168
+ end
169
+
170
+ def mark_period_on_success(result)
171
+ return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
172
+ return unless result.is_a?(RubyReactor::Success)
173
+ return if result.is_a?(RubyReactor::Skipped)
174
+
175
+ config = @reactor_class.period_config
176
+ ttl = RubyReactor::Period.ttl_seconds(config[:every])
177
+ RubyReactor.configuration.storage_adapter.period_mark(period_key(config), ttl)
178
+ end
179
+
180
+ def period_key(config)
181
+ base = config[:key_proc].call(@context.inputs)
182
+ RubyReactor::Period.key(base, config[:every])
183
+ end
184
+
185
+ def acquire_exclusive_lock
186
+ config = @reactor_class.lock_config
187
+ key = config[:key_proc].call(@context.inputs)
188
+
189
+ # Use root context ID as owner to allow re-entrancy across nested reactors
190
+ owner = (@context.root_context || @context).context_id
191
+
192
+ lock = RubyReactor::Lock.new(
193
+ key,
194
+ owner: owner,
195
+ ttl: config[:ttl],
196
+ wait: contention_wait(config[:wait]),
197
+ auto_extend: config.fetch(:auto_extend, true)
198
+ )
199
+ lock.acquire
200
+ @acquired_lock = lock
201
+ end
202
+
203
+ def acquire_semaphore
204
+ config = @reactor_class.semaphore_config
205
+ key = config[:key_proc].call(@context.inputs)
206
+ limit = config[:limit]
207
+
208
+ semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
209
+ semaphore.acquire
210
+ @acquired_semaphore = semaphore
211
+ end
212
+
213
+ # Inside a Sidekiq worker we'd rather snooze the job via perform_in than
214
+ # tie up the worker thread on a BLPOP / sleep loop. The non-blocking path
215
+ # fails fast and the Worker rescue branch reschedules.
216
+ def contention_wait(configured_wait)
217
+ return 0 if @context.inline_async_execution
218
+
219
+ configured_wait
220
+ end
221
+
222
+ def release_locks
223
+ release_one("semaphore", @acquired_semaphore) if @acquired_semaphore
224
+ @acquired_semaphore = nil
225
+
226
+ return unless @acquired_lock
227
+
228
+ release_one("lock", @acquired_lock)
229
+ @acquired_lock = nil
230
+ end
231
+
232
+ def release_one(kind, primitive)
233
+ released = primitive.release
234
+ return if released
235
+
236
+ RubyReactor.configuration.logger.warn(
237
+ "RubyReactor #{kind} '#{primitive.key}' was not held at release time " \
238
+ "(likely TTL expired or owner changed)"
239
+ )
240
+ rescue StandardError => e
241
+ # Never let release break the ensure chain — log and move on.
242
+ RubyReactor.configuration.logger.warn(
243
+ "RubyReactor failed to release #{kind} '#{primitive.key}': #{e.message}"
244
+ )
245
+ end
246
+
111
247
  def update_context_status(result)
112
248
  return unless result
113
249
 
114
250
  case result
115
251
  when RubyReactor::AsyncResult
116
252
  @context.status = :running
253
+ when RubyReactor::Skipped
254
+ @context.status = :skipped
117
255
  when RubyReactor::Success
118
256
  @context.status = :completed
119
257
  when RubyReactor::Failure
120
258
  @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
- }
259
+ @context.failure_reason = result
134
260
  when RubyReactor::InterruptResult
135
261
  @context.status = :paused
136
262
  end
@@ -158,8 +284,15 @@ module RubyReactor
158
284
  @result = @step_executor.execute_all_steps
159
285
  else
160
286
  case result
161
- when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult, RubyReactor::InterruptResult
162
- # Step was requeued, failed, or handed off to async - return the result
287
+ # Skipped must be listed before Success (Skipped < Success) so the
288
+ # halt path wins over the "continue with remaining steps" path.
289
+ when RubyReactor::Skipped,
290
+ RetryQueuedResult,
291
+ RubyReactor::Failure,
292
+ RubyReactor::AsyncResult,
293
+ RubyReactor::InterruptResult
294
+ # Terminal: step was skipped, requeued, failed, paused, or handed
295
+ # off to async. Return the result as-is.
163
296
  @result = result
164
297
  when RubyReactor::Success
165
298
  # Step succeeded, continue with remaining steps
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Lock
5
+ class AcquisitionError < StandardError; end
6
+
7
+ # Minimum interval between auto-extend pings; protects very small TTLs.
8
+ MIN_EXTEND_INTERVAL = 1.0
9
+
10
+ attr_reader :key, :owner, :ttl, :wait, :auto_extend
11
+
12
+ def initialize(key, owner:, ttl: 60, wait: 0, auto_extend: true)
13
+ @key = "lock:#{key}"
14
+ @owner = owner
15
+ @ttl = ttl
16
+ @wait = wait
17
+ @auto_extend = auto_extend
18
+ @extender = nil
19
+ @extender_running = false
20
+ end
21
+
22
+ def acquire
23
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ loop do
26
+ if adapter.lock_acquire(@key, @owner, @ttl)
27
+ start_extender if @auto_extend
28
+ return true
29
+ end
30
+
31
+ if @wait.zero? || (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) >= @wait
32
+ raise AcquisitionError, "Could not acquire lock '#{@key}' for owner '#{@owner}'"
33
+ end
34
+
35
+ sleep 0.1
36
+ end
37
+ end
38
+
39
+ def release
40
+ stop_extender
41
+ adapter.lock_release(@key, @owner)
42
+ end
43
+
44
+ def synchronize
45
+ acquire
46
+ yield
47
+ ensure
48
+ release
49
+ end
50
+
51
+ private
52
+
53
+ def start_extender
54
+ return if @extender&.alive?
55
+
56
+ interval = [@ttl / 3.0, MIN_EXTEND_INTERVAL].max
57
+ @extender_running = true
58
+
59
+ @extender = Thread.new do
60
+ while @extender_running
61
+ sleep interval
62
+ break unless @extender_running
63
+
64
+ begin
65
+ adapter.lock_extend(@key, @owner, @ttl)
66
+ rescue StandardError => e
67
+ RubyReactor.configuration.logger.warn(
68
+ "Lock auto-extend failed for '#{@key}': #{e.message}"
69
+ )
70
+ break
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def stop_extender
77
+ @extender_running = false
78
+ thread = @extender
79
+ @extender = nil
80
+ return unless thread
81
+
82
+ thread.wakeup if thread.alive?
83
+ thread.join(0.1)
84
+ rescue StandardError
85
+ # Best-effort shutdown; never let extender teardown break release.
86
+ end
87
+
88
+ def adapter
89
+ RubyReactor.configuration.storage_adapter
90
+ end
91
+ end
92
+ end
@@ -29,21 +29,9 @@ module RubyReactor
29
29
  # Since map_offset tracks dispatching progress and might exceed count due to batching reservation,
30
30
  # we must strictly check against the total count of elements.
31
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"])
36
-
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
32
+ if (failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name))
33
+ handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
34
+ return
47
35
  end
48
36
 
49
37
  return if results_count < total_count
@@ -96,6 +84,19 @@ module RubyReactor
96
84
  RubyReactor::Success(results)
97
85
  end
98
86
  end
87
+
88
+ def self.handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
89
+ # Resolve the class of the mapped reactor to retrieve its context
90
+ reactor_class = resolve_reactor_class(metadata["reactor_class_info"])
91
+ failed_context_data = storage.retrieve_context(failed_context_id, reactor_class.name)
92
+
93
+ return unless failed_context_data
94
+
95
+ failed_context = RubyReactor::Context.deserialize_from_retry(failed_context_data)
96
+ reason = failed_context.failure_reason
97
+ result = reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
98
+ resume_parent_execution(parent_context, step_name, result, storage)
99
+ end
99
100
  end
100
101
  end
101
102
  end
@@ -5,151 +5,137 @@ module RubyReactor
5
5
  class ElementExecutor
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
- 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
10
 
24
- if serialized_context
25
- context = ContextSerializer.deserialize(serialized_context)
11
+ context = hydrate_or_create_context(arguments)
12
+ storage = RubyReactor.configuration.storage_adapter
13
+ storage.store_map_element_context_id(arguments[:map_id], context.context_id,
14
+ arguments[:parent_reactor_class_name])
15
+
16
+ return if check_fail_fast?(arguments, storage)
17
+
18
+ executor = Executor.new(context.reactor_class, {}, context)
19
+ arguments[:serialized_context] ? executor.resume_execution : executor.execute
20
+
21
+ handle_result(executor.result, arguments, context, storage, executor)
22
+ finalize_execution(arguments, storage)
23
+ end
24
+
25
+ def self.load_parent_context(arguments, reactor_class_name, storage)
26
+ parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
27
+ parent_reactor_class = Object.const_get(reactor_class_name)
28
+ parent_context = Context.new(
29
+ ContextSerializer.deserialize_value(parent_context_data["inputs"]),
30
+ parent_reactor_class
31
+ )
32
+ parent_context.context_id = arguments[:parent_context_id]
33
+ parent_context
34
+ end
35
+
36
+ # Legacy helpers resolved_next_element, build_serialized_inputs, queue_element_job
37
+ # are REMOVED as they are no longer used for self-queuing.
38
+
39
+ # Basic helper to build inputs for the CURRENT element (still needed for perform)
40
+ # Wait, perform uses `serialized_inputs` passed to it.
41
+ # We don't need `build_element_inputs` here?
42
+ # `perform` uses `params[:serialized_inputs]`.
43
+ # So we can remove input building helpers too?
44
+ # Let's check if they are used elsewhere.
45
+ # `resolve_reactor_class` is used in `perform`.
46
+ # `build_element_inputs` is likely in Helpers or mixed in?
47
+
48
+ # rubocop:disable Style/IdenticalConditionalBranches
49
+ def self.hydrate_or_create_context(arguments)
50
+ if arguments[:serialized_context]
51
+ context = ContextSerializer.deserialize(arguments[:serialized_context])
26
52
  context.map_metadata = arguments
27
- reactor_class = context.reactor_class
28
53
 
29
- # Ensure inputs are present (fallback to serialized_inputs if missing from context)
30
- if context.inputs.empty? && serialized_inputs
31
- context.inputs = ContextSerializer.deserialize_value(serialized_inputs)
54
+ if context.inputs.empty? && arguments[:serialized_inputs]
55
+ context.inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
32
56
  end
57
+ context
33
58
  else
34
- # Deserialize inputs
35
- inputs = ContextSerializer.deserialize_value(serialized_inputs)
36
-
37
- # Resolve reactor class
38
- reactor_class = resolve_reactor_class(reactor_class_info)
59
+ inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
60
+ reactor_class = resolve_reactor_class(arguments[:reactor_class_info])
39
61
 
40
- # Create context
41
62
  context = Context.new(inputs, reactor_class)
42
- context.parent_context_id = parent_context_id
63
+ context.parent_context_id = arguments[:parent_context_id]
43
64
  context.map_metadata = arguments
65
+ context
44
66
  end
67
+ end
68
+ # rubocop:enable Style/IdenticalConditionalBranches
45
69
 
46
- storage = RubyReactor.configuration.storage_adapter
47
- storage.store_map_element_context_id(map_id, context.context_id, parent_reactor_class_name)
48
-
49
- # Fail Fast Check
50
- if arguments[:fail_fast]
51
- failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
52
- if failed_context_id
53
- # Decrement counter as we are skipping execution
54
- new_count = storage.decrement_map_counter(map_id, parent_reactor_class_name)
55
- return unless new_count.zero?
70
+ def self.check_fail_fast?(arguments, storage)
71
+ return false unless arguments[:fail_fast]
56
72
 
57
- # Trigger collection if we are the last one (skipped or otherwise)
58
- RubyReactor.configuration.async_router.perform_map_collection_async(
59
- parent_context_id: parent_context_id,
60
- map_id: map_id,
61
- parent_reactor_class_name: parent_reactor_class_name,
62
- step_name: step_name,
63
- strict_ordering: strict_ordering,
64
- timeout: 3600
65
- )
66
- return
67
- end
68
- end
69
-
70
- # Execute
71
- executor = Executor.new(reactor_class, {}, context)
73
+ map_id = arguments[:map_id]
74
+ parent_reactor_class_name = arguments[:parent_reactor_class_name]
72
75
 
73
- if serialized_context
74
- executor.resume_execution
75
- else
76
- executor.execute
77
- end
76
+ failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
77
+ return false unless failed_context_id
78
78
 
79
- result = executor.result
79
+ # Skip execution
80
+ finalize_execution(arguments, storage)
81
+ true
82
+ end
80
83
 
81
- if result.is_a?(RetryQueuedResult)
82
- trigger_next_batch_if_needed(arguments, index, batch_size)
83
- return
84
- end
84
+ def self.handle_result(result, arguments, context, storage, executor)
85
+ return if result.is_a?(RetryQueuedResult)
85
86
 
86
- # Store result
87
+ map_id = arguments[:map_id]
88
+ index = arguments[:index]
89
+ parent_class = arguments[:parent_reactor_class_name] # Using short name for variable
87
90
 
88
91
  if result.success?
89
- storage.store_map_result(map_id, index,
90
- ContextSerializer.serialize_value(result.value),
91
- parent_reactor_class_name,
92
- strict_ordering: strict_ordering)
92
+ storage.store_map_result(map_id, index, ContextSerializer.serialize_value(result.value),
93
+ parent_class, strict_ordering: arguments[:strict_ordering])
93
94
  else
94
- # Trigger Compensation Logic
95
95
  executor.undo_all
96
-
97
- # Store error
98
- storage.store_map_result(map_id, index, { _error: result.error }, parent_reactor_class_name,
99
- strict_ordering: strict_ordering)
96
+ storage.store_map_result(map_id, index, { _error: result.error }, parent_class,
97
+ strict_ordering: arguments[:strict_ordering])
100
98
 
101
99
  if arguments[:fail_fast]
102
- storage.store_map_failed_context_id(map_id, context.context_id, parent_reactor_class_name)
100
+ storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
101
+ # FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
102
+ RubyReactor.configuration.async_router.perform_map_collection_async(
103
+ parent_context_id: arguments[:parent_context_id],
104
+ map_id: map_id,
105
+ parent_reactor_class_name: parent_class,
106
+ step_name: arguments[:step_name],
107
+ strict_ordering: arguments[:strict_ordering],
108
+ timeout: 3600
109
+ )
103
110
  end
104
111
  end
112
+ end
105
113
 
106
- # Decrement counter
107
- new_count = storage.decrement_map_counter(map_id, parent_reactor_class_name)
114
+ def self.finalize_execution(arguments, storage)
115
+ map_id = arguments[:map_id]
116
+ parent_class = arguments[:parent_reactor_class_name]
108
117
 
109
- # Trigger next batch if it's the last element of the current batch
110
- trigger_next_batch_if_needed(arguments, index, batch_size)
118
+ new_count = storage.decrement_map_counter(map_id, parent_class)
119
+ trigger_next_batch_if_needed(arguments, arguments[:index], arguments[:batch_size])
111
120
 
112
121
  return unless new_count.zero?
113
122
 
114
- # Trigger collection
115
123
  RubyReactor.configuration.async_router.perform_map_collection_async(
116
- parent_context_id: parent_context_id,
124
+ parent_context_id: arguments[:parent_context_id],
117
125
  map_id: map_id,
118
- parent_reactor_class_name: parent_reactor_class_name,
119
- step_name: step_name,
120
- strict_ordering: strict_ordering,
126
+ parent_reactor_class_name: parent_class,
127
+ step_name: arguments[:step_name],
128
+ strict_ordering: arguments[:strict_ordering],
121
129
  timeout: 3600
122
130
  )
123
131
  end
124
132
 
125
- def self.load_parent_context(arguments, reactor_class_name, storage)
126
- parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
127
- parent_reactor_class = Object.const_get(reactor_class_name)
128
- parent_context = Context.new(
129
- ContextSerializer.deserialize_value(parent_context_data["inputs"]),
130
- parent_reactor_class
131
- )
132
- parent_context.context_id = arguments[:parent_context_id]
133
- parent_context
134
- end
135
-
136
- # Legacy helpers resolved_next_element, build_serialized_inputs, queue_element_job
137
- # are REMOVED as they are no longer used for self-queuing.
138
-
139
- # Basic helper to build inputs for the CURRENT element (still needed for perform)
140
- # Wait, perform uses `serialized_inputs` passed to it.
141
- # We don't need `build_element_inputs` here?
142
- # `perform` uses `params[:serialized_inputs]`.
143
- # So we can remove input building helpers too?
144
- # Let's check if they are used elsewhere.
145
- # `resolve_reactor_class` is used in `perform`.
146
- # `build_element_inputs` is likely in Helpers or mixed in?
147
-
148
133
  def self.trigger_next_batch_if_needed(arguments, index, batch_size)
149
134
  return unless batch_size && ((index + 1) % batch_size).zero?
150
135
 
151
136
  # Trigger Dispatcher for next batch
152
137
  next_batch_args = arguments.dup
138
+ # Ensure we don't carry over temporary execution flags if any
153
139
  next_batch_args[:continuation] = true
154
140
  RubyReactor::Map::Dispatcher.perform(next_batch_args)
155
141
  end
@@ -31,6 +31,7 @@ module RubyReactor
31
31
  end
32
32
 
33
33
  def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
34
+ # rubocop:disable Metrics/BlockLength
34
35
  source.map.with_index do |element, index|
35
36
  if storage_options[:fail_fast]
36
37
  failed_context_id = storage_options[:storage].retrieve_map_failed_context_id(
@@ -71,12 +72,12 @@ module RubyReactor
71
72
 
72
73
  result
73
74
  end.compact
75
+ # rubocop:enable Metrics/BlockLength
74
76
  end
75
77
 
76
78
  def self.link_contexts(child_context, parent_context)
77
79
  child_context.parent_context = parent_context
78
80
  child_context.root_context = parent_context.root_context || parent_context
79
- child_context.test_mode = parent_context.test_mode
80
81
  child_context.inline_async_execution = parent_context.inline_async_execution
81
82
  end
82
83
 
@@ -63,7 +63,8 @@ module RubyReactor
63
63
  final_result.error,
64
64
  step: step_name_sym,
65
65
  context: parent_context,
66
- original_error: final_result.error.is_a?(Exception) ? final_result.error : nil
66
+ original_error: final_result.error.is_a?(Exception) ? final_result.error : nil,
67
+ exception_class: final_result.respond_to?(:exception_class) ? final_result.exception_class : nil
67
68
  )
68
69
 
69
70
  # Pass backtrace if available
@@ -60,7 +60,7 @@ module RubyReactor
60
60
  end
61
61
 
62
62
  def [](index)
63
- return nil if index < 0 || index >= count
63
+ return nil if index.negative? || index >= count
64
64
 
65
65
  results = @storage.retrieve_map_results_batch(
66
66
  @map_id,