ruby_reactor 0.2.0 → 0.3.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/README.md +132 -0
- data/Rakefile +2 -2
- data/documentation/data_pipelines.md +90 -84
- data/documentation/testing.md +812 -0
- data/lib/ruby_reactor/configuration.rb +1 -1
- data/lib/ruby_reactor/context.rb +13 -5
- data/lib/ruby_reactor/context_serializer.rb +70 -4
- data/lib/ruby_reactor/dsl/map_builder.rb +6 -2
- data/lib/ruby_reactor/dsl/reactor.rb +3 -2
- data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
- data/lib/ruby_reactor/executor/result_handler.rb +9 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +26 -8
- data/lib/ruby_reactor/executor/step_executor.rb +24 -99
- data/lib/ruby_reactor/executor.rb +3 -13
- data/lib/ruby_reactor/map/collector.rb +72 -33
- data/lib/ruby_reactor/map/dispatcher.rb +162 -0
- data/lib/ruby_reactor/map/element_executor.rb +103 -114
- data/lib/ruby_reactor/map/execution.rb +18 -4
- data/lib/ruby_reactor/map/helpers.rb +4 -3
- data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
- data/lib/ruby_reactor/reactor.rb +174 -16
- data/lib/ruby_reactor/rspec/helpers.rb +17 -0
- data/lib/ruby_reactor/rspec/matchers.rb +256 -0
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
- data/lib/ruby_reactor/rspec.rb +18 -0
- data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +15 -10
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -3
- data/lib/ruby_reactor/step/compose_step.rb +0 -1
- data/lib/ruby_reactor/step/map_step.rb +52 -27
- data/lib/ruby_reactor/storage/redis_adapter.rb +59 -0
- data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +32 -24
- data/lib/ruby_reactor.rb +70 -10
- metadata +12 -3
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
5
|
class Reactor
|
|
5
6
|
include RubyReactor::Dsl::Reactor
|
|
6
7
|
|
|
@@ -64,34 +65,71 @@ module RubyReactor
|
|
|
64
65
|
def initialize(context = {})
|
|
65
66
|
@context = context
|
|
66
67
|
@result = :unexecuted
|
|
67
|
-
|
|
68
|
-
@
|
|
68
|
+
|
|
69
|
+
if @context.is_a?(Context)
|
|
70
|
+
@execution_trace = @context.execution_trace || []
|
|
71
|
+
@undo_trace = @execution_trace.select { |e| e[:type] == :undo }
|
|
72
|
+
@result = reconstruct_result
|
|
73
|
+
else
|
|
74
|
+
@undo_trace = []
|
|
75
|
+
@execution_trace = []
|
|
76
|
+
end
|
|
69
77
|
end
|
|
70
78
|
|
|
79
|
+
# rubocop:disable Metrics/MethodLength
|
|
71
80
|
def run(inputs = {})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
# For all reactors, initialize context first to capture execution ID
|
|
82
|
+
@context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
|
|
83
|
+
|
|
84
|
+
# Validate inputs
|
|
85
|
+
validation_result = self.class.validate_inputs(inputs)
|
|
86
|
+
if validation_result.failure?
|
|
87
|
+
@result = validation_result
|
|
88
|
+
@context.status = "failed"
|
|
89
|
+
@context.failure_reason = {
|
|
90
|
+
message: validation_result.error.message,
|
|
91
|
+
validation_errors: validation_result.error.field_errors
|
|
92
|
+
}
|
|
93
|
+
save_context
|
|
94
|
+
return validation_result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if self.class.async? && !@context.inline_async_execution
|
|
98
|
+
# For async reactors, queue a job for the whole reactor
|
|
99
|
+
@context.status = :running
|
|
100
|
+
save_context
|
|
101
|
+
|
|
102
|
+
serialized_context = ContextSerializer.serialize(@context)
|
|
103
|
+
@result = configuration.async_router.perform_async(serialized_context, self.class.name,
|
|
104
|
+
intermediate_results: @context.intermediate_results)
|
|
105
|
+
|
|
106
|
+
# Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
107
|
+
# Check storage to see if it's already finished or paused (interrupted).
|
|
108
|
+
begin
|
|
109
|
+
reloaded = self.class.find(@context.context_id)
|
|
110
|
+
if reloaded.finished? || reloaded.context.status.to_s == "paused"
|
|
111
|
+
@context = reloaded.context
|
|
112
|
+
@result = reloaded.result
|
|
113
|
+
@execution_trace = reloaded.execution_trace
|
|
114
|
+
@undo_trace = reloaded.undo_trace
|
|
115
|
+
return @result
|
|
116
|
+
end
|
|
117
|
+
rescue StandardError
|
|
118
|
+
# Ignore if not found or other errors during reload check
|
|
119
|
+
end
|
|
120
|
+
|
|
77
121
|
else
|
|
78
122
|
# For sync reactors (potentially with async steps), execute normally
|
|
79
123
|
context = @context.is_a?(Context) ? @context : nil
|
|
80
124
|
executor = Executor.new(self.class, inputs, context)
|
|
81
125
|
@result = executor.execute
|
|
82
|
-
|
|
83
126
|
@context = executor.context
|
|
84
|
-
|
|
85
|
-
# Merge traces
|
|
86
|
-
@undo_trace = executor.undo_trace
|
|
87
127
|
@execution_trace = executor.execution_trace
|
|
88
|
-
|
|
89
|
-
# If execution returned an AsyncResult (from step-level async), return it
|
|
90
|
-
return @result if @result.is_a?(RubyReactor::AsyncResult)
|
|
91
|
-
|
|
92
|
-
@result
|
|
128
|
+
@undo_trace = executor.undo_trace
|
|
93
129
|
end
|
|
130
|
+
@result
|
|
94
131
|
end
|
|
132
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
95
133
|
|
|
96
134
|
def continue(payload:, step_name:, idempotency_key: nil)
|
|
97
135
|
_ = idempotency_key
|
|
@@ -178,6 +216,125 @@ module RubyReactor
|
|
|
178
216
|
raise Error::DependencyError, "Dependency graph contains cycles"
|
|
179
217
|
end
|
|
180
218
|
|
|
219
|
+
def reconstruct_result
|
|
220
|
+
case @context.status.to_s
|
|
221
|
+
when "completed" then reconstruct_success_result
|
|
222
|
+
when "failed" then reconstruct_failure_result
|
|
223
|
+
when "paused" then reconstruct_paused_result
|
|
224
|
+
else :unexecuted
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def reconstruct_success_result
|
|
229
|
+
rs = self.class.respond_to?(:returns) ? self.class.returns : nil
|
|
230
|
+
val = if rs
|
|
231
|
+
@context.intermediate_results[rs.to_sym] || @context.intermediate_results[rs.to_s]
|
|
232
|
+
else
|
|
233
|
+
find_last_step_result
|
|
234
|
+
end
|
|
235
|
+
Success.new(val)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def find_last_step_result
|
|
239
|
+
last_run = @execution_trace.reverse.find { |e| e[:type] == :run || e["type"] == "run" }
|
|
240
|
+
return unless last_run
|
|
241
|
+
|
|
242
|
+
step_name = last_run[:step] || last_run["step"]
|
|
243
|
+
@context.intermediate_results[step_name.to_sym] || @context.intermediate_results[step_name.to_s]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def reconstruct_failure_result
|
|
247
|
+
reason = @context.failure_reason || {}
|
|
248
|
+
return reason if reason.is_a?(RubyReactor::Failure)
|
|
249
|
+
|
|
250
|
+
# Use string keys preferred, fallback to symbol
|
|
251
|
+
r = ->(k) { reason[k.to_s] || reason[k.to_sym] }
|
|
252
|
+
|
|
253
|
+
Failure.new(
|
|
254
|
+
r[:message],
|
|
255
|
+
step_name: r[:step_name],
|
|
256
|
+
inputs: r[:inputs] || {},
|
|
257
|
+
backtrace: r[:backtrace],
|
|
258
|
+
reactor_name: r[:reactor_name],
|
|
259
|
+
step_arguments: r[:step_arguments] || {},
|
|
260
|
+
exception_class: r[:exception_class],
|
|
261
|
+
file_path: r[:file_path],
|
|
262
|
+
line_number: r[:line_number],
|
|
263
|
+
code_snippet: r[:code_snippet],
|
|
264
|
+
validation_errors: r[:validation_errors],
|
|
265
|
+
retryable: r[:retryable],
|
|
266
|
+
invalid_payload: r[:invalid_payload]
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def reconstruct_paused_result
|
|
271
|
+
InterruptResult.new(
|
|
272
|
+
execution_id: @context.context_id,
|
|
273
|
+
intermediate_results: @context.intermediate_results
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def initialize_and_validate_run?(inputs)
|
|
278
|
+
# For all reactors, initialize context first to capture execution ID
|
|
279
|
+
@context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
|
|
280
|
+
|
|
281
|
+
validation_result = self.class.validate_inputs(inputs)
|
|
282
|
+
if validation_result.failure?
|
|
283
|
+
handle_validation_failure(validation_result)
|
|
284
|
+
return false
|
|
285
|
+
end
|
|
286
|
+
true
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def handle_validation_failure(result)
|
|
290
|
+
@result = result
|
|
291
|
+
@context.status = "failed"
|
|
292
|
+
@context.failure_reason = {
|
|
293
|
+
message: result.error.message,
|
|
294
|
+
validation_errors: result.error.field_errors
|
|
295
|
+
}
|
|
296
|
+
save_context
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def perform_async_run
|
|
300
|
+
@context.status = :running
|
|
301
|
+
save_context
|
|
302
|
+
|
|
303
|
+
serialized_context = ContextSerializer.serialize(@context)
|
|
304
|
+
@result = configuration.async_router.perform_async(serialized_context, self.class.name,
|
|
305
|
+
intermediate_results: @context.intermediate_results)
|
|
306
|
+
|
|
307
|
+
check_for_inline_completion
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def check_for_inline_completion
|
|
311
|
+
# Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
312
|
+
# Check storage to see if it's already finished or paused (interrupted).
|
|
313
|
+
reloaded = self.class.find(@context.context_id)
|
|
314
|
+
if reloaded.finished? || reloaded.context.status.to_s == "paused"
|
|
315
|
+
update_state_from_reloaded(reloaded)
|
|
316
|
+
@result
|
|
317
|
+
end
|
|
318
|
+
rescue StandardError
|
|
319
|
+
# Ignore if not found or other errors during reload check
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def update_state_from_reloaded(reloaded)
|
|
323
|
+
@context = reloaded.context
|
|
324
|
+
@result = reloaded.result
|
|
325
|
+
@execution_trace = reloaded.execution_trace
|
|
326
|
+
@undo_trace = reloaded.undo_trace
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def perform_sync_run(inputs)
|
|
330
|
+
context = @context.is_a?(Context) ? @context : nil
|
|
331
|
+
executor = Executor.new(self.class, inputs, context)
|
|
332
|
+
@result = executor.execute
|
|
333
|
+
@context = executor.context
|
|
334
|
+
@execution_trace = executor.execution_trace
|
|
335
|
+
@undo_trace = executor.undo_trace
|
|
336
|
+
end
|
|
337
|
+
|
|
181
338
|
def validate_continue_step!(step_name)
|
|
182
339
|
return if step_name.to_s == @context.current_step.to_s
|
|
183
340
|
|
|
@@ -258,4 +415,5 @@ module RubyReactor
|
|
|
258
415
|
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
259
416
|
end
|
|
260
417
|
end
|
|
418
|
+
# rubocop:enable Metrics/ClassLength
|
|
261
419
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
module Helpers
|
|
6
|
+
def test_reactor(reactor_class, inputs, context: {}, async: nil, process_jobs: true)
|
|
7
|
+
TestSubject.new(
|
|
8
|
+
reactor_class: reactor_class,
|
|
9
|
+
inputs: inputs,
|
|
10
|
+
context: context,
|
|
11
|
+
async: async,
|
|
12
|
+
process_jobs: process_jobs
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# rubocop:disable Metrics/BlockLength
|
|
7
|
+
::RSpec::Matchers.define :be_success do
|
|
8
|
+
match do |subject|
|
|
9
|
+
subject.ensure_executed!
|
|
10
|
+
subject.success?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
failure_message do |subject|
|
|
14
|
+
result = subject.result
|
|
15
|
+
if result&.failure?
|
|
16
|
+
format_failure_message(result)
|
|
17
|
+
else
|
|
18
|
+
"expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_failure_message(error)
|
|
23
|
+
# Safely extract values
|
|
24
|
+
err_msg = error.respond_to?(:error) ? error.error.to_s : error.to_s
|
|
25
|
+
ex_class = error.respond_to?(:exception_class) ? error.exception_class : nil
|
|
26
|
+
step = error.respond_to?(:step_name) ? error.step_name : nil
|
|
27
|
+
file = error.respond_to?(:file_path) ? error.file_path : nil
|
|
28
|
+
line = error.respond_to?(:line_number) ? error.line_number : nil
|
|
29
|
+
snippet = error.respond_to?(:code_snippet) ? error.code_snippet : nil
|
|
30
|
+
backtrace = error.respond_to?(:backtrace) ? error.backtrace : nil
|
|
31
|
+
|
|
32
|
+
lines = []
|
|
33
|
+
lines << "Error: #{ex_class || "UnknownError"}"
|
|
34
|
+
lines << err_msg.to_s
|
|
35
|
+
lines << "Step: :#{step}" if step
|
|
36
|
+
lines << "File: #{file}:#{line}" if file
|
|
37
|
+
|
|
38
|
+
append_snippet(lines, snippet)
|
|
39
|
+
append_backtrace(lines, backtrace)
|
|
40
|
+
|
|
41
|
+
lines.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def append_snippet(lines, snippet)
|
|
45
|
+
return unless snippet.is_a?(Array) && !snippet.empty?
|
|
46
|
+
|
|
47
|
+
lines << ""
|
|
48
|
+
snippet.each do |s|
|
|
49
|
+
prefix = s[:target] ? "--> " : " "
|
|
50
|
+
lines << "#{prefix}#{s[:content]}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append_backtrace(lines, backtrace)
|
|
55
|
+
return unless backtrace && !backtrace.empty?
|
|
56
|
+
|
|
57
|
+
lines << ""
|
|
58
|
+
lines << "Backtrace:"
|
|
59
|
+
lines << backtrace.take(10).map { |l| "- #{l}" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
::RSpec::Matchers.define :be_failure do
|
|
64
|
+
match do |subject|
|
|
65
|
+
subject.ensure_executed!
|
|
66
|
+
subject.failure?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
failure_message do |_subject|
|
|
70
|
+
"expected reactor to be failure, but succeeded"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
::RSpec::Matchers.define :have_run_step do |step_name|
|
|
75
|
+
match do |subject|
|
|
76
|
+
subject.ensure_executed!
|
|
77
|
+
@trace = subject.reactor_instance.context.execution_trace
|
|
78
|
+
@entry = @trace.find { |t| t[:step].to_s == step_name.to_s }
|
|
79
|
+
|
|
80
|
+
return false unless @entry
|
|
81
|
+
|
|
82
|
+
matches_result?(subject, step_name) && matches_order?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def matches_result?(subject, step_name)
|
|
86
|
+
return true unless @check_result
|
|
87
|
+
|
|
88
|
+
actual_result = subject.step_result(step_name)
|
|
89
|
+
if @expected_result.is_a?(Regexp)
|
|
90
|
+
actual_result.to_s.match?(@expected_result)
|
|
91
|
+
else
|
|
92
|
+
values_match?(@expected_result, actual_result)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def matches_order?
|
|
97
|
+
return true unless @after_step
|
|
98
|
+
|
|
99
|
+
after_index = @trace.index(@entry)
|
|
100
|
+
before_entry = @trace.find { |t| t[:step].to_s == @after_step.to_s }
|
|
101
|
+
|
|
102
|
+
return false unless before_entry
|
|
103
|
+
|
|
104
|
+
after_index > @trace.index(before_entry)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
chain :returning do |value|
|
|
108
|
+
@check_result = true
|
|
109
|
+
@expected_result = value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
chain :after do |step|
|
|
113
|
+
@after_step = step
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
failure_message do |subject|
|
|
117
|
+
msg = "expected reactor to have run step :#{step_name}"
|
|
118
|
+
if @check_result
|
|
119
|
+
actual_result = subject.step_result(step_name)
|
|
120
|
+
msg += " returning #{@expected_result.inspect}, but returned #{actual_result.inspect}"
|
|
121
|
+
end
|
|
122
|
+
msg += " after :#{@after_step}" if @after_step
|
|
123
|
+
msg
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
::RSpec::Matchers.define :have_retried_step do |step_name|
|
|
128
|
+
match do |subject|
|
|
129
|
+
subject.ensure_executed!
|
|
130
|
+
attempts = subject.reactor_instance.context.retry_context.attempts_for_step(step_name)
|
|
131
|
+
retries = attempts - 1
|
|
132
|
+
|
|
133
|
+
if @expected_retries
|
|
134
|
+
retries == @expected_retries
|
|
135
|
+
else
|
|
136
|
+
retries.positive?
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
chain :times do |count|
|
|
141
|
+
@expected_retries = count
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
failure_message do |_subject|
|
|
145
|
+
msg = "expected reactor to have retried step :#{step_name}"
|
|
146
|
+
msg += " #{@expected_retries} times" if @expected_retries
|
|
147
|
+
msg
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
::RSpec::Matchers.define :have_validation_error do |field|
|
|
152
|
+
match do |subject|
|
|
153
|
+
subject.ensure_executed!
|
|
154
|
+
return false unless subject.failure?
|
|
155
|
+
|
|
156
|
+
# Try to get validation errors from failure reason
|
|
157
|
+
reason = subject.reactor_instance.context.failure_reason || {}
|
|
158
|
+
|
|
159
|
+
# If failure is InputValidationError, it might be serialized differently
|
|
160
|
+
# Or stored in validation_errors key
|
|
161
|
+
errors = reason["validation_errors"] || reason[:validation_errors]
|
|
162
|
+
|
|
163
|
+
if errors
|
|
164
|
+
errors.key?(field.to_s) || errors.key?(field.to_sym)
|
|
165
|
+
else
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
failure_message do |_subject|
|
|
171
|
+
"expected reactor to have validation error on :#{field}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Matcher to check if reactor is paused at an interrupt
|
|
176
|
+
::RSpec::Matchers.define :be_paused do
|
|
177
|
+
match do |subject|
|
|
178
|
+
subject.ensure_executed!
|
|
179
|
+
subject.paused?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
failure_message do |subject|
|
|
183
|
+
"expected reactor to be paused, but status was #{subject.reactor_instance.context.status}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
failure_message_when_negated do |_subject|
|
|
187
|
+
"expected reactor not to be paused, but it is"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Matcher to check if reactor is paused at a specific interrupt step
|
|
192
|
+
# Works with both single and multiple concurrent interrupts
|
|
193
|
+
::RSpec::Matchers.define :be_paused_at do |*step_names|
|
|
194
|
+
match do |subject|
|
|
195
|
+
subject.ensure_executed!
|
|
196
|
+
return false unless subject.paused?
|
|
197
|
+
|
|
198
|
+
ready_steps = subject.ready_interrupt_steps
|
|
199
|
+
step_names.all? { |name| ready_steps.include?(name.to_sym) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
failure_message do |subject|
|
|
203
|
+
if subject.paused?
|
|
204
|
+
ready_steps = subject.ready_interrupt_steps
|
|
205
|
+
if step_names.size == 1
|
|
206
|
+
"expected reactor to be paused at :#{step_names.first}, " \
|
|
207
|
+
"but ready interrupt steps are: #{ready_steps.inspect}"
|
|
208
|
+
else
|
|
209
|
+
"expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
|
|
210
|
+
"but ready interrupt steps are: #{ready_steps.inspect}"
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
"expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
|
|
214
|
+
"but status was #{subject.reactor_instance.context.status}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
failure_message_when_negated do |_subject|
|
|
219
|
+
"expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Matcher to check the exact set of ready interrupt steps
|
|
224
|
+
::RSpec::Matchers.define :have_ready_interrupts do |*expected_steps|
|
|
225
|
+
match do |subject|
|
|
226
|
+
subject.ensure_executed!
|
|
227
|
+
return false unless subject.paused?
|
|
228
|
+
|
|
229
|
+
actual_steps = subject.ready_interrupt_steps.sort
|
|
230
|
+
expected = expected_steps.map(&:to_sym).sort
|
|
231
|
+
actual_steps == expected
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
failure_message do |subject|
|
|
235
|
+
if subject.paused?
|
|
236
|
+
actual_steps = subject.ready_interrupt_steps
|
|
237
|
+
"expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
|
|
238
|
+
"but got #{actual_steps.inspect}"
|
|
239
|
+
else
|
|
240
|
+
"expected reactor to be paused with ready interrupt steps, " \
|
|
241
|
+
"but status was #{subject.reactor_instance.context.status}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
failure_message_when_negated do |subject|
|
|
246
|
+
actual_steps = subject.ready_interrupt_steps
|
|
247
|
+
"expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
|
|
248
|
+
"but it was #{actual_steps.inspect}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Add more matchers as per plan
|
|
253
|
+
# rubocop:enable Metrics/BlockLength
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_reactor"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module RSpec
|
|
7
|
+
# Patch StepExecutor to handle inline async execution during tests.
|
|
8
|
+
# This ensures that when a worker runs inline (e.g. Sidekiq::Testing.inline!),
|
|
9
|
+
# the calling executor can pick up the state change immediately and continue,
|
|
10
|
+
# preventing stalled execution in nested async scenarios.
|
|
11
|
+
module StepExecutorPatch
|
|
12
|
+
def execute_step(step_config)
|
|
13
|
+
# 1. Call original implementation
|
|
14
|
+
result = super
|
|
15
|
+
|
|
16
|
+
# 2. Add test-specific logic for inline async execution
|
|
17
|
+
# Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
|
|
18
|
+
if should_check_inline_completion?(result)
|
|
19
|
+
# Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
|
|
20
|
+
refresh_context_from_storage
|
|
21
|
+
|
|
22
|
+
# If the step itself now has a result, it means it ran inline
|
|
23
|
+
if @context.has_result?(step_config.name)
|
|
24
|
+
# If the step failed, we should return failure
|
|
25
|
+
return reconstruct_failure(@context.failure_reason) if @context.failed?
|
|
26
|
+
|
|
27
|
+
return nil # Continue to next step
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# If the overall reactor finished or paused for other reasons (e.g. error in worker)
|
|
31
|
+
if @context.finished? || @context.status.to_s == "paused"
|
|
32
|
+
return reconstruct_failure(@context.failure_reason) if @context.failed?
|
|
33
|
+
|
|
34
|
+
if @context.status.to_s == "paused"
|
|
35
|
+
return RubyReactor::InterruptResult.new(
|
|
36
|
+
execution_id: @context.context_id,
|
|
37
|
+
intermediate_results: @context.intermediate_results
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
return nil # Finished successfully
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Return original result if no intervention needed
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def refresh_context_from_storage
|
|
51
|
+
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
52
|
+
reactor_class_name = @reactor_class.name
|
|
53
|
+
serialized = storage.retrieve_context(@context.context_id, reactor_class_name)
|
|
54
|
+
return unless serialized
|
|
55
|
+
|
|
56
|
+
reloaded_context = Context.deserialize_from_retry(serialized)
|
|
57
|
+
|
|
58
|
+
# Update local context state
|
|
59
|
+
@context.status = reloaded_context.status
|
|
60
|
+
@context.failure_reason = reloaded_context.failure_reason
|
|
61
|
+
@context.cancelled = reloaded_context.cancelled
|
|
62
|
+
@context.cancellation_reason = reloaded_context.cancellation_reason
|
|
63
|
+
@context.intermediate_results.merge!(reloaded_context.intermediate_results)
|
|
64
|
+
@context.execution_trace = reloaded_context.execution_trace
|
|
65
|
+
@context.private_data.merge!(reloaded_context.private_data)
|
|
66
|
+
@context.retry_context = reloaded_context.retry_context
|
|
67
|
+
@context.composed_contexts.merge!(reloaded_context.composed_contexts)
|
|
68
|
+
@context.map_operations.merge!(reloaded_context.map_operations)
|
|
69
|
+
@context.map_metadata = reloaded_context.map_metadata
|
|
70
|
+
|
|
71
|
+
# Also need to mark step as completed in dependency graph
|
|
72
|
+
reloaded_context.intermediate_results.each_key do |step_name|
|
|
73
|
+
@dependency_graph.complete_step(step_name.to_sym)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def should_check_inline_completion?(result)
|
|
78
|
+
return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
|
|
79
|
+
return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
|
|
80
|
+
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|