Repository navigation
Enforce pool timeouts for map() chunks instead of crashing the TimeoutHandler - #473
akashchamp wants to merge 7 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
| ), (None, None)) | ||
|
|
||
| def on_soft_timeout(self, job): | ||
| def on_soft_timeout(self, job, pid=None): |
There was a problem hiding this comment.
this and some other changes seems to be changing the public API.... arent they? if so I think we have to update documentations and include this changes to a minor release like 4.4.0
There was a problem hiding this comment.
The only signature changes are an optional pid=None on TimeoutHandler.on_soft_timeout/on_hard_timeout and optional soft_timeout/timeout keywords on MapResult.__init__. Existing callers (including Celery's asynpool, which calls them with just job) behave exactly as before, so I don't think anything breaks, and the rest (_running_chunks, _chunks_done) is private. Happy to add a changelog entry, and whether it goes out as 4.4.0 or a patch release is your call.
There was a problem hiding this comment.
The merge conflicts are resolved; the branch now contains current main. I also added a 4.4.0 (unreleased) entry to CHANGES.txt describing the optional pid argument on TimeoutHandler.on_soft_timeout/on_hard_timeout and the optional soft_timeout/timeout keywords on MapResult, pushed in 19178f7. Whether it ships as 4.4.0 or a patch release is your call.
|
@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. |
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.on_soft_timeout/on_hard_timeouttake an optionalpidand still work with justjob, which is how Celery's asynpool calls them.imap()results are skipped since they don't record accept times.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.