Skip to content

Commit ab71675

Browse files
DerekStridecodex
andcommitted
fix(map): stop queued parallel iterations after failure
Schedule bounded parallel map work lazily so queued iterations do not start after an aborting failure. Also preserve the original execution error when final output computation raises during unwind, and keep aborting FailCog exceptions bubbling through system cogs that wrap nested scopes. Agent: pi Co-authored-by: ChatGPT <codex@openai.com>
1 parent 2fac07d commit ab71675

4 files changed

Lines changed: 93 additions & 28 deletions

File tree

lib/roast/cog.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ def run!(barrier, config, input_context, executor_scope_value, executor_scope_in
8787
rescue ControlFlow::FailCog => e
8888
# TODO: do something with the message passed to fail!
8989
@failed = true
90-
# TODO: better / cleaner handling in the workflow execution manager for a workflow failure
91-
# just re-raising this exception for now
92-
raise e if config.abort_on_failure?
90+
# System cogs are wrappers around nested execution scopes. If a nested scope raises an aborting
91+
# FailCog, keep bubbling it instead of converting it into a local, non-aborting system cog failure.
92+
raise e if config.abort_on_failure? || is_a?(SystemCog)
9393
rescue ControlFlow::Next, ControlFlow::Break => e
9494
@skipped = true
9595
raise e

lib/roast/execution_manager.rb

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,26 +92,36 @@ def run!
9292
Sync do |sync_task|
9393
sync_task.annotate("ExecutionManager #{@scope}")
9494
TaskContext.begin_execution_manager(self)
95-
@cog_stack.each do |cog|
96-
cog_config = @config_manager.config_for(cog.class, cog.name)
97-
cog_task = cog.run!(
98-
@barrier,
99-
cog_config.deep_dup,
100-
cog_input_context,
101-
@scope_value.deep_dup,
102-
@scope_index,
103-
)
104-
cog_task.wait unless cog_config.async?
95+
execution_error = nil
96+
begin
97+
@cog_stack.each do |cog|
98+
cog_config = @config_manager.config_for(cog.class, cog.name)
99+
cog_task = cog.run!(
100+
@barrier,
101+
cog_config.deep_dup,
102+
cog_input_context,
103+
@scope_value.deep_dup,
104+
@scope_index,
105+
)
106+
cog_task.wait unless cog_config.async?
107+
end
108+
# Wait on the tasks in their completion order, so that an exception in a task will be raised as soon as it occurs
109+
# noinspection RubyArgCount
110+
@barrier.wait { |task| wait_for_task_with_exception_handling(task) }
111+
compute_final_output # eagerly compute the final output (so it, too, can 'break!' subsequent executions in a loop)
112+
rescue StandardError => e
113+
execution_error = e
114+
raise
115+
ensure
116+
@barrier.stop
117+
begin
118+
compute_final_output
119+
rescue StandardError
120+
raise unless execution_error
121+
end
122+
TaskContext.end
123+
@running = false
105124
end
106-
# Wait on the tasks in their completion order, so that an exception in a task will be raised as soon as it occurs
107-
# noinspection RubyArgCount
108-
@barrier.wait { |task| wait_for_task_with_exception_handling(task) }
109-
compute_final_output # eagerly compute the final output (so it, too, can 'break!' subsequent executions in a loop)
110-
ensure
111-
@barrier.stop
112-
compute_final_output
113-
TaskContext.end
114-
@running = false
115125
end
116126
end
117127

lib/roast/system_cogs/map.rb

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,17 @@ def execute_map_in_series(run, input)
305305
#: (Symbol, Map::Input, Integer?) -> Output
306306
def execute_map_in_parallel(run, input, max_parallel_tasks)
307307
barrier = Async::Barrier.new
308-
semaphore = Async::Semaphore.new(max_parallel_tasks, parent: barrier) if max_parallel_tasks.present?
309-
ems = {}
310-
input.items.map.with_index do |item, index|
311-
(semaphore || barrier).async(finished: false) do |task|
308+
ems = Array.new(input.items.length)
309+
next_index_to_schedule = 0
310+
311+
schedule_iteration = lambda do
312+
next if next_index_to_schedule >= input.items.length
313+
314+
item = input.items[next_index_to_schedule]
315+
index = next_index_to_schedule
316+
next_index_to_schedule += 1
317+
318+
barrier.async(finished: false) do |task|
312319
task.annotate("Map Invocation #{index + input.initial_index}")
313320
ems[index] = em = create_execution_manager_for_map_item(run, item, index + input.initial_index)
314321
em.prepare!
@@ -317,12 +324,16 @@ def execute_map_in_parallel(run, input, max_parallel_tasks)
317324
# TODO: do something with the message passed to next!
318325
# proceed to next iteration
319326
end
320-
end #: Array[Async::Task]
327+
end
328+
329+
initial_parallelism = max_parallel_tasks || input.items.length
330+
[initial_parallelism, input.items.length].min.times { schedule_iteration.call }
321331

322332
# Wait on the tasks in their completion order, so that an exception in a task will be raised as soon as it occurs
323333
# noinspection RubyArgCount
324334
barrier.wait do |task|
325335
task.wait
336+
schedule_iteration.call
326337
rescue ControlFlow::Break
327338
# TODO: do something with the message passed to break!
328339
barrier.stop
@@ -331,7 +342,7 @@ def execute_map_in_parallel(run, input, max_parallel_tasks)
331342
raise e
332343
end
333344

334-
Output.new((0...input.items.length).map { |idx| ems[idx] })
345+
Output.new(ems)
335346
ensure
336347
# noinspection RubyRedundantSafeNavigation
337348
barrier&.stop

test/roast/system_cogs/map_test.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,50 @@ def build_manager(execution_procs, scope: nil, scope_value: nil, scope_index: 0)
395395
assert_equal "B", results[1].value
396396
assert_equal "C", results[2].value
397397
end
398+
399+
test "parallel map aborts pending iterations when an iteration fails and abort_on_failure is enabled" do
400+
started_items = []
401+
402+
config_proc = proc do
403+
map(:my_map) { parallel 2 }
404+
test_cog { abort_on_failure! }
405+
end
406+
config_manager = ConfigManager.new(@registry, [config_proc])
407+
config_manager.prepare!
408+
409+
exec_procs = {
410+
nil => [proc {
411+
map(:my_map, run: :parallel_failure) { ["slow", "fail", "later_1", "later_2"] }
412+
outputs { collect(map(:my_map)) }
413+
}],
414+
parallel_failure: [proc {
415+
test_cog(:step) do |_input, scope, _index|
416+
started_items << scope
417+
418+
case scope
419+
when "slow"
420+
sleep 0.2
421+
scope.upcase
422+
when "fail"
423+
raise ControlFlow::FailCog, "boom"
424+
else
425+
scope.upcase
426+
end
427+
end
428+
}],
429+
}
430+
manager = ExecutionManager.new(
431+
@registry, config_manager, exec_procs, @workflow_context
432+
)
433+
manager.prepare!
434+
435+
error = assert_raises(ControlFlow::FailCog) do
436+
manager.run!
437+
end
438+
439+
assert_equal "boom", error.message
440+
assert_equal ["fail", "slow"], started_items.sort
441+
end
398442
end
399443
end
400444
end

0 commit comments

Comments
 (0)