ruby_reactor 0.4.1 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require_relative "executor/input_validator"
4
5
  require_relative "executor/graph_manager"
5
6
  require_relative "executor/retry_manager"
@@ -8,16 +9,19 @@ require_relative "executor/result_handler"
8
9
  require_relative "executor/step_executor"
9
10
 
10
11
  module RubyReactor
12
+ # rubocop:disable Metrics/ClassLength
11
13
  class Executor
12
14
  attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
13
- :step_executor, :result
15
+ :step_executor, :result, :middlewares
14
16
 
15
17
  def initialize(reactor_class, inputs = {}, context = nil)
16
18
  @reactor_class = reactor_class
17
19
  @context = context || Context.new(inputs, reactor_class)
20
+ @middlewares = Executor.middlewares_for(reactor_class)
21
+ @context.middlewares = @middlewares
18
22
  @dependency_graph = DependencyGraph.new
19
23
  @compensation_manager = CompensationManager.new(@context)
20
- @retry_manager = RetryManager.new(@context)
24
+ @retry_manager = RetryManager.new(@context, @middlewares)
21
25
  @result_handler = ResultHandler.new(
22
26
  context: @context,
23
27
  compensation_manager: @compensation_manager,
@@ -30,7 +34,8 @@ module RubyReactor
30
34
  managers: {
31
35
  retry_manager: @retry_manager,
32
36
  result_handler: @result_handler,
33
- compensation_manager: @compensation_manager
37
+ compensation_manager: @compensation_manager,
38
+ middlewares: @middlewares
34
39
  }
35
40
  )
36
41
  @result = nil
@@ -38,17 +43,44 @@ module RubyReactor
38
43
  @acquired_semaphore = nil
39
44
  end
40
45
 
41
- def execute
46
+ def self.resolve_middlewares(reactor_class)
47
+ global_list = Array(RubyReactor.configuration.middlewares)
48
+ reactor_list = if reactor_class.respond_to?(:middlewares)
49
+ Array(reactor_class.middlewares)
50
+ else
51
+ []
52
+ end
53
+
54
+ (global_list + reactor_list).map do |mw|
55
+ if mw.is_a?(Class)
56
+ mw.new
57
+ elsif mw.is_a?(Array) && mw.first.is_a?(Class)
58
+ klass, opts = mw
59
+ klass.new(**(opts || {}))
60
+ else
61
+ mw
62
+ end
63
+ end
64
+ end
65
+
66
+ def self.middlewares_for(reactor_class)
67
+ RubyReactor::MiddlewareRunner.new(resolve_middlewares(reactor_class))
68
+ end
69
+
70
+ def execute # rubocop:disable Metrics/MethodLength
71
+ middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
72
+ completed = false
73
+
42
74
  skipped = check_period_gate
43
75
  if skipped
44
76
  @result = skipped
45
77
  update_context_status(@result)
46
78
  save_context
79
+ completed = true
47
80
  return @result
48
81
  end
49
82
 
50
- check_rate_limit
51
- acquire_locks
83
+ acquire_locks_with_telemetry
52
84
 
53
85
  input_validator = InputValidator.new(@reactor_class, @context)
54
86
  input_validator.validate!
@@ -64,6 +96,7 @@ module RubyReactor
64
96
  update_context_status(@result)
65
97
  mark_period_on_success(@result)
66
98
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
99
+ completed = true
67
100
  @result
68
101
  rescue RubyReactor::Lock::AcquisitionError,
69
102
  RubyReactor::Semaphore::AcquisitionError,
@@ -72,41 +105,60 @@ module RubyReactor
72
105
  rescue StandardError => e
73
106
  @result = @result_handler.handle_execution_error(e)
74
107
  update_context_status(@result)
108
+ completed = true
75
109
  @result
76
110
  ensure
77
111
  release_locks
78
112
  save_context if persist_context?
113
+
114
+ if completed
115
+ middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
116
+ else
117
+ middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
118
+ end
79
119
  end
80
120
 
81
121
  def resume_execution
82
- @context.status = :running
83
- acquire_locks
84
- prepare_for_resume
85
- save_context
86
-
87
- @result = if @context.current_step
88
- execute_current_step_and_continue
89
- else
90
- execute_remaining_steps
91
- end
122
+ middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
123
+ completed = false
124
+ begin
125
+ @context.status = :running
126
+ acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
127
+ acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
128
+ prepare_for_resume
129
+ save_context
92
130
 
93
- update_context_status(@result)
94
- mark_period_on_success(@result)
131
+ @result = if @context.current_step
132
+ execute_current_step_and_continue
133
+ else
134
+ execute_remaining_steps
135
+ end
95
136
 
96
- handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
137
+ update_context_status(@result)
138
+ mark_period_on_success(@result)
139
+
140
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
141
+ completed = true
142
+ @result
143
+ rescue RubyReactor::Lock::AcquisitionError,
144
+ RubyReactor::Semaphore::AcquisitionError,
145
+ RubyReactor::RateLimit::ExceededError
146
+ raise
147
+ rescue StandardError => e
148
+ handle_resume_error(e)
149
+ update_context_status(@result)
150
+ completed = true
151
+ @result
152
+ ensure
153
+ release_locks
154
+ save_context
97
155
 
98
- @result
99
- rescue RubyReactor::Lock::AcquisitionError,
100
- RubyReactor::Semaphore::AcquisitionError,
101
- RubyReactor::RateLimit::ExceededError => e
102
- raise e
103
- rescue StandardError => e
104
- handle_resume_error(e)
105
- update_context_status(@result)
106
- @result
107
- ensure
108
- release_locks
109
- save_context
156
+ if completed
157
+ middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
158
+ else
159
+ middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
160
+ end
161
+ end
110
162
  end
111
163
 
112
164
  def undo_all
@@ -143,10 +195,15 @@ module RubyReactor
143
195
  private
144
196
 
145
197
  def acquire_locks
198
+ check_rate_limit
146
199
  acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
147
200
  acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
148
201
  end
149
202
 
203
+ def acquire_locks_with_telemetry
204
+ acquire_locks
205
+ end
206
+
150
207
  # Consume one slot from each configured rate-limit window. Raises
151
208
  # `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
152
209
  # hint) if any window is full. Only consulted on initial `execute`; resumes
@@ -202,8 +259,14 @@ module RubyReactor
202
259
  wait: contention_wait(config[:wait]),
203
260
  auto_extend: config.fetch(:auto_extend, true)
204
261
  )
205
- lock.acquire
206
- @acquired_lock = lock
262
+ begin
263
+ lock.acquire
264
+ @acquired_lock = lock
265
+ middlewares.on(:lock_acquired, key, @context)
266
+ rescue RubyReactor::Lock::AcquisitionError => e
267
+ middlewares.on(:lock_failed, key, e, @context)
268
+ raise
269
+ end
207
270
  end
208
271
 
209
272
  def acquire_semaphore
@@ -212,8 +275,14 @@ module RubyReactor
212
275
  limit = config[:limit]
213
276
 
214
277
  semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
215
- semaphore.acquire
216
- @acquired_semaphore = semaphore
278
+ begin
279
+ semaphore.acquire
280
+ @acquired_semaphore = semaphore
281
+ middlewares.on(:semaphore_acquired, key, limit, @context)
282
+ rescue RubyReactor::Semaphore::AcquisitionError => e
283
+ middlewares.on(:semaphore_failed, key, limit, e, @context)
284
+ raise
285
+ end
217
286
  end
218
287
 
219
288
  # Inside a Sidekiq worker we'd rather snooze the job via perform_in than
@@ -226,13 +295,19 @@ module RubyReactor
226
295
  end
227
296
 
228
297
  def release_locks
229
- release_one("semaphore", @acquired_semaphore) if @acquired_semaphore
298
+ if @acquired_semaphore
299
+ key = @acquired_semaphore.key
300
+ release_one("semaphore", @acquired_semaphore)
301
+ middlewares.on(:semaphore_released, key, @context)
302
+ end
230
303
  @acquired_semaphore = nil
231
304
 
232
305
  return unless @acquired_lock
233
306
 
307
+ key = @acquired_lock.key
234
308
  release_one("lock", @acquired_lock)
235
309
  @acquired_lock = nil
310
+ middlewares.on(:lock_released, key, @context)
236
311
  end
237
312
 
238
313
  def release_one(kind, primitive)
@@ -332,4 +407,5 @@ module RubyReactor
332
407
  )
333
408
  end
334
409
  end
410
+ # rubocop:enable Metrics/ClassLength
335
411
  end
@@ -59,8 +59,8 @@ module RubyReactor
59
59
  # Resume parent execution
60
60
  resume_parent_execution(parent_context, step_name, final_result, storage)
61
61
  rescue StandardError => e
62
- puts "COLLECTOR CRASH: #{e.message}"
63
- puts e.backtrace
62
+ RubyReactor.configuration.logger.error("Map collector crashed: #{e.message}")
63
+ RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
64
64
  raise e
65
65
  end
66
66
 
@@ -74,8 +74,8 @@ module RubyReactor
74
74
  collected = collect_block.call(results)
75
75
  RubyReactor::Success(collected)
76
76
  rescue StandardError => e
77
- puts "COLLECTOR INNER EXCEPTION: #{e.message}"
78
- puts e.backtrace
77
+ RubyReactor.configuration.logger.error("Map collect block raised: #{e.message}")
78
+ RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
79
79
  RubyReactor::Failure(e)
80
80
  end
81
81
  else
@@ -9,6 +9,12 @@ module RubyReactor
9
9
  arguments = arguments.transform_keys(&:to_sym)
10
10
 
11
11
  context = hydrate_or_create_context(arguments)
12
+ # The element already runs inside its own background worker, so any async
13
+ # steps (and async retries) must execute inline here rather than handing
14
+ # off to a detached Worker that would escape map result/counter tracking.
15
+ # This mirrors SidekiqWorkers::Worker, which sets the same flag.
16
+ context.inline_async_execution = true
17
+
12
18
  storage = RubyReactor.configuration.storage_adapter
13
19
  storage.store_map_element_context_id(arguments[:map_id], context.context_id,
14
20
  arguments[:parent_reactor_class_name])
@@ -18,7 +24,15 @@ module RubyReactor
18
24
  executor = Executor.new(context.reactor_class, {}, context)
19
25
  arguments[:serialized_context] ? executor.resume_execution : executor.execute
20
26
 
21
- handle_result(executor.result, arguments, context, storage, executor)
27
+ result = executor.result
28
+
29
+ # An async retry requeued this element as a fresh MapElementWorker job, so
30
+ # it is not finished yet. Do not store a result, decrement the completion
31
+ # counter, or trigger the next batch — the requeued job will do that when
32
+ # the element ultimately succeeds or exhausts its retries.
33
+ return if result.is_a?(RetryQueuedResult)
34
+
35
+ handle_result(result, arguments, context, storage, executor)
22
36
  finalize_execution(arguments, storage)
23
37
  end
24
38
 
@@ -52,7 +52,7 @@ module RubyReactor
52
52
  end
53
53
 
54
54
  # Resumes parent reactor execution after map completion
55
- def resume_parent_execution(parent_context, step_name, final_result, storage)
55
+ def resume_parent_execution(parent_context, step_name, final_result, storage) # rubocop:disable Metrics/MethodLength
56
56
  executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
57
  step_name_sym = step_name.to_sym
58
58
 
@@ -74,9 +74,22 @@ module RubyReactor
74
74
  error.set_backtrace(final_result.error.backtrace)
75
75
  end
76
76
 
77
- failure_response = executor.result_handler.handle_execution_error(error)
78
- # Manually update context status since we're not running executor loop
79
- executor.send(:update_context_status, failure_response)
77
+ # Bracket the rollback with reactor lifecycle events so that
78
+ # compensation/undo spans nest under a reactor span (and stay attached
79
+ # to the originating trace), mirroring the success path's
80
+ # resume_execution. Without this, rollback runs in the collector worker
81
+ # with no active reactor span and the undo/compensation spans orphan.
82
+ executor.middlewares.on(:start_reactor, parent_context.reactor_class.name, parent_context.inputs,
83
+ parent_context)
84
+ failure_response = nil
85
+ begin
86
+ failure_response = executor.result_handler.handle_execution_error(error)
87
+ # Manually update context status since we're not running executor loop
88
+ executor.send(:update_context_status, failure_response)
89
+ ensure
90
+ executor.middlewares.on(:failed_reactor, parent_context.reactor_class.name, failure_response,
91
+ parent_context)
92
+ end
80
93
  else
81
94
  parent_context.set_result(step_name_sym, final_result.value)
82
95
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Base class for all middlewares in RubyReactor.
5
+ # Middlewares allow hooking into the execution lifecycle of reactors and steps.
6
+ class Middleware
7
+ attr_reader :options
8
+
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # MiddlewareRunner executes event hooks on a collection of configured middlewares.
5
+ class MiddlewareRunner
6
+ def initialize(middlewares)
7
+ @middlewares = middlewares || []
8
+ end
9
+
10
+ # Dispatches the given lifecycle event to all configured middlewares.
11
+ # Invokes the specific event hook method if defined, e.g., `on_start_reactor`.
12
+ # Fallback to the generic `on` method if implemented on the middleware.
13
+ # StandardErrors are swallowed and logged to prevent middleware failure from halting execution.
14
+ def on(event, *args)
15
+ @middlewares.each do |middleware|
16
+ method_name = "on_#{event}"
17
+ if middleware.respond_to?(method_name)
18
+ middleware.send(method_name, *args)
19
+ elsif middleware.respond_to?(:on)
20
+ middleware.on(event, *args)
21
+ end
22
+ rescue StandardError => e
23
+ RubyReactor.configuration.logger.warn(
24
+ "RubyReactor middleware error in #{middleware.class} during #{event}: #{e.message}"
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end