ruby_reactor 0.3.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 +31 -4
- 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 +55 -4
- 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 +8 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
- 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 +16 -15
- data/lib/ruby_reactor/map/element_executor.rb +90 -104
- data/lib/ruby_reactor/map/execution.rb +2 -1
- data/lib/ruby_reactor/map/helpers.rb +2 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
- 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} +10 -5
- 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 +11 -18
- 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 +9 -3
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
|
+
class TestSubject
|
|
7
|
+
include ::RSpec::Mocks::ExampleMethods
|
|
8
|
+
|
|
9
|
+
attr_reader :reactor_instance, :run_result
|
|
10
|
+
|
|
11
|
+
def initialize(reactor_class:, inputs:, context: {}, async: nil, process_jobs: true)
|
|
12
|
+
@reactor_class = reactor_class
|
|
13
|
+
@inputs = inputs
|
|
14
|
+
@context_data = context
|
|
15
|
+
@async = async
|
|
16
|
+
@process_jobs = process_jobs
|
|
17
|
+
@interceptors = []
|
|
18
|
+
@executed = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- Configuration DSL ---
|
|
22
|
+
|
|
23
|
+
def failing_at(step_name, *nested_steps, element_index: nil, &block)
|
|
24
|
+
@interceptors << {
|
|
25
|
+
type: :failure,
|
|
26
|
+
step_path: [step_name, *nested_steps],
|
|
27
|
+
conditions: { element_index: element_index, block: block }
|
|
28
|
+
}
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Intercept a step and provide a custom implementation
|
|
33
|
+
#
|
|
34
|
+
# @param step_name [Symbol, String] The name of the step to intercept
|
|
35
|
+
# @param nested_steps [Array<Symbol, String>] Path to nested steps if applicable
|
|
36
|
+
# @param element_index [Integer] Optional index for map steps
|
|
37
|
+
# @yield [args, context, original_impl] block to execute
|
|
38
|
+
# @yieldparam args [Hash] The arguments passed to the step
|
|
39
|
+
# @yieldparam context [RubyReactor::Context] The execution context
|
|
40
|
+
# @yieldparam original_impl [Proc] A proc that can be called to execute the original implementation:
|
|
41
|
+
# original_impl.call(args, context)
|
|
42
|
+
def mock_step(step_name, *nested_steps, element_index: nil, &block)
|
|
43
|
+
@interceptors << {
|
|
44
|
+
type: :mock,
|
|
45
|
+
step_path: [step_name, *nested_steps],
|
|
46
|
+
conditions: { element_index: element_index, block: block }
|
|
47
|
+
}
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fluent API for mocking nested map steps
|
|
52
|
+
# @example
|
|
53
|
+
# reactor.map(:my_map).mock_step(:inner_step) { ... }
|
|
54
|
+
def map(step_name)
|
|
55
|
+
StepProxy.new(self, step_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fluent API for mocking nested compose steps
|
|
59
|
+
# @example
|
|
60
|
+
# reactor.compose(:my_sub_reactor).mock_step(:inner_step) { ... }
|
|
61
|
+
def composed(step_name)
|
|
62
|
+
# If already executed, return the traversed subject
|
|
63
|
+
return traverse_composed(step_name) if @executed
|
|
64
|
+
|
|
65
|
+
# Otherwise return a configuration proxy
|
|
66
|
+
StepProxy.new(self, step_name)
|
|
67
|
+
end
|
|
68
|
+
alias compose composed
|
|
69
|
+
|
|
70
|
+
# Proxy class for fluent mocking configuration
|
|
71
|
+
class StepProxy
|
|
72
|
+
def initialize(subject, step_name)
|
|
73
|
+
@subject = subject
|
|
74
|
+
@step_name = step_name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mock_step(inner_step_name, *nested_steps, &block)
|
|
78
|
+
@subject.mock_step(@step_name, inner_step_name, *nested_steps, &block)
|
|
79
|
+
@subject # Return subject to allow chaining or calling run
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Support deep nesting?
|
|
83
|
+
def map(inner_step_name)
|
|
84
|
+
StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def composed(inner_step_name)
|
|
88
|
+
StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- Traversal ---
|
|
93
|
+
|
|
94
|
+
def map_elements(step_name)
|
|
95
|
+
ensure_executed!
|
|
96
|
+
|
|
97
|
+
# Check composed_contexts
|
|
98
|
+
entry = @reactor_instance.context.composed_contexts[step_name] ||
|
|
99
|
+
@reactor_instance.context.composed_contexts[step_name.to_s] ||
|
|
100
|
+
@reactor_instance.context.composed_contexts[step_name.to_sym]
|
|
101
|
+
|
|
102
|
+
return [] unless entry && entry[:type] == :map_ref
|
|
103
|
+
|
|
104
|
+
map_id = entry[:map_id]
|
|
105
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
106
|
+
|
|
107
|
+
# This requires the storage adapter to implement retrieval of map element context IDs
|
|
108
|
+
# If it's not implemented in MemoryAdapter (if used), we might need to fallback?
|
|
109
|
+
# But tests use Redis.
|
|
110
|
+
child_ids = storage.retrieve_map_element_context_ids(map_id, @reactor_instance.class.name)
|
|
111
|
+
|
|
112
|
+
child_ids.map do |id|
|
|
113
|
+
klass = RubyReactor::Context.resolve_reactor_class(entry[:element_reactor_class])
|
|
114
|
+
child_instance = klass.find(id)
|
|
115
|
+
self.class.new(
|
|
116
|
+
reactor_class: child_instance.class,
|
|
117
|
+
inputs: child_instance.context.inputs,
|
|
118
|
+
context: child_instance.context,
|
|
119
|
+
async: @async,
|
|
120
|
+
process_jobs: @process_jobs
|
|
121
|
+
).tap do |s|
|
|
122
|
+
s.instance_variable_set(:@executed, true)
|
|
123
|
+
s.instance_variable_set(:@reactor_instance, child_instance)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def map_element(step_name, index: 0)
|
|
129
|
+
elements = map_elements(step_name)
|
|
130
|
+
elements[index]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def traverse_composed(step_name)
|
|
136
|
+
ensure_executed!
|
|
137
|
+
|
|
138
|
+
entry = @reactor_instance.context.composed_contexts[step_name] ||
|
|
139
|
+
@reactor_instance.context.composed_contexts[step_name.to_s] ||
|
|
140
|
+
@reactor_instance.context.composed_contexts[step_name.to_sym]
|
|
141
|
+
|
|
142
|
+
unless entry && entry[:type] == :composed
|
|
143
|
+
# Try to find failed attempt if validation failure?
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
child_context = entry[:context]
|
|
148
|
+
child_instance = child_context.reactor_class.new(child_context)
|
|
149
|
+
|
|
150
|
+
self.class.new(
|
|
151
|
+
reactor_class: child_instance.class,
|
|
152
|
+
inputs: child_instance.context.inputs,
|
|
153
|
+
context: child_instance.context,
|
|
154
|
+
async: @async,
|
|
155
|
+
process_jobs: @process_jobs
|
|
156
|
+
).tap do |s|
|
|
157
|
+
s.instance_variable_set(:@executed, true)
|
|
158
|
+
s.instance_variable_set(:@reactor_instance, child_instance)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
public
|
|
163
|
+
|
|
164
|
+
def run_async(boolean)
|
|
165
|
+
@async = boolean
|
|
166
|
+
self
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# --- Execution ---
|
|
170
|
+
|
|
171
|
+
def run
|
|
172
|
+
return self if @executed
|
|
173
|
+
|
|
174
|
+
# 1. Apply Interceptors (Dynamic Subclassing)
|
|
175
|
+
execution_class = prepare_execution_class
|
|
176
|
+
|
|
177
|
+
# 2. Capture Context ID
|
|
178
|
+
captured_context_id = nil
|
|
179
|
+
|
|
180
|
+
allow(RubyReactor::Context).to receive(:new).and_wrap_original do |m, *args|
|
|
181
|
+
ctx = m.call(*args)
|
|
182
|
+
captured_context_id ||= ctx.context_id
|
|
183
|
+
ctx
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 3. Native Run
|
|
187
|
+
if @async == false
|
|
188
|
+
allow(execution_class).to receive(:async?).and_return(false)
|
|
189
|
+
elsif @async == true
|
|
190
|
+
allow(execution_class).to receive(:async?).and_return(true)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@run_result = nil
|
|
194
|
+
if @process_jobs && defined?(Sidekiq::Testing)
|
|
195
|
+
# Ensure SidekiqAdapter is used to capture jobs in fake mode
|
|
196
|
+
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::SidekiqAdapter)
|
|
197
|
+
|
|
198
|
+
# Avoid nesting error which happens in Sidekiq 7+ if a mode is already set
|
|
199
|
+
begin
|
|
200
|
+
Sidekiq::Testing.fake! do
|
|
201
|
+
@run_result = execution_class.run(@inputs)
|
|
202
|
+
end
|
|
203
|
+
rescue Sidekiq::Testing::TestModeAlreadySetError
|
|
204
|
+
@run_result = execution_class.run(@inputs)
|
|
205
|
+
end
|
|
206
|
+
else
|
|
207
|
+
@run_result = execution_class.run(@inputs)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# 4. Reload
|
|
211
|
+
raise "Could not capture context ID during execution" unless captured_context_id
|
|
212
|
+
|
|
213
|
+
# Reload using the execution class (which might be the mocked subclass with unique name)
|
|
214
|
+
@reactor_instance = execution_class.find(captured_context_id)
|
|
215
|
+
# Update our reference to the class so future reloads (e.g. in result introspection) work
|
|
216
|
+
@reactor_class = execution_class
|
|
217
|
+
@executed = true
|
|
218
|
+
self
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- Introspection (Auto-Run) ---
|
|
222
|
+
|
|
223
|
+
def result
|
|
224
|
+
ensure_executed!
|
|
225
|
+
|
|
226
|
+
ctx = @reactor_instance.context
|
|
227
|
+
status = ctx.status.to_s
|
|
228
|
+
case status
|
|
229
|
+
when "failed"
|
|
230
|
+
return ctx.failure_reason if ctx.failure_reason.is_a?(RubyReactor::Failure)
|
|
231
|
+
|
|
232
|
+
RubyReactor::Failure.new(ctx.failure_reason || {})
|
|
233
|
+
when "completed"
|
|
234
|
+
# Determine the success value
|
|
235
|
+
val = if @reactor_class.return_step
|
|
236
|
+
ctx.intermediate_results[@reactor_class.return_step.to_sym]
|
|
237
|
+
else
|
|
238
|
+
# Return result of the last executed step
|
|
239
|
+
# Execution trace contains: { step: name, ... }
|
|
240
|
+
# Trace does not strictly contain result, so we look up in intermediate_results
|
|
241
|
+
last_trace = ctx.execution_trace.last
|
|
242
|
+
if last_trace
|
|
243
|
+
step_name = last_trace[:step]
|
|
244
|
+
# Handle symbol/string mismatch
|
|
245
|
+
ctx.intermediate_results[step_name.to_sym] || ctx.intermediate_results[step_name.to_s]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
RubyReactor::Success.new(val)
|
|
249
|
+
when "running"
|
|
250
|
+
# Try to determine if it is truly running or if we just missed the completion
|
|
251
|
+
if @process_jobs && defined?(Sidekiq::Testing)
|
|
252
|
+
# Force one more check
|
|
253
|
+
process_pending_jobs
|
|
254
|
+
# Reload status
|
|
255
|
+
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
256
|
+
return result unless @reactor_instance.context.status.to_s == "running"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# If still running, return a Pending/Running result instead of nil
|
|
260
|
+
# This allows matchers to report "expected success but was running"
|
|
261
|
+
RubyReactor::Failure("Reactor is still running (Async operations pending?)",
|
|
262
|
+
retryable: true)
|
|
263
|
+
when "paused"
|
|
264
|
+
RubyReactor::InterruptResult.new(
|
|
265
|
+
execution_id: ctx.context_id,
|
|
266
|
+
intermediate_results: ctx.intermediate_results
|
|
267
|
+
# We assume no error if paused normally
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def success?
|
|
273
|
+
ensure_executed!
|
|
274
|
+
@reactor_instance.context.status.to_s == "completed"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def failure?
|
|
278
|
+
ensure_executed!
|
|
279
|
+
@reactor_instance.context.status.to_s == "failed"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# --- Interrupt Test Helpers ---
|
|
283
|
+
|
|
284
|
+
# Check if the reactor is paused at an interrupt
|
|
285
|
+
#
|
|
286
|
+
# @return [Boolean] true if the reactor is in paused state
|
|
287
|
+
def paused?
|
|
288
|
+
ensure_executed!
|
|
289
|
+
@reactor_instance.context.status.to_s == "paused"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Get the current step where the reactor is paused (interrupt step)
|
|
293
|
+
# Note: When multiple interrupts are ready, this returns just one of them.
|
|
294
|
+
# Use `ready_interrupt_steps` to get all ready interrupt steps.
|
|
295
|
+
#
|
|
296
|
+
# @return [Symbol, nil] the name of the current interrupt step, or nil if not paused
|
|
297
|
+
def current_step
|
|
298
|
+
ensure_executed!
|
|
299
|
+
step = @reactor_instance.context.current_step
|
|
300
|
+
step&.to_sym
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Get all ready interrupt steps (steps that can be resumed)
|
|
304
|
+
# This is useful when multiple interrupts are waiting concurrently.
|
|
305
|
+
#
|
|
306
|
+
# @return [Array<Symbol>] list of ready interrupt step names
|
|
307
|
+
def ready_interrupt_steps
|
|
308
|
+
ensure_executed!
|
|
309
|
+
return [] unless paused?
|
|
310
|
+
|
|
311
|
+
# Build the dependency graph and get ready steps
|
|
312
|
+
graph = RubyReactor::DependencyGraph.new
|
|
313
|
+
graph_manager = RubyReactor::Executor::GraphManager.new(
|
|
314
|
+
@reactor_class, graph, @reactor_instance.context
|
|
315
|
+
)
|
|
316
|
+
graph_manager.build_and_validate!
|
|
317
|
+
graph_manager.mark_completed_steps_from_context
|
|
318
|
+
|
|
319
|
+
# Filter to only interrupt steps (using interrupt? predicate method)
|
|
320
|
+
ready = graph_manager.dependency_graph.ready_steps
|
|
321
|
+
ready.select { |step_config| step_config.respond_to?(:interrupt?) && step_config.interrupt? }
|
|
322
|
+
.map { |step_config| step_config.name.to_sym }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Resume a paused reactor with the given payload
|
|
326
|
+
#
|
|
327
|
+
# @param payload [Hash] The data to provide to the interrupt step
|
|
328
|
+
# @param step [Symbol, String, nil] The specific interrupt step to resume.
|
|
329
|
+
# Required when multiple interrupts are ready. If not provided and only
|
|
330
|
+
# one interrupt is ready, that step will be used.
|
|
331
|
+
# @return [TestSubject] self for chaining and introspection
|
|
332
|
+
# @raise [Error::ValidationError] if the reactor is not paused, step is ambiguous, or payload is invalid
|
|
333
|
+
def resume(payload: {}, step: nil)
|
|
334
|
+
ensure_executed!
|
|
335
|
+
|
|
336
|
+
unless paused?
|
|
337
|
+
raise RubyReactor::Error::ValidationError,
|
|
338
|
+
"Cannot resume: reactor is not paused (status: #{@reactor_instance.context.status})"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
step_name = determine_resume_step(step)
|
|
342
|
+
|
|
343
|
+
# Use the reactor's continue method
|
|
344
|
+
@reactor_instance.continue(payload: payload, step_name: step_name)
|
|
345
|
+
|
|
346
|
+
# Process any pending async jobs
|
|
347
|
+
process_pending_jobs if @process_jobs && defined?(Sidekiq::Testing)
|
|
348
|
+
|
|
349
|
+
# Reload the reactor instance to get updated state
|
|
350
|
+
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
351
|
+
|
|
352
|
+
# Return self for chaining and introspection
|
|
353
|
+
self
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
def determine_resume_step(step)
|
|
359
|
+
ready_steps = ready_interrupt_steps
|
|
360
|
+
|
|
361
|
+
if step
|
|
362
|
+
# User explicitly specified a step
|
|
363
|
+
step_sym = step.to_sym
|
|
364
|
+
unless ready_steps.include?(step_sym)
|
|
365
|
+
raise RubyReactor::Error::ValidationError,
|
|
366
|
+
"Cannot resume: step :#{step} is not ready. Ready steps: #{ready_steps.inspect}"
|
|
367
|
+
end
|
|
368
|
+
step_sym
|
|
369
|
+
elsif ready_steps.size == 1
|
|
370
|
+
# Only one step ready, use it
|
|
371
|
+
ready_steps.first
|
|
372
|
+
elsif ready_steps.size > 1
|
|
373
|
+
# Multiple steps ready, step is required
|
|
374
|
+
raise RubyReactor::Error::ValidationError,
|
|
375
|
+
"Cannot resume: multiple interrupt steps are ready (#{ready_steps.inspect}). " \
|
|
376
|
+
"Please specify which step to resume using: resume(step: :step_name, payload: {...})"
|
|
377
|
+
else
|
|
378
|
+
# Fallback to current_step (shouldn't happen normally)
|
|
379
|
+
current_step || raise(RubyReactor::Error::ValidationError, "Cannot resume: no ready interrupt steps found")
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
public
|
|
384
|
+
|
|
385
|
+
def step_result(name)
|
|
386
|
+
ensure_executed!
|
|
387
|
+
# Prefer intermediate_results as it is the data store
|
|
388
|
+
# Logic to handle symbol vs string mismatch
|
|
389
|
+
key_sym = name.to_sym
|
|
390
|
+
key_str = name.to_s
|
|
391
|
+
|
|
392
|
+
if @reactor_instance.context.intermediate_results.key?(key_sym)
|
|
393
|
+
@reactor_instance.context.intermediate_results[key_sym]
|
|
394
|
+
elsif @reactor_instance.context.intermediate_results.key?(key_str)
|
|
395
|
+
@reactor_instance.context.intermediate_results[key_str]
|
|
396
|
+
else
|
|
397
|
+
# Fallback to execution trace if available (e.g. for inspection)
|
|
398
|
+
entry = @reactor_instance.context.execution_trace.find { |t| t[:step].to_s == key_str }
|
|
399
|
+
entry ? entry[:result] : nil
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def error
|
|
404
|
+
res = result
|
|
405
|
+
res.respond_to?(:error) ? res.error : nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def ensure_executed!
|
|
409
|
+
run unless @executed
|
|
410
|
+
|
|
411
|
+
# Process jobs if status is running and processing is enabled
|
|
412
|
+
return unless @process_jobs && @reactor_instance.context.status.to_s == "running"
|
|
413
|
+
|
|
414
|
+
process_pending_jobs
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
private
|
|
418
|
+
|
|
419
|
+
def process_pending_jobs
|
|
420
|
+
return unless defined?(Sidekiq::Testing)
|
|
421
|
+
|
|
422
|
+
# Loop until no more jobs are being queued
|
|
423
|
+
# This handles batched map execution where jobs queue more jobs
|
|
424
|
+
max_iterations = 100
|
|
425
|
+
iterations = 0
|
|
426
|
+
|
|
427
|
+
while iterations < max_iterations
|
|
428
|
+
iterations += 1
|
|
429
|
+
jobs_processed = false
|
|
430
|
+
|
|
431
|
+
# Known worker classes to check
|
|
432
|
+
worker_classes = [
|
|
433
|
+
RubyReactor::SidekiqWorkers::Worker,
|
|
434
|
+
RubyReactor::SidekiqWorkers::MapElementWorker,
|
|
435
|
+
RubyReactor::SidekiqWorkers::MapExecutionWorker,
|
|
436
|
+
RubyReactor::SidekiqWorkers::MapCollectorWorker
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
worker_classes.each do |worker_class|
|
|
440
|
+
while worker_class.jobs.any?
|
|
441
|
+
job = worker_class.jobs.shift
|
|
442
|
+
worker_class.new.perform(*job["args"])
|
|
443
|
+
jobs_processed = true
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
break unless jobs_processed
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Final reload
|
|
451
|
+
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def prepare_execution_class
|
|
455
|
+
# Even if no interceptors, we might need to subclass to override async steps
|
|
456
|
+
return @reactor_class if @interceptors.empty? && @async != false
|
|
457
|
+
|
|
458
|
+
interceptors = @interceptors
|
|
459
|
+
force_sync = @async == false
|
|
460
|
+
|
|
461
|
+
execution_class = Class.new(@reactor_class) do
|
|
462
|
+
# 1. Copy configuration from parent
|
|
463
|
+
@steps = superclass.steps.dup
|
|
464
|
+
@inputs = superclass.inputs.dup
|
|
465
|
+
@input_validations = superclass.input_validations.dup
|
|
466
|
+
@middlewares = superclass.middlewares.dup
|
|
467
|
+
@return_step = superclass.return_step
|
|
468
|
+
@async = superclass.async?
|
|
469
|
+
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
470
|
+
|
|
471
|
+
# 2. Add Name Handling with Unique Registry Entry
|
|
472
|
+
# We must register a unique name so that if this reactor is reloaded (e.g. after async child completion),
|
|
473
|
+
# it resolves back to THIS mocked class, not the original superclass.
|
|
474
|
+
unique_name = "#{superclass.name}Mock#{object_id}"
|
|
475
|
+
define_singleton_method(:name) { unique_name }
|
|
476
|
+
RubyReactor::Registry.register(unique_name, self)
|
|
477
|
+
|
|
478
|
+
# 3. Apply Force Sync (Disable async on all steps)
|
|
479
|
+
if force_sync
|
|
480
|
+
@steps.each do |name, config|
|
|
481
|
+
next unless config.async?
|
|
482
|
+
|
|
483
|
+
# Clone and modify
|
|
484
|
+
new_config = config.clone
|
|
485
|
+
new_config.instance_variable_set(:@async, false)
|
|
486
|
+
@steps[name] = new_config
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# 4. Apply Interceptors
|
|
492
|
+
apply_interceptors(execution_class, interceptors)
|
|
493
|
+
|
|
494
|
+
execution_class
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def apply_interceptors(klass, interceptors)
|
|
498
|
+
# Group interceptors by the current level step
|
|
499
|
+
grouped = interceptors.group_by { |i| i[:step_path].first }
|
|
500
|
+
|
|
501
|
+
grouped.each do |target_step, step_interceptors|
|
|
502
|
+
step_config_orig = klass.steps[target_step]
|
|
503
|
+
|
|
504
|
+
unless step_config_orig
|
|
505
|
+
# Maybe it's a map step? We can't easily intercept inner steps from here
|
|
506
|
+
next
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Create a new StepConfig
|
|
510
|
+
step_config = step_config_orig.clone
|
|
511
|
+
|
|
512
|
+
# Check if we have nested interceptors
|
|
513
|
+
nested_interceptors = step_interceptors.select { |i| i[:step_path].size > 1 }
|
|
514
|
+
|
|
515
|
+
if nested_interceptors.any?
|
|
516
|
+
apply_nested_interceptors(step_config, nested_interceptors)
|
|
517
|
+
step_config.instance_variable_set(:@async, false)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Apply direct interceptors (mocks/failures on this step)
|
|
521
|
+
direct_interceptors = step_interceptors.select { |i| i[:step_path].size == 1 }
|
|
522
|
+
direct_interceptors.each do |interceptor|
|
|
523
|
+
case interceptor[:type]
|
|
524
|
+
when :failure
|
|
525
|
+
apply_failure_interceptor(step_config, target_step)
|
|
526
|
+
when :mock
|
|
527
|
+
apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
klass.steps[target_step] = step_config
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def apply_nested_interceptors(step_config, interceptors)
|
|
536
|
+
# Determine if it's a map step or compose step based on arguments
|
|
537
|
+
# Map steps have :mapped_reactor_class in arguments
|
|
538
|
+
# Compose steps (ComposeStep logic) should also have it in arguments (passed via DSL builder)
|
|
539
|
+
|
|
540
|
+
args = step_config.arguments
|
|
541
|
+
target_reactor_class_source = nil
|
|
542
|
+
arg_key = nil
|
|
543
|
+
|
|
544
|
+
if args[:mapped_reactor_class]
|
|
545
|
+
target_reactor_class_source = args[:mapped_reactor_class][:source]
|
|
546
|
+
arg_key = :mapped_reactor_class
|
|
547
|
+
elsif args[:composed_reactor_class]
|
|
548
|
+
target_reactor_class_source = args[:composed_reactor_class][:source]
|
|
549
|
+
arg_key = :composed_reactor_class
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
return unless target_reactor_class_source.is_a?(RubyReactor::Template::Value)
|
|
553
|
+
|
|
554
|
+
original_child_reactor = target_reactor_class_source.value
|
|
555
|
+
|
|
556
|
+
# Dynamically subclass the child reactor
|
|
557
|
+
mocked_child_reactor = Class.new(original_child_reactor) do
|
|
558
|
+
define_singleton_method(:name) { original_child_reactor.name }
|
|
559
|
+
# Copy configuration
|
|
560
|
+
@steps = superclass.steps.dup
|
|
561
|
+
@inputs = superclass.inputs.dup
|
|
562
|
+
@input_validations = superclass.input_validations.dup
|
|
563
|
+
@middlewares = superclass.middlewares.dup
|
|
564
|
+
@return_step = superclass.return_step
|
|
565
|
+
@async = superclass.async?
|
|
566
|
+
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Recursively apply interceptors to the child reactor
|
|
570
|
+
# Shift path: [:map_step, :inner, :deep] -> [:inner, :deep]
|
|
571
|
+
child_interceptors = interceptors.map do |i|
|
|
572
|
+
i.merge(step_path: i[:step_path].drop(1))
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
apply_interceptors(mocked_child_reactor, child_interceptors)
|
|
576
|
+
|
|
577
|
+
# Replace the argument source with the mocked class
|
|
578
|
+
# We need to clone arguments hash to avoid mutating original config globally if shared?
|
|
579
|
+
# StepConfig arguments are usually unique per config instance (we cloned step_config)
|
|
580
|
+
# BUT arguments hash is shared references. We must dup it.
|
|
581
|
+
|
|
582
|
+
current_args = step_config.arguments
|
|
583
|
+
step_config.instance_variable_set(:@arguments, current_args.dup)
|
|
584
|
+
|
|
585
|
+
step_config.arguments[arg_key] = step_config.arguments[arg_key].dup if step_config.arguments[arg_key]
|
|
586
|
+
|
|
587
|
+
step_config.arguments[arg_key][:source] = RubyReactor::Template::Value.new(mocked_child_reactor)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def apply_failure_interceptor(step_config, target_step)
|
|
591
|
+
failure_impl = lambda do |_input, _context|
|
|
592
|
+
RubyReactor::Failure("Simulated failure at #{target_step}")
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
step_config.instance_variable_set(:@run_block, failure_impl)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
599
|
+
mock_block = interceptor[:conditions][:block]
|
|
600
|
+
|
|
601
|
+
# Prepare original implementation call
|
|
602
|
+
original_impl = if step_config_orig.has_run_block?
|
|
603
|
+
step_config_orig.run_block
|
|
604
|
+
elsif step_config_orig.has_impl?
|
|
605
|
+
->(args, ctx) { step_config_orig.impl.run(args, ctx) }
|
|
606
|
+
else
|
|
607
|
+
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Create the new implementation that wraps the user block
|
|
611
|
+
wrapper_impl = lambda do |args, context|
|
|
612
|
+
if mock_block.arity == 3
|
|
613
|
+
mock_block.call(args, context, original_impl)
|
|
614
|
+
else
|
|
615
|
+
mock_block.call(args, context)
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
step_config.instance_variable_set(:@run_block, wrapper_impl)
|
|
620
|
+
step_config.instance_variable_set(:@async, false)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
# rubocop:enable Metrics/ClassLength
|
|
624
|
+
end
|
|
625
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rspec/helpers"
|
|
4
|
+
require_relative "rspec/matchers"
|
|
5
|
+
require_relative "rspec/test_subject"
|
|
6
|
+
|
|
7
|
+
module RubyReactor
|
|
8
|
+
module RSpec
|
|
9
|
+
def self.configure(config)
|
|
10
|
+
require_relative "rspec/step_executor_patch"
|
|
11
|
+
|
|
12
|
+
config.include RubyReactor::RSpec::Helpers
|
|
13
|
+
config.include RubyReactor::RSpec::Matchers
|
|
14
|
+
|
|
15
|
+
::RubyReactor::Executor::StepExecutor.prepend(RubyReactor::RSpec::StepExecutorPatch)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
|
-
class
|
|
4
|
+
class SidekiqAdapter
|
|
5
5
|
def self.perform_async(serialized_context, reactor_class_name = nil, intermediate_results: {})
|
|
6
6
|
job_id = SidekiqWorkers::Worker.perform_async(serialized_context, reactor_class_name)
|
|
7
7
|
context = ContextSerializer.deserialize(serialized_context)
|
|
@@ -20,7 +20,7 @@ module RubyReactor
|
|
|
20
20
|
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
21
21
|
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
22
22
|
batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
23
|
-
RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
23
|
+
job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
24
24
|
{
|
|
25
25
|
"map_id" => map_id,
|
|
26
26
|
"element_id" => element_id,
|
|
@@ -36,6 +36,7 @@ module RubyReactor
|
|
|
36
36
|
"fail_fast" => fail_fast
|
|
37
37
|
}
|
|
38
38
|
)
|
|
39
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
@@ -63,7 +64,7 @@ module RubyReactor
|
|
|
63
64
|
# rubocop:disable Metrics/ParameterLists
|
|
64
65
|
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
65
66
|
strict_ordering:, timeout:)
|
|
66
|
-
RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
67
|
+
job_id = RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
67
68
|
{
|
|
68
69
|
"parent_context_id" => parent_context_id,
|
|
69
70
|
"map_id" => map_id,
|
|
@@ -73,12 +74,15 @@ module RubyReactor
|
|
|
73
74
|
"timeout" => timeout
|
|
74
75
|
}
|
|
75
76
|
)
|
|
77
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
76
78
|
end
|
|
77
|
-
# rubocop:enable Metrics/ParameterLists
|
|
78
79
|
|
|
80
|
+
# rubocop:enable Metrics/ParameterLists
|
|
81
|
+
# rubocop:disable Metrics/ParameterLists
|
|
79
82
|
def self.perform_map_execution_async(map_id:, serialized_inputs:, reactor_class_info:, strict_ordering:,
|
|
80
83
|
parent_context_id:, parent_reactor_class_name:, step_name:, fail_fast: nil)
|
|
81
|
-
|
|
84
|
+
# rubocop:enable Metrics/ParameterLists
|
|
85
|
+
job_id = RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
|
|
82
86
|
{
|
|
83
87
|
"map_id" => map_id,
|
|
84
88
|
"serialized_inputs" => serialized_inputs,
|
|
@@ -90,6 +94,7 @@ module RubyReactor
|
|
|
90
94
|
"fail_fast" => fail_fast
|
|
91
95
|
}
|
|
92
96
|
)
|
|
97
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
end
|