Uh oh!
There was an error while loading. Please reload this page.
Fix deferred task instances being cascade-deleted by Trigger.clean_unused on MySQL - #72062
Open
aunderwood24 wants to merge 3 commits into
Open
Fix deferred task instances being cascade-deleted by Trigger.clean_unused on MySQL#72062aunderwood24 wants to merge 3 commits into
aunderwood24 wants to merge 3 commits into
Conversation
…used on MySQL On MySQL, Trigger.clean_unused() deletes unreferenced triggers in two statements: SELECT candidate ids into a Python list, then DELETE by id. A task instance that defers between the two statements attaches to a trigger already on the list; the DELETE fires anyway and the ON DELETE CASCADE on task_instance.trigger_id silently deletes the task instance row. The DELETE now re-checks the reference predicates via correlated NOT EXISTS subqueries, which MySQL allows (error 1093 only applies to subqueries on the target table), restoring the atomicity the single-statement branch gives every other dialect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
aunderwood24
marked this pull request as ready for review
August 25, 2026 15:02
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes: #72061
related: #68243, #71540
Why
On MySQL,
Trigger.clean_unused()deletes unreferenced triggers in two steps: SELECT the candidate ids into a Python list, then DELETE by id. A task that defers between those two statements attaches to a trigger that's already on the list; the DELETE fires anyway, and theON DELETE CASCADEontask_instance.trigger_idsilently deletes the task instance row. The dag run then either fails via "Task deadlock (no runnable tasks)" with zero failed tasks (so no failure callback ever fires), or completes as success with the task's work never run. We hit this in production at Patreon on 3.3.1 across ~109 runs / 55 dags in 48h before finding the cause — details and a deterministic reproducer in #72061.#68244's SKIP LOCKED protects the SELECT, but the list is stale by the time the DELETE runs. The window is the full round trip between the two statements, and
clean_unused()runs every triggerer loop tick.How
The two-step exists because MySQL raises error 1093 for a DELETE whose subquery selects from the target table (#38663). That restriction is only about the target table: correlated NOT EXISTS subqueries against
task_instance,asset, andcallbackare legal inside the DELETE. So this change keeps the materialized ids as a candidate set (preserving #68244's SKIP LOCKED behaviour on the SELECT) and re-checks the same three reference predicates inside the DELETE itself. A trigger that gained a reference after the SELECT now survives the sweep and gets cleaned on a later pass once it's genuinely unused — which is exactly how the single-statement branch already behaves for every other dialect.Verified against real MySQL 8.0: stock code loses rows within one run of a 200-task deferral-churn dag (~800 defer events); with this change, zero rows lost across 21,000+ defer events. Postgres control: zero losses either way.
Behaviour change
Only on MySQL: a trigger that becomes referenced between the candidate SELECT and the DELETE is no longer deleted (previously it was, cascading into the task instance row). No change for other dialects.
Test plan
test_clean_unused_keeps_triggers_referenced_after_candidate_select: pins the dialect to "mysql" so the two-step branch runs on every backend (on the MySQL matrix it exercises the real dialect SQL), interleaves a deferral between the id SELECT and the DELETE, and asserts both the trigger and the task instance row survive. Fails on current main (assert 0 == 1— trigger deleted), passes with the fix.test_clean_unusedstill passesprek run --from-ref mainCould a committer add the
backport-to-v3-3-testlabel? The widened exposure shipped in 3.3.1 (via #68244), so 3.3.x is the line running this in the wild.^ Add meaningful description above
Generated with: Claude Code (investigation, fix, and tests were reviewed, run, and reproduced by a human before submission)
Read the Pull Request Guidelines for more information.