Skip to content

Don't record FAILURE when on_success raises after SUCCESS was stored - #10759

Merged
auvipy merged 12 commits into
celery:mainfrom
gyanu2507:fix/on-success-raise-after-store
Oct 25, 2026
Merged

auvipy merged 12 commits into
celery:mainfrom
gyanu2507:fix/on-success-raise-after-store

Conversation

@gyanu2507

@gyanu2507 gyanu2507 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Refs #10724 (the on_success path). #10752 covers the other path in that issue, a pool child lost after the store.

Task.on_success and Task.after_return run after the task's outcome is recorded. If either hook raised in a worker, the exception reached the tracer's outer except Exception, which calls on_error again. For a task that succeeded, that meant:

  • the errbacks fired, so the app hears both "succeeded" and "failed" for one run
  • a chord header was reported as a failure
  • task_failure and task_internal_error were sent, and task_success and after_return were skipped (for an on_success raise)
  • on backends without a guard, such as the database backend, the stored SUCCESS was overwritten with FAILURE. Key-value backends keep it, because KeyValueStoreBackend._store_result returns early when the stored status is SUCCESS.

For a task that failed, a raising after_return recorded the failure a second time.

With this change, a worker logs an exception from either hook at ERROR level, with the task name and id, and the recorded outcome stands. That matches how exceptions from signal receivers are already handled. Specifically:

  • In eager mode (apply(), task_always_eager) the exception still propagates to the caller, as on main, so test suites keep seeing broken hooks.
  • Reject raised from a hook still reaches the outer except Reject: raise, as on main.
  • Exceptions from the task body and from storing the result go through the failure path unchanged.

Because broken hooks no longer send task_failure or task_internal_error in a worker, I added a changelog entry and a sentence to the on_success and after_return sections of docs/userguide/tasks.rst.

Tests

In t/unit/tasks/test_trace.py:

  • test_trace_on_success_raises_after_success_was_stored: non-eager trace with a raising on_success. handle_failure isn't called, task_success and after_return run, and the error is logged. A comment notes that the state check alone also passes on main with the cache backend.
  • test_trace_on_success_raises_eager_propagates and test_trace_after_return_raises_eager_propagates: eager mode still raises.
  • test_trace_on_success_raises_reject_is_not_swallowed: Reject still propagates.
  • test_trace_after_return_raises[False/True]: a raising after_return records no failure for a successful task, and exactly one for a failed task.

t/unit/tasks and t/unit/worker pass locally on the branch after the merge from main (1806 passed). flake8 and isort are clean.

Task.on_success runs after mark_as_done. If it raised, the exception
reached the tracer's outer handler, which overwrote the stored SUCCESS
with FAILURE, fired the errbacks, and skipped the task_success signal
and after_return.

Log the exception instead, the same way signal receivers' exceptions
are handled, and finish the success path.

Refs celery#10724

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

Thanks @gyanu2507, this is the right fix for the on_success half of #10724. I compared main and this branch by running the tracer with eager=False and a task whose on_success raises:

main this PR
mark_as_failure called (with call_errbacks=True) yes no
task_failure / task_internal_error signals both sent not sent
task_success signal skipped sent
after_return skipped called with SUCCESS
tracer return value ExceptionInfo 4

Catching the exception next to the hook, logging it, and continuing down the success path matches how signal receiver errors are already handled.

1. Eager mode now swallows the error

On main, the outer handler does if eager: raise, so task.apply() (and task_always_eager) propagates an exception from on_success to the caller:

main:    add.apply((2, 2))  -> raises RuntimeError('hook failed')
this PR: add.apply((2, 2))  -> EagerResult SUCCESS 4, error only logged

Many projects run their test suites in eager mode, so a broken on_success hook that fails a test today would pass silently after this change. The reasoning in the code comment ("SUCCESS is already stored") also applies less in eager mode, where nothing is stored by default. Could you keep the old behaviour there, e.g. if eager: raise inside the new except? If swallowing is intended in eager mode as well, please say so in the changelog.

2. after_return has the same problem

task_after_return(...) runs a few lines later, also after mark_as_done, and it is still unprotected. With an after_return that raises, both main and this branch call mark_as_failure with errbacks and send task_failure for a task that succeeded. For the failure states it's arguably worse: on_error has already recorded the failure, and the outer handler records it a second time. It's the same class of bug, so it would fit in this PR, or in a follow-up linked from #10724 so the issue isn't closed with that path still open.

3. Description and test accuracy

  • The description says the stored SUCCESS is overwritten with FAILURE. On key-value backends it isn't: KeyValueStoreBackend._store_result returns early when the current status is SUCCESS, so in my run the stored state stays SUCCESS on main too. The real effects there are the errbacks, the chord part counted as a failure, and the failure signals. The database backend has no such guard, so the overwrite does apply there. Worth making precise.
  • For the same reason, assert ...get_state('id-1') == states.SUCCESS in the new test also passes on main (the test app uses the cache backend). The assertions that actually pin the fix are handle_failure.assert_not_called(), the task_success receiver and after_return. That's fine, but a short comment would stop someone from trimming the test down to the state check.
  • The test doesn't check that the error is logged. Patching celery.app.trace.logger (as other tests in this file do) and asserting logger.exception was called would cover the new branch fully.

4. Smaller points

  • Reject is a subclass of Exception, so raise Reject(...) from on_success is now swallowed. On main it reached the outer except Reject: raise. Rejecting a task whose SUCCESS is stored and whose callbacks were dispatched is questionable anyway, but please make the choice explicit (either except Reject: raise before the new handler, or a line in the comment).
  • Errors in on_success no longer produce task_failure or task_internal_error, only an ERROR log record. Anyone alerting on those signals will stop seeing broken hooks. That deserves a changelog line and a sentence in the on_success section of docs/userguide/tasks.rst.

LGTM once the eager behaviour is decided. The after_return case can be a follow-up if you prefer to keep this PR small.

@auvipy
auvipy self-requested a review October 3, 2026 11:07
@auvipy auvipy added this to the 5.7.x milestone Oct 3, 2026
…turn

- Re-raise from on_success in eager mode so apply() and task_always_eager
  still surface a broken hook.
- Let Reject from the hook reach the outer handler as before.
- Handle after_return the same way: it also runs after the outcome is
  recorded, and a raise there recorded a failure for a successful task,
  or a second failure for a failed one.
- Assert the log call in the test and note which assertions pin the fix.
- Document the behaviour in the task handler docs and the changelog.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

Thanks @MehrazRumman, this was a really useful review. Pushed 45a2f34 on top of the merge from main:

  1. Eager mode: the new handler re-raises when eager is true, so apply() and task_always_eager behave as on main. Covered by test_trace_on_success_raises_eager_propagates.
  2. after_return: I kept it in this PR since it's the same bug. It gets the same handling (log in a worker, re-raise eagerly, let Reject through). test_trace_after_return_raises checks both sides: no failure recorded for a successful task, and exactly one for a failed task, where main records two.
  3. Accuracy: the description now says that key-value backends keep the stored SUCCESS and that the database backend is the one that gets overwritten. The test has a comment saying the state check doesn't pin the fix on its own, and it now asserts logger.exception.
  4. Reject: an explicit except Reject: raise sits before each new handler, with a test. The lost task_failure / task_internal_error signals are now in the changelog and in the on_success / after_return docs.

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.72%. Comparing base (4543e46) to head (1b4dd96).

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #10759   +/-   ##
=======================================
  Coverage   90.72%   90.72%           
=======================================
  Files         153      153           
  Lines       21533    21549   +16     
  Branches     2651     2653    +2     
=======================================
+ Hits        19535    19551   +16     
  Misses       1685     1685           
  Partials      313      313           
Flag Coverage Δ
unittests 90.70% <100.00%> (+<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 14, 2026
Resolve Changelog.rst: keep Unreleased entry for celery#10724 above 5.7.0rc1.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

@MehrazRumman thank you for the detailed review. The follow-up in 45a2f3420 covers the points you raised:

  1. Eager mode still propagates (if eager: raise inside the new handlers).
  2. after_return is wrapped the same way as on_success.
  3. Description/tests: state-check comment, logger.exception assertion, and the docs/changelog notes about signals.
  4. Reject is re-raised before the broad Exception handler.

I just merged current main (Changelog conflict only: Unreleased #10724 entry kept above 5.7.0rc1). Ready for another look when you have time.

@auvipy

auvipy commented Oct 22, 2026

Copy link
Copy Markdown
Member

@ChristianMurphy can you check this one too?

Comment thread Changelog.rst Resolved
@auvipy

auvipy commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

i think the unit test coverage can be improved as well @gyanu2507

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

ChristianMurphy commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

I tested this with a prefork worker, against Redis and SQLite (db+sqlite) result backends, on main and on this branch. When on_success or after_return raises, the task is still SUCCESS afterward, where main overwrote it with FAILURE on SQLite. The errback doesn't run, and only task_success fires. A chord with that task in its header now runs its body, where main failed it with ChordError. A failed task whose after_return raises is stored with its own exception, not the hook's. Eager apply() matches main, and Reject, KeyboardInterrupt and SystemExit still propagate.

@gyanu2507

Before merging:

  1. Changelog.rst: the merge in a3075a9 dropped the ======= under 5.7.0b1, so that heading no longer renders. I think that's what auvipy meant. There's also an extra blank line above its label.
  2. trace.py: the except Exception in both new handlers now catches MemoryError, which main re-raises through the outer except MemoryError: raise. Using except (Reject, MemoryError): raise keeps that.

Also, the Reject branch for after_return has no test.

… quiet

Restore the RST section underline under 5.7.0b1 that a main merge dropped. Extend the on_success-raises regression test so task_failure and task_internal_error are asserted not to fire.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

@auvipy Restored the ======= underline under the 5.7.0b1 changelog heading.

Also tightened the existing unit coverage: when on_success raises after SUCCESS is stored, the test now asserts that task_failure and task_internal_error stay quiet (in addition to handle_failure not being called and task_success still firing). Happy to add more cases if you have a specific gap in mind.

@auvipy

auvipy commented Oct 24, 2026

Copy link
Copy Markdown
Member

2. trace.py: the except Exception in both new handlers now catches MemoryError, which main re-raises through the outer except MemoryError: raise. Using except (Reject, MemoryError): raise keeps that.

Also, the Reject branch for after_return has no test

is this already covered? the codcov is reporting some missing coverage. not specific though, can you cross check?

@ChristianMurphy

Copy link
Copy Markdown
Contributor

I checked 34c68d3. The changelog heading renders again, and the test now checks that task_failure and task_internal_error don't fire. The docs are right about eager runs too: apply() still raises the hook's error, as on main.

These are still open from my last comment.

MemoryError is now swallowed. On main, a MemoryError raised by on_success or after_return in a worker propagates. On this branch, the new handlers log it, and the task finishes normally. Re-raising it next to Reject makes it propagate again, as on main:

except (Reject, MemoryError):
    raise

The postrun handler a few lines below already re-raises MemoryError this way.

No test raises Reject from after_return. That raise is line 673 of trace.py. It's the line Codecov reports as missing, and the tests still pass if I delete it. A version of test_trace_on_success_raises_reject_is_not_swallowed that raises from after_return would test it.

Also, the merge adds an extra blank line above .. _version-5.7.0b1:.

Keep MemoryError (and Reject) propagating from on_success/after_return like main. Add after_return Reject and on_success MemoryError tests. Restore Unreleased changelog entries after merging main.
@gyanu2507

Copy link
Copy Markdown
Contributor Author

Addressed the remaining notes from @ChristianMurphy:

  1. on_success / after_return now use except (Reject, MemoryError): raise, so MemoryError propagates again as on main.
  2. Added test_trace_after_return_raises_reject_is_not_swallowed and test_trace_on_success_raises_memory_error_propagates.
  3. Merged main and kept both Unreleased changelog entries (our A task whose SUCCESS is stored still fires its error callback: a pool child lost after the store, or a raising on_success #10724 note and the Redis chord Lua entry).

@auvipy
auvipy merged commit 380b4a9 into celery:main Oct 25, 2026
505 checks passed
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