fix(fully_async): per-task timeout + exception-safe requeue in rollout worker - #1012
fix(fully_async): per-task timeout + exception-safe requeue in rollout worker#1012Shi-Dong wants to merge 1 commit into
Conversation
…t worker AsyncRolloutWorker.continuous_worker_loop had two bugs that together caused rollout-level soft-deadlock once a single trial went pathological: 1. No per-task timeout. A group-task awaiting a stuck HTTP call to the agent server / SGLang held its concurrency slot indefinitely; with enough pathological trials the worker could no longer dispatch new groups. 2. Silent group-loss. generate_and_rm_group uses asyncio.gather without return_exceptions=True, so any sub-task raise propagated and the worker's bare `except Exception: print` dropped the group instead of returning it to the data buffer. This change introduces a single choke point _run_group_with_timeout that wraps the per-group coroutine with asyncio.wait_for and a full exception guard. On timeout or any exception the group's samples are marked Sample.Status.ABORTED and returned normally, letting the existing aborted-requeue path in generate_rollout_async re-add them to the data buffer. CancelledError is propagated (after marking) so shutdown still works. The wrapper is now the sole contract between the worker loop and the generation coroutine. A new CLI arg --rollout-group-timeout-s (default 1800s) exposes the bound. Existing runs get the 1800s default, which is strictly better than the prior infinite behavior. None or <= 0 disables the time bound while preserving exception safety. Tests: tests/fast/rollout/test_fully_async_rollout_timeout.py covers (a) normal completion, (b) timeout path, (c) direct exception, (d) the exact production failure mode of a sub-task exception propagating out of asyncio.gather, (e) disabled-timeout still catches exceptions, (f) 0.0 timeout disables bound, (g) cancellation propagates after marking.
There was a problem hiding this comment.
Code Review
This pull request introduces a per-task timeout and exception-handling wrapper for the fully async rollout worker to prevent hung tasks from occupying concurrency slots and to ensure groups are not silently dropped on failure. Feedback was provided regarding the handling of asyncio.CancelledError, which currently propagates and could cause the main worker loop to terminate unexpectedly because it is not caught by the Exception block. Additionally, a correction was suggested for the command-line argument help text, as the float type constraint prevents users from passing None directly via the CLI.
| except asyncio.CancelledError: | ||
| # Propagate cancellation (e.g. worker shutdown) without silencing it, | ||
| # but first mark samples so any caller that catches can requeue. | ||
| for s in group: | ||
| s.status = Sample.Status.ABORTED | ||
| raise |
There was a problem hiding this comment.
Re-raising asyncio.CancelledError here will cause the task.result() call in the continuous_worker_loop (line 169) and the task_done_callback (line 202) to raise the exception. Since CancelledError inherits from BaseException in Python 3.8+, it is not caught by the except Exception block (line 170), which will lead to the unexpected termination of the worker loop. Furthermore, if the exception propagates, the group is never put into the output_queue, causing the collector to lose those samples and potentially hang while waiting for the target batch size. To maintain the "exception-safe" contract and prevent group loss, it is recommended to catch the cancellation, mark the samples as ABORTED, and return the group normally. When resetting these samples for retry, ensure all metadata fields (e.g., remove_sample, train_metadata) are cleared to their default state to prevent carrying over stale data. Note that you should also update the corresponding test case test_cancellation_propagates_after_marking in tests/fast/rollout/test_fully_async_rollout_timeout.py to reflect this change.
except asyncio.CancelledError:
logger.warning(
"Group task was cancelled; marking %d samples ABORTED for requeue",
len(group),
)
for s in group:
s.status = Sample.Status.ABORTED
s.remove_sample = False
s.train_metadata = {}
return groupReferences
- When implementing a function to reset Sample objects for retry, ensure all generated outputs and metadata fields (e.g., remove_sample, train_metadata) are cleared to their default state to prevent carrying over stale data from previous attempts.
| "async worker. A group that exceeds this deadline has its samples marked " | ||
| "Sample.Status.ABORTED and is returned to the data buffer for retry via the " | ||
| "existing aborted-requeue path, preventing stuck trials from owning a " | ||
| "concurrency slot forever. Set to None (or <= 0) to disable." |
There was a problem hiding this comment.
The help text suggests setting the value to None to disable the timeout. However, because the argument type is set to float, argparse will raise a ValueError if a user attempts to pass the string "None" via the command line. It is clearer to instruct users to use a non-positive value (e.g., 0) to disable the time bound.
| "concurrency slot forever. Set to None (or <= 0) to disable." | |
| "concurrency slot forever. Set to <= 0 to disable." |
Summary
Fixes two compounding bugs in
AsyncRolloutWorker.continuous_worker_loop(
examples/fully_async/fully_async_rollout.py) that together caused rollout-level soft-deadlock once a single trial went pathological. Observed live on
GLM-4.7-Flash async agentic RL Run 4 rollout 3, where 11 long-tail SWE-bench
trials (agent step counts 60-148 vs. normal 20-40) held 11/32 concurrency
slots for ~7.5 hours and would have done so indefinitely. Full forensic
write-up lives in the memory-bank entity "Run 4 rollout-3 stall — full
forensic diagnosis".
Before
asyncio.create_task(generate_and_rm_group(...))was never wrapped in
asyncio.wait_for. A group-task awaiting a stuckHTTP call held its slot forever. Line 73
while len(active_tasks) < max_concurrent_taskscould never dispatch new groups once enough slotswere pathological.
generate_and_rm_groupusesasyncio.gatherwithout
return_exceptions=True, so one sub-task raise propagates. Theworker's lines 67-69
task.result() / except Exception: printswallowedit and did not call
data_buffer.add_samples— the group vanished andthe outer collector's
while len(data) < 32never recovered.After
Both paths funnel through a new module-level helper
_run_group_with_timeoutthat wraps the per-group coroutine with
asyncio.wait_forand a fullexception guard. Its contract: always returns the group, never raises.
On timeout or any exception, every sample in the group is marked
Sample.Status.ABORTEDand returned normally, so the existing aborted-requeue path in
generate_rollout_async(re-adds todata_bufferviaadd_samples) handles recovery.CancelledErroris still propagated (aftermarking) so worker shutdown works correctly.
On why
return_exceptions=Trueingenerate_and_rm_groupwas not chosenTwo reasons:
generate_and_rm_groupis on a broader code path than fully-async andchanging its gather semantics risks regressions in consumers that rely on
exceptions escaping (e.g.
abort()atsglang_rollout.py:334orchestratesits own cancellation).
lines. A single choke point is cleaner than changes in two files.
CLI
New arg
--rollout-group-timeout-s(default 1800.0 seconds). Existingruns that omit it get the 1800s default, which is strictly better than the
prior infinite behavior. Set to
None(or<= 0) to disable the timebound; exception safety still applies.
Test plan
tests/fast/rollout/test_fully_async_rollout_timeout.py:ABORTED.ABORTED,not silently dropped.
asyncio.gather(exactproduction failure mode) — group returned as
ABORTED.timeout_s=Nonedisables the bound but keeps exception safety.timeout_s=0.0disables the bound.CancelledErrorpropagates after samples are marked.miles dep stack is heavy; the pytest file uses the same logic).
pytest tests/fast/rollout/test_fully_async_rollout_timeout.pyin a full miles env before merge.
expectation for your longest-running rollout shape.
Notes