Repository navigation
Don't fail a task whose SUCCESS was stored before the worker was lost - #10752
Conversation
When the pool child stores SUCCESS and then dies (OOM kill, SIGKILL, a crash in on_success, a task_postrun receiver or process_cleanup), the parent gets WorkerLostError and Request.on_failure calls mark_as_failure unconditionally. That fires the errbacks for a task that succeeded, counts a chord header twice, and with reject_on_worker_lost requeues a task that already ran. Check the stored state first. If it is SUCCESS, acknowledge (acks_late) and return without recording a failure. If the backend can't be read, keep the existing failure handling. Refs celery#10724
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #10752 +/- ##
=======================================
Coverage 90.72% 90.72%
=======================================
Files 153 153
Lines 21549 21571 +22
Branches 2653 2657 +4
=======================================
+ Hits 19551 19571 +20
- Misses 1685 1687 +2
Partials 313 313
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
auvipy
left a comment
There was a problem hiding this comment.
are the change already covered by existing smoke or integration tests?
Keep the SUCCESS-already-stored guard ahead of the new retry_on_worker_lost path so a finished task is not retried or failed.
|
@auvipy the change is covered by unit tests in
There is no separate smoke/integration case for this; reproducing it end-to-end needs a child that stores SUCCESS and then dies before the parent collects the result. I just merged current |
|
@ChristianMurphy can you review this? may be merging this before your one would be good idea |
|
I tested this against db0da7c on Redis, SQLite and On Redis and SQLite it works as described. The errback and On merge order: this merges cleanly with #10920 and #10759. Each of the three fixes a different path, and any order works. Some things to consider:
|
@gyanu2507 please check |
MehrazRumman
left a comment
There was a problem hiding this comment.
Checked at cdc4118. t/unit/worker/test_request.py: 163 passed. ..._after_success_was_stored and ..._after_success_was_stored_acks_late fail on main.
The branch hasn't changed since 78a66bd (2026-10-01) apart from merges from main, so @ChristianMurphy's four points above still apply. I didn't test those again. One more thing, related to his point 2:
The lookup can block the worker's main process
_success_was_stored() calls backend.get_state(), which goes through get_task_meta() → _ensure_retryable(). on_failure runs in the worker's main process for a WorkerLostError, so this is a blocking backend read inside the event loop. With the default settings it fails fast and falls back, as the description says. But with result_backend_always_retry=True, result_backend_max_retries defaults to inf. If the backend is down, the lookup keeps sleeping and retrying (backoff up to 10s) and never reaches the except that is meant to fall back.
I simulated a backend that's down: every read raises ConnectionError, and exception_safe_to_retry() accepts that, as the Redis and database backends do. I counted the retry sleeps:
WorkerLostError for a task with ignore_result=True |
main | this PR |
|---|---|---|
result_backend_always_retry=False (default) |
returns at once | returns at once |
result_backend_always_retry=True |
returns at once, never reads the backend | still retrying after 50 sleeps (~200s), and doesn't stop |
For a task that stores errors, main would also retry when mark_as_failure() stores the failure. But for ignore_result tasks main never touches the backend here, and this PR adds a lookup that can block the event loop indefinitely. That stalls every other task on the worker. It's also a lookup that can never find SUCCESS, because such a task never stores one.
Suggestions:
- Skip the lookup when
self.task.ignore_resultis set. Together with skippingrpc://(point 2), that covers the cases where there's nothing to find. - Make it a single attempt that doesn't go through the retry loop, e.g. read
backend._get_task_meta_for(self.id)inside the existingtry, so a backend that's down falls back right away as the comment says. - Add a test with
result_backend_always_retry=Trueand a backend whose read fails, checking thaton_failurefalls back without sleeping.
…ookup When a child stored SUCCESS then died, send task-succeeded so Flower is not stuck on STARTED. Skip the SUCCESS lookup for ignore_result and rpc:// (always PENDING, and rpc declares a durable reply queue). Add unit coverage for retry_on_worker_lost, rpc, and ignore_result.
|
Addressed @ChristianMurphy's review notes:
Also restored the missing |
auvipy
left a comment
There was a problem hiding this comment.
are the change also covered by existing smoke or integration tests?
@gyanu2507 are this also covered? |
|
I tested 0d81e01 again with a prefork worker on Redis, killing the child right after SUCCESS is stored. The main fix works. I'd change two more things. 1. Put a The new
Sending 2. Check the request's
Using |
Use saferepr for the late task-succeeded payload so large or non-JSON results cannot break the event. Check self.ignore_result so apply_async(ignore_result=...) skips the SUCCESS lookup.
|
Addressed the follow-ups from @ChristianMurphy:
Also merged current |
…c1 (#160) ## TLDR Today the Celery worker self-test pins two outcomes that Celery has since fixed. When the pool child is lost after SUCCESS is stored, or the task's `on_success` hook raises, the test still expects the error callback to fire beside the link. Celery fixed both on `main` (celery/celery#10752 and #10759, for celery/celery#10724), after 5.7.0rc1. With the lock still on 5.6.3, CI is green. The first lock bump to a release with the fixes would turn this suite red, though nothing in the harness was wrong. This PR keeps those two rows only for Celery releases up to 5.7.0rc1. So the table holds on both sides of the fix, and a lock bump no longer breaks it. The rows that still diverge are now linked to their open issues: `died at mark_as_done` to celery/celery#10949 and the refused link to celery/celery#10725. | Celery | `died at task_postrun` | `the task's on_success hook raised` | | --- | --- | --- | | ≤ 5.7.0rc1 (the lock: 5.6.3) | pinned: SUCCESS, `failed: WorkerLostError` + `sent` | pinned: SUCCESS, `failed: ReceiverFailed` + `sent` | | later releases | normal operation: SUCCESS, `sent` | normal operation: SUCCESS, `sent` | The gap text drops "after the result is stored". It now reads "a failure after the link is published, or a refused link, still runs the failure path", which is true on both sides. ## Why a version gate Celery's `main` between those merges and its next release still reports `5.7.0rc1`. Detecting #10759 would mean reading Celery's source, and the suite runs the locked release anyway. So the gate is `Version(celery.__version__) > Version("5.7.0rc1")`, with that caveat written beside it. ## Tested In WSL, Python 3.12, Redis 7.4.11 (`package/tests/celery_worker`, `-p no:django -rxX`): | Celery | Gate | Result | | --- | --- | --- | | 5.6.3 (the lock) | off, as computed | 15 passed, 2 xfailed | | `main` at e3cc7cc53 (has both fixes), gate forced on | on | 15 passed, 2 xfailed | | `main` at e3cc7cc53, gate forced off | off | fails, naming `died at task_postrun` as moved, as the table should | - `ruff check` and `ruff format --check`: clean. - `pyrefly check`: no errors in the changed file. The environment lacked the extras and demos behind the repository's other errors. Found while updating celery/celery#10726, which runs the same integration against Celery's `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
Description
Refs #10724 (the lost pool child path).
When a prefork child stores SUCCESS and then dies before handing the result back (OOM kill, SIGKILL, or a crash in
on_success, atask_postrunreceiver orprocess_cleanup), the parent getsWorkerLostError.Request.on_failurethen callsmark_as_failureunconditionally. That has three effects on a task that already succeeded:on_chord_part_returnruns a second time, so a chord header is counted twice and the chord can fail on a header recorded as SUCCESSacks_lateandreject_on_worker_lost, the message is requeued and the task runs againThis change reads the stored state before the worker-lost handling. If it's SUCCESS, the request is acknowledged (when
acks_late) and nothing is recorded as a failure. If the backend is disabled or can't be reached, the existing failure handling runs as before. The lookup only happens onWorkerLostError, so the normal path doesn't pay for it.The other path in #10724, a raising
on_successhook inside the tracer, lives incelery/app/trace.pyand isn't covered here. I kept it out to keep this change small.Tests
In
t/unit/worker/test_request.py:test_on_failure_WorkerLostError_after_success_was_stored: the reproduction from the issue. No errback runs and the state stays SUCCESS.test_on_failure_WorkerLostError_after_success_was_stored_acks_late: withreject_on_worker_lost, the message is acked, not requeued, andmark_as_failureisn't called.test_on_failure_WorkerLostError_backend_unavailable: if reading the state raises, the failure is still recorded.The first two fail on
mainand pass with the fix.t/unit/workerandt/unit/taskspass locally (1803 passed). #10726 adds the first test as a strict xfail, so whichever lands second will need to drop the duplicate.