Skip to content

Keep the first SUCCESS in the database backend - #10920

Merged
auvipy merged 5 commits into
celery:mainfrom
ChristianMurphy:fix/database-backend-success-guard
Oct 24, 2026
Merged

auvipy merged 5 commits into
celery:mainfrom
ChristianMurphy:fix/database-backend-success-guard

Conversation

@ChristianMurphy

@ChristianMurphy ChristianMurphy commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Note: Before submitting this pull request, please review our contributing
guidelines
.

Description

Fixes #10912.

The key-value backends ignore any write once a task has a SUCCESS. The database backend had no such check. So when a task runs again under acks_late, because its worker was killed after saving the result and before the ack, the second run writes over the stored SUCCESS.

This returns early in DatabaseBackend._store_result when the stored state is SUCCESS.

It also always writes the status column. SQLAlchemy only writes columns whose values changed since it read the row. If another run saved a SUCCESS after that read, a STARTED write used to update the result and skip the status. The row then held SUCCESS with the STARTED result. The new check would then ignore every later write to that row, including that run's own SUCCESS.

When the row exists, it's read again with SELECT ... FOR UPDATE before the check. When a task runs twice at once, the second run waits for the first and then sees its SUCCESS. A missing row isn't locked. On MySQL, that takes a gap lock, and concurrent first writes for new tasks then deadlock. With 16 processes writing new tasks, locking every read gave 8 to 20 deadlocks per 3,200 writes on MySQL 8.4, and this version gave none. SQLite has no row locks, and the status column is still always written for it.

Other effects, all as on the key-value backends:

  • Any later write is ignored. This includes a FAILURE that the same run stores when on_success raises, and AbortableAsyncResult.abort() on a task that already succeeded.
  • Database rows don't expire like Redis keys. A task id that you reuse on purpose keeps its first SUCCESS until AsyncResult.forget() or the daily celery.backend_cleanup task deletes the row. That task only runs with Celery beat. The FAQ says reusing an id is undefined.

worker_deduplicate_successful_tasks can skip a copy that runs again, but it is off by default. The MongoDB and Cassandra backends have the same gap, in #10929.

The new tests check that STARTED, FAILURE and SUCCESS writes after a SUCCESS are ignored, and that the extended columns stay the same. They also check that a row can still go from STARTED to SUCCESS. When another run saves SUCCESS during a write, that SUCCESS is kept, and without row locks the row isn't left mixed. The crash script in #10912 now keeps the first SUCCESS.

The key-value backends ignore any update once a task has a SUCCESS. The
database backend has no such check. With `acks_late`, a task runs again
when its worker is killed after it saved the result but before the ack.
The second run then writes over the stored SUCCESS. With
`task_track_started`, readers see STARTED, and then a new result or a
FAILURE.

Return early when the stored state is SUCCESS, as the key-value backends
do. Any later write for that task id is ignored, including a FAILURE
that the same run stores when `on_success` raises.

Also write the status column every time. SQLAlchemy only writes columns
that changed since it read the row. So a STARTED write could race with
another run's SUCCESS and leave that SUCCESS next to the STARTED result,
which the new check would then keep.

Fixes celery#10912
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.71%. Comparing base (fc8019a) to head (c98e492).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10920      +/-   ##
==========================================
- Coverage   90.72%   90.71%   -0.02%     
==========================================
  Files         153      153              
  Lines       21520    21529       +9     
  Branches     2649     2652       +3     
==========================================
+ Hits        19524    19530       +6     
- Misses       1684     1685       +1     
- Partials      312      314       +2     
Flag Coverage Δ
unittests 90.69% <100.00%> (-0.02%) ⬇️

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.

@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 52120ef. t/unit/backends/test_database.py: 111 passed. All five new tests fail on main.

  • The early return matches BaseKeyValueStoreBackend._store_result, which skips any write once the stored status is SUCCESS. The two backends now behave the same for a task that runs again after a crash, which is the case in #10912.
  • flag_modified(task, 'status') is needed, and test_store_result_racing_a_success_writes_its_state shows why: without it, main leaves the row with SUCCESS next to the STARTED result, and the new SUCCESS check would then freeze it like that. Good catch.
  • The return happens before _update_result, so the extended columns (worker, retries, ...) keep the values from the run that succeeded. test_store_result_after_success_keeps_extended_fields covers that.

On the row lock

On @auvipy's side of the open question in the description: the lock is small. I tried it locally:

    def _query_task(self, session, task_id, for_update=False):
        """Query task by id, falling back to deferring missing columns from database."""
        try:
            query = session.query(self.task_cls).filter(self.task_cls.task_id == task_id)
            if for_update:
                query = query.with_for_update()
            tasks = list(query)
            return tasks and tasks[0]

_store_result then calls self._query_task(session, task_id, for_update=True). The reads (_get_task_meta_for) stay as they are.

  • SQLAlchemy emits ... FOR UPDATE for PostgreSQL and MySQL and drops it for SQLite, so SQLite behaves as it does now.
  • The lock is held until session.commit(), or until session_cleanup closes the session on the early return.
  • With it, the database tests pass except test_store_result_racing_a_success_writes_its_state. That fails only because its _query_task stub takes two arguments. With the lock, that interleaving can't happen on PostgreSQL or MySQL anyway: the second run waits for the SUCCESS commit and then hits the new check. So the test would need to change with it.

I agree it can be a follow-up. The sequential case is the one #10912 reproduces, and this PR fixes it. I'd still lean towards adding the lock here, since #10912 reports 7 of 150 results changing in the Postgres fault test, and that test exercises the concurrent case too.

Smaller point

The MongoDB and Cassandra backends have the same gap, as the description says. It would be good to open an issue for them so they aren't forgotten.

@auvipy

auvipy commented Oct 22, 2026

Copy link
Copy Markdown
Member

I'd still lean towards adding the lock here, since #10912 reports 7 of 150 results changing in the Postgres fault test, and that test exercises the concurrent case too.

i agree with this @ChristianMurphy

When a task ran twice at once, both runs could read the row before
either one wrote SUCCESS. The later write then replaced the first
SUCCESS. Read the row again with SELECT ... FOR UPDATE before the
check. The second run then waits for the first and sees its SUCCESS.

Only a row that exists is locked. On MySQL, locking a missing row takes
a gap lock, and concurrent first writes for new tasks then deadlock.
SQLite has no row locks, and the status column is still always written
for it.
@ChristianMurphy

Copy link
Copy Markdown
Contributor Author

Thanks. I added the lock in f4054df, with one change: _store_result only takes it once a plain read has found the row. On MySQL, locking a row that doesn't exist yet takes a gap lock, and then concurrent first writes for new tasks deadlock.

I measured it with 16 processes storing STARTED and then SUCCESS for new tasks (3,200 writes), and with two runs storing SUCCESS for the same task at once (150 rounds):

MySQL 8.4 deadlocks SUCCESS changed (MySQL / Postgres 17)
no lock 0 4 / 4
lock on every store 8 to 20 per run 0 / 0
lock existing rows (this commit) 0 0 / 0

Postgres had no errors in any version. flag_modified stays for SQLite, which has no row locks. The race test is now two tests. A SUCCESS saved before the locked read is kept, and one saved after it, as on SQLite, doesn't leave a mixed row.

The MongoDB and Cassandra gap is now #10929.

@auvipy
auvipy merged commit dce25de into celery:main Oct 24, 2026
20 of 22 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.

Database backend lets a second run of a task overwrite its stored SUCCESS

3 participants