ruby_reactor 0.4.1 → 0.5.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.rubocop.yml +1 -0
  4. data/CHANGELOG.md +14 -0
  5. data/README.md +153 -18
  6. data/lib/ruby_reactor/configuration.rb +13 -1
  7. data/lib/ruby_reactor/context.rb +2 -1
  8. data/lib/ruby_reactor/context_serializer.rb +1 -3
  9. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  10. data/lib/ruby_reactor/dsl/lockable.rb +19 -28
  11. data/lib/ruby_reactor/dsl/reactor.rb +44 -9
  12. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  13. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  14. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
  16. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  17. data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
  18. data/lib/ruby_reactor/executor/step_executor.rb +46 -23
  19. data/lib/ruby_reactor/executor.rb +188 -49
  20. data/lib/ruby_reactor/map/collector.rb +4 -4
  21. data/lib/ruby_reactor/map/element_executor.rb +15 -1
  22. data/lib/ruby_reactor/map/helpers.rb +17 -4
  23. data/lib/ruby_reactor/middleware.rb +13 -0
  24. data/lib/ruby_reactor/middleware_runner.rb +29 -0
  25. data/lib/ruby_reactor/open_telemetry.rb +647 -0
  26. data/lib/ruby_reactor/rate_limit.rb +28 -0
  27. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  28. data/lib/ruby_reactor/reactor.rb +1 -0
  29. data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
  30. data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
  31. data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
  32. data/lib/ruby_reactor/step/map_step.rb +25 -33
  33. data/lib/ruby_reactor/validation/base.rb +4 -1
  34. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  35. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  36. data/lib/ruby_reactor/version.rb +1 -1
  37. data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
  38. data/teley/Dockerfile +60 -0
  39. metadata +6 -3
  40. data/lib/ruby_reactor/map/execution.rb +0 -101
  41. data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +0 -15
@@ -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,21 +43,56 @@ module RubyReactor
38
43
  @acquired_semaphore = nil
39
44
  end
40
45
 
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
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
48
63
  end
64
+ end
49
65
 
50
- check_rate_limit
51
- acquire_locks
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
52
73
 
74
+ if (skipped = check_period_gate)
75
+ completed = true
76
+ return finalize_skipped(skipped)
77
+ end
78
+
79
+ # Validate inputs BEFORE consuming a rate-limit slot or grabbing a
80
+ # lock/semaphore: a run that can never start must not burn quota or
81
+ # briefly block other callers.
53
82
  input_validator = InputValidator.new(@reactor_class, @context)
54
83
  input_validator.validate!
55
84
 
85
+ acquire_locks_with_telemetry
86
+
87
+ # Re-check the period gate now that we hold the lock. The pre-lock check
88
+ # is a fast path; this one closes the race where two callers both passed
89
+ # it and then serialized on the lock — without it the second caller would
90
+ # re-run work the first already marked. (No-op when no lock is configured.)
91
+ if (skipped = check_period_gate)
92
+ completed = true
93
+ return finalize_skipped(skipped)
94
+ end
95
+
56
96
  @context.status = :running
57
97
  save_context
58
98
 
@@ -64,49 +104,91 @@ module RubyReactor
64
104
  update_context_status(@result)
65
105
  mark_period_on_success(@result)
66
106
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
107
+ completed = true
67
108
  @result
68
109
  rescue RubyReactor::Lock::AcquisitionError,
69
110
  RubyReactor::Semaphore::AcquisitionError,
70
- RubyReactor::RateLimit::ExceededError => e
111
+ RubyReactor::RateLimit::ExceededError,
112
+ RubyReactor::RateLimitRegistry::UnknownLimitError => e
71
113
  raise e
72
114
  rescue StandardError => e
73
115
  @result = @result_handler.handle_execution_error(e)
74
116
  update_context_status(@result)
117
+ completed = true
75
118
  @result
76
119
  ensure
77
120
  release_locks
78
121
  save_context if persist_context?
122
+
123
+ if completed
124
+ middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
125
+ else
126
+ middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
127
+ end
79
128
  end
80
129
 
81
- def resume_execution
82
- @context.status = :running
83
- acquire_locks
84
- prepare_for_resume
85
- save_context
130
+ def resume_execution # rubocop:disable Metrics/MethodLength
131
+ middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
132
+ completed = false
133
+ # A fresh async reactor run reaches the worker through resume_execution
134
+ # (it never calls execute), so the period and rate-limit gates that live
135
+ # in execute must be applied here too. Genuine resumes (a step already ran
136
+ # or we paused mid-flight, so current_step is set) must NOT re-gate: a
137
+ # paused reactor must not throttle or skip itself on the way back in.
138
+ first_run = first_execution?
139
+ begin
140
+ @context.status = :running
86
141
 
87
- @result = if @context.current_step
88
- execute_current_step_and_continue
89
- else
90
- execute_remaining_steps
91
- end
142
+ if first_run && (skipped = check_period_gate)
143
+ completed = true
144
+ return finalize_skipped(skipped)
145
+ end
146
+ check_rate_limit if first_run
92
147
 
93
- update_context_status(@result)
94
- mark_period_on_success(@result)
148
+ acquire_concurrency_primitives
95
149
 
96
- handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
150
+ # Post-lock re-check (see execute) closes the period race for the
151
+ # first run of a locked async reactor.
152
+ if first_run && (skipped = check_period_gate)
153
+ completed = true
154
+ return finalize_skipped(skipped)
155
+ end
97
156
 
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
157
+ prepare_for_resume
158
+ save_context
159
+
160
+ @result = if @context.current_step
161
+ execute_current_step_and_continue
162
+ else
163
+ execute_remaining_steps
164
+ end
165
+
166
+ update_context_status(@result)
167
+ mark_period_on_success(@result)
168
+
169
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
170
+ completed = true
171
+ @result
172
+ rescue RubyReactor::Lock::AcquisitionError,
173
+ RubyReactor::Semaphore::AcquisitionError,
174
+ RubyReactor::RateLimit::ExceededError,
175
+ RubyReactor::RateLimitRegistry::UnknownLimitError
176
+ raise
177
+ rescue StandardError => e
178
+ handle_resume_error(e)
179
+ update_context_status(@result)
180
+ completed = true
181
+ @result
182
+ ensure
183
+ release_locks
184
+ save_context
185
+
186
+ if completed
187
+ middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
188
+ else
189
+ middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
190
+ end
191
+ end
110
192
  end
111
193
 
112
194
  def undo_all
@@ -143,26 +225,64 @@ module RubyReactor
143
225
  private
144
226
 
145
227
  def acquire_locks
228
+ check_rate_limit
229
+ acquire_concurrency_primitives
230
+ end
231
+
232
+ def acquire_concurrency_primitives
146
233
  acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
147
234
  acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
148
235
  end
149
236
 
237
+ def acquire_locks_with_telemetry
238
+ acquire_locks
239
+ end
240
+
150
241
  # Consume one slot from each configured rate-limit window. Raises
151
242
  # `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
152
- # hint) if any window is full. Only consulted on initial `execute`; resumes
153
- # never re-check (a paused reactor must not block itself on resume).
243
+ # hint) if any window is full. Consulted on the first execution only —
244
+ # `execute` for sync reactors, the first `resume_execution` pass for async
245
+ # reactors. Genuine resumes never re-check (a paused reactor must not block
246
+ # itself on resume).
154
247
  def check_rate_limit
155
248
  return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
156
249
 
157
250
  config = @reactor_class.rate_limit_config
158
- key_base = config[:key_proc].call(@context.inputs)
159
251
 
160
- RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
252
+ if config[:name]
253
+ # Named global limit: the name is the shared key base and the windows
254
+ # come from the registry (resolved lazily so config order doesn't matter).
255
+ key_base = config[:name].to_s
256
+ limits = RubyReactor.configuration.rate_limits.fetch(config[:name])
257
+ else
258
+ key_base = config[:key_proc].call(@context.inputs)
259
+ limits = config[:limits]
260
+ end
261
+
262
+ RubyReactor::RateLimit.new(key_base, limits: limits).check_and_increment!
263
+ end
264
+
265
+ # True when nothing has run yet for this context — the very first execution
266
+ # of the reactor, including an async reactor's first worker pass. A genuine
267
+ # resume (paused, async-handed-off, or retried step) always records a
268
+ # `current_step` before serializing, so it is never mistaken for a first run.
269
+ def first_execution?
270
+ @context.current_step.nil? && @context.intermediate_results.empty?
271
+ end
272
+
273
+ # Record and persist a Skipped result, then return it. Shared by the
274
+ # pre-lock and post-lock period gates in both execute and resume.
275
+ def finalize_skipped(skipped)
276
+ @result = skipped
277
+ update_context_status(@result)
278
+ save_context
279
+ @result
161
280
  end
162
281
 
163
282
  # Returns a Skipped result if the period bucket is already marked, else nil.
164
- # Only consulted on initial `execute`; resumes never re-check (a paused run
165
- # must not skip itself when its own marker eventually appears).
283
+ # Consulted before AND after lock acquisition on a first execution; genuine
284
+ # resumes never re-check (a paused run must not skip itself when its own
285
+ # marker eventually appears).
166
286
  def check_period_gate
167
287
  return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
168
288
 
@@ -202,8 +322,14 @@ module RubyReactor
202
322
  wait: contention_wait(config[:wait]),
203
323
  auto_extend: config.fetch(:auto_extend, true)
204
324
  )
205
- lock.acquire
206
- @acquired_lock = lock
325
+ begin
326
+ lock.acquire
327
+ @acquired_lock = lock
328
+ middlewares.on(:lock_acquired, key, @context)
329
+ rescue RubyReactor::Lock::AcquisitionError => e
330
+ middlewares.on(:lock_failed, key, e, @context)
331
+ raise
332
+ end
207
333
  end
208
334
 
209
335
  def acquire_semaphore
@@ -212,8 +338,14 @@ module RubyReactor
212
338
  limit = config[:limit]
213
339
 
214
340
  semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
215
- semaphore.acquire
216
- @acquired_semaphore = semaphore
341
+ begin
342
+ semaphore.acquire
343
+ @acquired_semaphore = semaphore
344
+ middlewares.on(:semaphore_acquired, key, limit, @context)
345
+ rescue RubyReactor::Semaphore::AcquisitionError => e
346
+ middlewares.on(:semaphore_failed, key, limit, e, @context)
347
+ raise
348
+ end
217
349
  end
218
350
 
219
351
  # Inside a Sidekiq worker we'd rather snooze the job via perform_in than
@@ -226,13 +358,19 @@ module RubyReactor
226
358
  end
227
359
 
228
360
  def release_locks
229
- release_one("semaphore", @acquired_semaphore) if @acquired_semaphore
361
+ if @acquired_semaphore
362
+ key = @acquired_semaphore.key
363
+ release_one("semaphore", @acquired_semaphore)
364
+ middlewares.on(:semaphore_released, key, @context)
365
+ end
230
366
  @acquired_semaphore = nil
231
367
 
232
368
  return unless @acquired_lock
233
369
 
370
+ key = @acquired_lock.key
234
371
  release_one("lock", @acquired_lock)
235
372
  @acquired_lock = nil
373
+ middlewares.on(:lock_released, key, @context)
236
374
  end
237
375
 
238
376
  def release_one(kind, primitive)
@@ -332,4 +470,5 @@ module RubyReactor
332
470
  )
333
471
  end
334
472
  end
473
+ # rubocop:enable Metrics/ClassLength
335
474
  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