ruby_reactor 0.4.0 → 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.
- checksums.yaml +4 -4
- data/.release-please-config.json +3 -0
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +15 -0
- data/README.md +8 -2
- data/lib/ruby_reactor/configuration.rb +6 -1
- data/lib/ruby_reactor/context.rb +6 -2
- data/lib/ruby_reactor/context_serializer.rb +44 -2
- data/lib/ruby_reactor/dsl/reactor.rb +16 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
- data/lib/ruby_reactor/executor/result_handler.rb +1 -12
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
- data/lib/ruby_reactor/executor/step_executor.rb +36 -18
- data/lib/ruby_reactor/executor.rb +119 -37
- 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/reactor.rb +12 -4
- data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
- data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
- data/lib/ruby_reactor/step/map_step.rb +25 -33
- data/lib/ruby_reactor/storage/redis_adapter.rb +10 -3
- data/lib/ruby_reactor/storage/redis_locking.rb +17 -0
- data/lib/ruby_reactor/utils/backtrace_location.rb +37 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +68 -8
- data/lib/ruby_reactor/web/coordination_serializer.rb +174 -0
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +21 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +7 -2
- data/teley/Dockerfile +60 -0
- metadata +10 -6
- data/lib/ruby_reactor/map/execution.rb +0 -101
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +0 -15
- data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +0 -19
- data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +0 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
save_context
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
131
|
+
@result = if @context.current_step
|
|
132
|
+
execute_current_step_and_continue
|
|
133
|
+
else
|
|
134
|
+
execute_remaining_steps
|
|
135
|
+
end
|
|
95
136
|
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
@@ -134,13 +186,24 @@ module RubyReactor
|
|
|
134
186
|
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
135
187
|
end
|
|
136
188
|
|
|
189
|
+
def persist_context?
|
|
190
|
+
@context.status.to_s != "pending" ||
|
|
191
|
+
@context.execution_trace.any? ||
|
|
192
|
+
@context.intermediate_results.any?
|
|
193
|
+
end
|
|
194
|
+
|
|
137
195
|
private
|
|
138
196
|
|
|
139
197
|
def acquire_locks
|
|
198
|
+
check_rate_limit
|
|
140
199
|
acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
141
200
|
acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
142
201
|
end
|
|
143
202
|
|
|
203
|
+
def acquire_locks_with_telemetry
|
|
204
|
+
acquire_locks
|
|
205
|
+
end
|
|
206
|
+
|
|
144
207
|
# Consume one slot from each configured rate-limit window. Raises
|
|
145
208
|
# `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
|
|
146
209
|
# hint) if any window is full. Only consulted on initial `execute`; resumes
|
|
@@ -196,8 +259,14 @@ module RubyReactor
|
|
|
196
259
|
wait: contention_wait(config[:wait]),
|
|
197
260
|
auto_extend: config.fetch(:auto_extend, true)
|
|
198
261
|
)
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
270
|
end
|
|
202
271
|
|
|
203
272
|
def acquire_semaphore
|
|
@@ -206,8 +275,14 @@ module RubyReactor
|
|
|
206
275
|
limit = config[:limit]
|
|
207
276
|
|
|
208
277
|
semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
286
|
end
|
|
212
287
|
|
|
213
288
|
# Inside a Sidekiq worker we'd rather snooze the job via perform_in than
|
|
@@ -220,13 +295,19 @@ module RubyReactor
|
|
|
220
295
|
end
|
|
221
296
|
|
|
222
297
|
def release_locks
|
|
223
|
-
|
|
298
|
+
if @acquired_semaphore
|
|
299
|
+
key = @acquired_semaphore.key
|
|
300
|
+
release_one("semaphore", @acquired_semaphore)
|
|
301
|
+
middlewares.on(:semaphore_released, key, @context)
|
|
302
|
+
end
|
|
224
303
|
@acquired_semaphore = nil
|
|
225
304
|
|
|
226
305
|
return unless @acquired_lock
|
|
227
306
|
|
|
307
|
+
key = @acquired_lock.key
|
|
228
308
|
release_one("lock", @acquired_lock)
|
|
229
309
|
@acquired_lock = nil
|
|
310
|
+
middlewares.on(:lock_released, key, @context)
|
|
230
311
|
end
|
|
231
312
|
|
|
232
313
|
def release_one(kind, primitive)
|
|
@@ -326,4 +407,5 @@ module RubyReactor
|
|
|
326
407
|
)
|
|
327
408
|
end
|
|
328
409
|
end
|
|
410
|
+
# rubocop:enable Metrics/ClassLength
|
|
329
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
|
-
|
|
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
|