Repository navigation
Enforce pool timeouts for map() chunks instead of crashing the TimeoutHandler - #473
akashchamp wants to merge 12 commits into
Conversation
MapResult stores one accept time and worker pid per item, so with a pool timeout or soft_timeout the TimeoutHandler tried to add the timeout to a list and crashed, taking the whole process down with it. imap() results crashed it the same way since they have no accept time. Time each running map() chunk on its own, the way a single apply_async() task is timed, and skip imap() results. Fixes celery#385, celery#412
|
@GangEunzzang can you review this? |
|
The crash fix works. On Two things I ran into:
with Pool(2, timeout=1) as p:
r = p.map_async(time.sleep, [25] * 4, chunksize=1)
r.get() # TimeLimitExceeded after ~1s
p.apply_async(time.sleep, (0.1,)).get() # returns after ~26sThe same four sleeps sent with
with Pool(2, timeout=20) as p:
b = p.map_async(time.sleep, [0.1, 14], chunksize=1)
time.sleep(0.5)
p.apply_async(time.sleep, (30,), timeout=1) # lands on the worker that ran b's first chunk
b.get() # WorkerLostErrorNot new ( def worker_pids(self):
return [pid for _, _, pid in self._running_chunks() if pid]On the API question: the new arguments are optional and Celery calls both methods with just |
|
I tried to fix the merge conflicts, lets see if that works in the CI |
|
@akashchamp can sou please check the last review? |
|
@LuisFigueroaG can you please cross check this one? |
Once one chunk hit the hard limit and failed the map, the other chunks that had also run out of time were skipped because the map was already ready, so their workers kept running. Kill those workers as well. MapResult.worker_pids() listed the workers of finished chunks too, so killing a worker failed any other map that had run a chunk on it earlier. Report only the workers of chunks that are still running.
|
Thanks for the careful review. Pushed 5da5112: worker_pids() now returns only the workers of running chunks, which fixes your second case, and the other chunks that have also timed out are now killed after the first one fails the map. One gap remains: chunks still queued when the map fails are not timed once a worker picks them up, so your first example still delays the following task. That needs the failed map to stay tracked by the TimeoutHandler, so I left it. On the subclass point: the handler now always passes pid, so an override taking only job would fail, and that is worth a line in the release notes. |
MehrazRumman
left a comment
There was a problem hiding this comment.
Checked at 5da5112 on macOS (Python 3.14, spawn). pytest t/unit: 89 passed, 53 skipped. CI is green.
I ran the same scripts on main and on this branch:
| Scenario | main | this PR |
|---|---|---|
Pool(2, timeout=2).map(time.sleep, [0.1, 6]) |
TimeoutHandler crashes with TypeError, process exits 1 |
TimeLimitExceeded after ~4s |
same with soft_timeout=2 |
crashes, exit 1 | SoftTimeLimitExceeded after ~4s |
Pool(2, timeout=1).map(time.sleep, [0.4] * 8, chunksize=1) |
crashes, exit 1 | returns in 2s, so the limit applies per chunk |
@GangEunzzang's second case (stale pid → WorkerLostError) |
crashes, exit 1 | returns [None, None] |
| @GangEunzzang's first case (queued chunks after the map fails) | crashes, exit 1 | the next task waits ~8.8s |
The crash fix, the per-chunk timing and the worker_pids() change all work. Reading the chunk indexing: _ack() fills every item of a chunk, so reading _time_accepted[start] / _worker_pid[start] in _running_chunks() is right.
Keeping the old call signature for apply_async() tasks
On @auvipy's API question: the handler now always calls on_soft_timeout(job, pid) / on_hard_timeout(job, pid), so a TimeoutHandler subclass that overrides them with (self, job) breaks for plain apply_async() tasks too, even though those worked before. Passing pid only for map chunks avoids that. A map() with a timeout crashed before anyway, so only the new code path needs the new argument:
else:
tasks = [(i, job._time_accepted, None)]
for key, ack_time, pid in tasks:
extra = () if pid is None else (pid,)
if _timed_out(ack_time, hard_timeout):
on_hard_timeout(job, *extra)
elif key not in dirty and _timed_out(ack_time,
soft_timeout):
on_soft_timeout(job, *extra)
dirty.add(key)With that, the only change to the public API is the optional soft_timeout/timeout keywords on MapResult.__init__.
Smaller points
- Please add a
CHANGES.txtentry. Besides the fix, it should say that the pooltimeout/soft_timeoutnow apply to eachmap()/starmap()chunk, and thatimap()/imap_unordered()results are still not timed. Today they don't crash, but they silently ignore the pool timeouts. - The queued-chunk gap is fine to leave out of this PR, but it would be good to open an issue for it so it isn't lost: once a map fails, its chunks that are still queued run without a time limit.
- The description says flake8 shows nothing new, but
t/unit/test_pool.pygets three new warnings: W293 on the whitespace-only line aftertest_map_hard_timeout(line 151), and two E501 lines (80 and 85 characters).
|
@akashchamp The worker_pids() change fixes the second case, but killing the other timed-out chunks can now kill an unrelated task. The pid is read before the first worker is killed, so if the second chunk finishes in the meantime, its worker has already picked up the next queued task and that task fails with |
When one map() chunk hit the hard limit, the workers of the other timed-out chunks were killed from a pid read before the first kill. If one of those chunks finished in the meantime, its worker could already be running the next queued task, which then failed with WorkerLostError. Check right before the kill that no other job in the cache is running on that worker.
|
Thanks, you were right. Pushed 9a36281: before killing the worker of another timed-out chunk, the handler now checks that no other job in the cache is running on it, and a regression test covers the case where that worker has already moved on to another task. The check can only see what the result handler has already processed, so it narrows the window rather than closing it completely. |
@akashchamp was this addressed? |
|
@GangEunzzang @MehrazRumman can you check if this pr is fixing the same problem #481 ? |
GangEunzzang
left a comment
There was a problem hiding this comment.
@auvipy #481 is a different fix, it only rejects chunksize < 1 in imap(). Merging both only conflicts on the 4.4.0 entry in CHANGES.txt.
@akashchamp the crash fix holds up on Linux and macOS, but I think a few things still need fixing.
The pid suggestion from the last review isn't in yet. A TimeoutHandler subclass with on_hard_timeout(self, job) now crashes the process on a plain apply_async() timeout, which works on main. I couldn't find a real subclass like that, but passing pid only for map chunks avoids it.
_runs_other_job can also be fooled by imap(). IMapIterator.worker_pids() keeps workers that already finished, so with a live imap() a timed-out chunk's worker gets spared and the chunk runs past its limit:
p = Pool(3, timeout=3)
it = p.imap(work, [0.1, 20], chunksize=1) # work sleeps, then prints
time.sleep(0.5)
p.map_async(work, [12, 12, 12], chunksize=1).get() # fails after ~4s, one 12s chunk still finishesWithout the imap() all three chunks are killed.
Once a map fails, its running chunks lose their limit too, not just the queued ones:
p = Pool(2, timeout=3)
r = p.map_async(work, [1.5, 12, 12], chunksize=1)
r.get() # TimeLimitExceeded after 3-4s, but the third chunk still finishes at ~13.5sSame thing when a soft limit fails the map: a chunk still cleaning up never hits its hard limit. apply_async() kills both. When a timeout fails the map main crashes, so it's not a regression, and I'd be fine with this as a follow-up as long as the issue says running chunks too. A quick prototype that keeps failed maps tracked fixed the running and queued cases, but it needs _ack and _join_exited_workers changes.
The per-chunk soft path also has no test. With the old dirty filter a chunk gets repeated soft signals, and with dirty keyed per job later chunks get no soft limit at all, and t/unit/test_pool.py passes both ways.
The TimeoutHandler called on_soft_timeout(job, pid) and on_hard_timeout(job, pid) for every job, so a subclass that overrides them with only job broke on a plain apply_async() timeout, which works on main. Pass the pid only for map() chunks, the new code path, and call the callbacks with just job otherwise.
IMapIterator.worker_pids() keeps every worker that ever ran one of its tasks, so with a live imap() the TimeoutHandler took a worker that had finished an imap() task for one still busy with it, and spared it. The timed-out map() chunk on that worker then ran past its limit. Track which imap() tasks are still running and check only those. worker_pids() is unchanged.
With the TimeoutHandler no longer crashing on imap() results, a hard timeout that kills a worker while an imap() is still running reaches _join_exited_workers, which reads job._write_to and job._scheduled_for. IMapIterator has neither, so the Supervisor thread crashed with AttributeError and the process exited. Give IMapIterator the same None defaults as ApplyResult.
Check that every running chunk gets exactly one soft timeout signal, including a chunk that starts after the others were signalled. The test fails if the dirty set drops the (job, chunk) keys on each pass, and if it is keyed per job.
|
Not until now, sorry: as of 2037a38 only map chunks get |
Fixes #385 and #412.
With
timeoutorsoft_timeoutset on the pool,map()/starmap()crash the TimeoutHandler thread:MapResultkeeps one accept time and worker pid per item, so the handler ends up doinglist + int. The thread crash callsos._exit(1), so the whole process dies.imap()hits the same thing with anAttributeErrorbecauseIMapIteratorhas no_time_acceptedat all.This times each running
map()chunk on its own, the same way a singleapply_async()task is timed: a soft timeout signals the worker running that chunk, and a hard timeout fails the map withTimeLimitExceededand kills that worker. Finished chunks stop counting, so the timeout applies per chunk, not to the whole map. Only map chunks pass the workerpidtoon_soft_timeout/on_hard_timeout; plainapply_async()tasks still call them with justjob, so existing overrides keep working.imap()results are skipped since they don't record accept times, and a worker exit while animap()is running no longer crashes the Supervisor thread.Verification (Linux, Python 3.12):
Pool(2, timeout=2).map(time.sleep, [0.1, 6])loggedThread 'TimeoutHandler' crashed: TypeError('can only concatenate list (not "int") to list')and the interpreter exited with status 1;imapdied the same way with theAttributeError.timeout=2raisesTimeLimitExceeded(2)after about 4s,soft_timeout=2raisesSoftTimeLimitExceeded, andimapreturns its results.MapResult(one finished chunk, one running) and anIMapIteratorin the cache, plus an end-to-endmap()hard-timeout test.pytest t/unit: 77 passed, 51 skipped. flake8 shows nothing new on the touched files; codespell is clean.pytest t/unit: 108 passed, 53 skipped. Added tests for aTimeoutHandlersubclass whose callbacks take onlyjob, the soft timeout of each map chunk, a finishedimap()task on a timed-out chunk's worker, and a hard timeout while animap()is running.