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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +14 -0
- data/README.md +153 -18
- data/lib/ruby_reactor/configuration.rb +13 -1
- data/lib/ruby_reactor/context.rb +2 -1
- data/lib/ruby_reactor/context_serializer.rb +1 -3
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +19 -28
- data/lib/ruby_reactor/dsl/reactor.rb +44 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
- data/lib/ruby_reactor/executor/step_executor.rb +46 -23
- data/lib/ruby_reactor/executor.rb +188 -49
- data/lib/ruby_reactor/map/collector.rb +4 -4
- data/lib/ruby_reactor/map/element_executor.rb +15 -1
- data/lib/ruby_reactor/map/helpers.rb +17 -4
- data/lib/ruby_reactor/middleware.rb +13 -0
- data/lib/ruby_reactor/middleware_runner.rb +29 -0
- data/lib/ruby_reactor/open_telemetry.rb +647 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
- data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
- data/lib/ruby_reactor/step/map_step.rb +25 -33
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
- data/teley/Dockerfile +60 -0
- metadata +6 -3
- data/lib/ruby_reactor/map/execution.rb +0 -101
- 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
|
|
42
|
-
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
mark_period_on_success(@result)
|
|
148
|
+
acquire_concurrency_primitives
|
|
95
149
|
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
153
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
165
|
-
# must not skip itself when its own
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
#
|
|
79
|
-
|
|
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
|