Skip to content

Don't fail a task whose SUCCESS was stored before the worker was lost - #10752

Merged
auvipy merged 16 commits into
celery:mainfrom
gyanu2507:fix/worker-lost-after-success-stored
Oct 25, 2026
Merged

auvipy merged 16 commits into
celery:mainfrom
gyanu2507:fix/worker-lost-after-success-stored

Conversation

@gyanu2507

Copy link
Copy Markdown
Contributor

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, a task_postrun receiver or process_cleanup), the parent gets WorkerLostError. Request.on_failure then calls mark_as_failure unconditionally. That has three effects on a task that already succeeded:

  • the errbacks fire, so the app hears both "succeeded" and "failed" for one execution
  • on_chord_part_return runs a second time, so a chord header is counted twice and the chord can fail on a header recorded as SUCCESS
  • with acks_late and reject_on_worker_lost, the message is requeued and the task runs again

This 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 on WorkerLostError, so the normal path doesn't pay for it.

The other path in #10724, a raising on_success hook inside the tracer, lives in celery/app/trace.py and 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: with reject_on_worker_lost, the message is acked, not requeued, and mark_as_failure isn't called.
  • test_on_failure_WorkerLostError_backend_unavailable: if reading the state raises, the failure is still recorded.

The first two fail on main and pass with the fix. t/unit/worker and t/unit/tasks pass locally (1803 passed). #10726 adds the first test as a strict xfail, so whichever lands second will need to drop the duplicate.

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
@auvipy auvipy added this to the 5.7.0 milestone Oct 1, 2026
@auvipy auvipy added the Worker label Oct 1, 2026
@codecov

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.72%. Comparing base (380b4a9) to head (aa4e0b2).

Files with missing lines Patch % Lines
celery/worker/request.py 90.90% 2 Missing ⚠️
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           
Flag Coverage Δ
unittests 90.70% <90.90%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@auvipy auvipy modified the milestones: 5.7.0, 5.7.x Oct 5, 2026
@auvipy auvipy modified the milestones: 5.7.x, 5.7.0 Oct 8, 2026

@auvipy auvipy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the change already covered by existing smoke or integration tests?

@auvipy auvipy modified the milestones: 5.7.0, 5.7.x Oct 14, 2026
Keep the SUCCESS-already-stored guard ahead of the new
retry_on_worker_lost path so a finished task is not retried or failed.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

@auvipy the change is covered by unit tests in t/unit/worker/test_request.py:

  • test_on_failure_WorkerLostError_after_success_was_stored — SUCCESS stays SUCCESS, errbacks do not run
  • test_on_failure_WorkerLostError_after_success_was_stored_acks_late — acknowledges instead of rejecting/requeueing when reject_on_worker_lost is set
  • test_on_failure_WorkerLostError_backend_unavailable — falls back to the old failure path if the backend cannot be read

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 main and kept the SUCCESS guard ahead of the new retry_on_worker_lost path so a finished task is not retried.

@auvipy auvipy modified the milestones: 5.7.x, 5.7.0 Oct 22, 2026
@auvipy

auvipy commented Oct 22, 2026

Copy link
Copy Markdown
Member

@ChristianMurphy can you review this? may be merging this before your one would be good idea

@ChristianMurphy

Copy link
Copy Markdown
Contributor

I tested this against db0da7c on Redis, SQLite and rpc://, with a prefork worker and a task_success receiver that kills the child with SIGKILL after SUCCESS is stored.

On Redis and SQLite it works as described. The errback and task_failure don't fire, and a chord with that task in its header runs its body instead of failing with ChordError. With acks_late and reject_on_worker_lost the task runs once, where main requeues it (62 runs in 8 seconds when the crash repeats). retry_on_worker_lost doesn't send it again either.

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:

  1. The worker doesn't send a final event. Main sends task-failed. With this change the last event is task-started, so events.State (and Flower) show the task as STARTED forever. Could it send task-succeeded?
  2. On rpc:// the lookup always returns PENDING, because results go to the caller's queue, so the behavior doesn't change. But each worker process now declares a durable queue named after its oid, which stays for 24 hours. Skipping the lookup for rpc://, and for ignore_result tasks, would avoid that.
  3. A SUCCESS from an earlier delivery of the same task id also matches. On Redis, when a task id is sent again and its second run is killed mid-task, the message is acked instead of requeued, and the warning says the task succeeded.
  4. A test for retry_on_worker_lost would help.

@auvipy

auvipy commented Oct 22, 2026

Copy link
Copy Markdown
Member

Some things to consider:

  1. The worker doesn't send a final event. Main sends task-failed. With this change the last event is task-started, so events.State (and Flower) show the task as STARTED forever. Could it send task-succeeded?
  2. On rpc:// the lookup always returns PENDING, because results go to the caller's queue, so the behavior doesn't change. But each worker process now declares a durable queue named after its oid, which stays for 24 hours. Skipping the lookup for rpc://, and for ignore_result tasks, would avoid that.
  3. A SUCCESS from an earlier delivery of the same task id also matches. On Redis, when a task id is sent again and its second run is killed mid-task, the message is acked instead of requeued, and the warning says the task succeeded.
  4. A test for retry_on_worker_lost would help.

@gyanu2507 please check

@MehrazRumman MehrazRumman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_result is set. Together with skipping rpc:// (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 existing try, so a backend that's down falls back right away as the comment says.
  • Add a test with result_backend_always_retry=True and a backend whose read fails, checking that on_failure falls back without sleeping.

auvipy and others added 2 commits October 24, 2026 09:57
…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.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

Addressed @ChristianMurphy's review notes:

  1. When SUCCESS was already stored, the worker now sends task-succeeded (with the stored result) so Flower / events.State do not stay on STARTED.
  2. _success_was_stored skips the lookup for ignore_result tasks and for the rpc:// backend, so we do not declare a durable per-worker reply queue for a check that would always see PENDING.
  3. A SUCCESS from an earlier delivery of the same task id is still a known limitation of a pure get_state check. Happy to follow up if maintainers want a stronger correlation (for example tying the stored result to the current delivery).
  4. Added a unit test that retry_on_worker_lost does not re-send when SUCCESS is already stored, plus coverage for the rpc:// and ignore_result skip paths.

Also restored the missing ======= underline under the 5.7.0b1 changelog heading if that had been dropped by a main merge.

@auvipy auvipy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the change also covered by existing smoke or integration tests?

@auvipy

auvipy commented Oct 24, 2026

Copy link
Copy Markdown
Member

Suggestions:

* Skip the lookup when `self.task.ignore_result` is set. Together with skipping `rpc://` (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 existing `try`, so a backend that's down falls back right away as the comment says.

* Add a test with `result_backend_always_retry=True` and a backend whose read fails, checking that `on_failure` falls back without sleeping.

@gyanu2507 are this also covered?

@ChristianMurphy

Copy link
Copy Markdown
Contributor

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 saferepr of the result in the event, as a normal success does.

The new task-succeeded event (request.py:720) sends the whole result. A normal success sends saferepr(result, resultrepr_maxsize), which is capped at about 1,000 characters. Sending the whole result causes two problems:

  • A task that returned 100,000 characters put all of them in the event.
  • If the result can't be JSON encoded, the event isn't sent. I saw this with result_serializer='pickle' and an object as the result. The dispatcher puts the failed event in its buffer without logging anything, so Flower still shows STARTED.

Sending saferepr(result, self.task.resultrepr_maxsize) fixes both. No test checks the payload yet. One would catch this.

2. Check the request's ignore_result, not the task's.

_success_was_stored checks task.ignore_result (request.py:831). The request has its own self.ignore_result, which also reads the ignore_result option passed to apply_async(). Checking the task's value misses two cases:

  • apply_async(ignore_result=True) still does the lookup. With result_backend_always_retry=True and the backend down, on_failure never returns. That's the hang MehrazRumman found, still reachable this way.
  • With task_ignore_result=True in the settings and apply_async(ignore_result=False), SUCCESS is stored, but the lookup is skipped. The task then fails the same way it does on main.

Using self.ignore_result fixes both. test_on_failure_WorkerLostError_skips_lookup_when_ignore_result would then need to set the option on the request instead of the task.

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.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

Addressed the follow-ups from @ChristianMurphy:

  1. The late task-succeeded event now sends saferepr(result, self.task.resultrepr_maxsize) instead of the raw result, matching a normal success path for size and JSON-safety. The acks_late unit test asserts the payload is the saferepr string.
  2. _success_was_stored now checks self.ignore_result (request-level, including apply_async(ignore_result=...)) instead of only task.ignore_result. The ignore_result unit test sets the flag on the request.

Also merged current main.

@auvipy
auvipy merged commit e3cc7cc into celery:main Oct 25, 2026
505 checks passed
github-actions Bot pushed a commit to gigaverse-app/pytest-obligation that referenced this pull request Oct 25, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants