Skip to content

Speed up Trigger.clean_unused query to prevent Triggerer crashes - #68244

Merged
vatsrahul1001 merged 3 commits into
apache:mainfrom
AntonioBergonzi:ab/improve_slow_delete
Jul 29, 2026
Merged

Speed up Trigger.clean_unused query to prevent Triggerer crashes #68244
vatsrahul1001 merged 3 commits into
apache:mainfrom
AntonioBergonzi:ab/improve_slow_delete

Conversation

@AntonioBergonzi

@AntonioBergonziAntonioBergonzi commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Description

Changed the query that checks for triggers that have no more callbacks, assets or task instances associated to them from count + aggregate to anti join on task_instance.
The result is the same (we are checking for non existence) but the query is more efficient. Also added skip locked since we run multiple triggerers and the different queries interfere with each other.

I did not add tests since I'm not introducing a new functionality, so the current one should cover the code change. If you think additional tests are needed, please let me know.

Testing

Plans of the queries OLD QUERY:
EXPLAIN
DELETE
FROM trigger
WHERE trigger.id IN (SELECT trigger.id
FROM trigger
LEFT OUTER JOIN task_instance ON trigger.id = task_instance.trigger_id
WHERE NOT (EXISTS (SELECT 1
FROM asset_watcher
WHERE trigger.id = asset_watcher.trigger_id
AND (EXISTS (SELECT 1
FROM asset
WHERE asset.id = asset_watcher.asset_id)))
)
AND NOT (EXISTS (SELECT 1
FROM callback
WHERE trigger.id = callback.trigger_id))
GROUP BY trigger.id
HAVING count(task_instance.trigger_id) = 0);

yields

-[ RECORD 1 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Delete on trigger (cost=49.31..93458.14 rows=0 width=0)
-[ RECORD 2 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop (cost=49.31..93458.14 rows=1 width=34)
-[ RECORD 3 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Subquery Scan on "ANY_subquery" (cost=49.04..93455.59 rows=1 width=32)
-[ RECORD 4 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> GroupAggregate (cost=49.04..93455.58 rows=1 width=4)
-[ RECORD 5 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Group Key: trigger_1.id
-[ RECORD 6 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Filter: (count(task_instance.trigger_id) = 0)
-[ RECORD 7 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop Left Join (cost=49.04..192.44 rows=18652448 width=8)
-[ RECORD 8 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop Anti Join (cost=48.61..72.06 rows=72 width=4)
-[ RECORD 9 ]---------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Merge Anti Join (cost=48.46..48.83 rows=72 width=4)
-[ RECORD 10 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Merge Cond: (trigger_1.id = asset_watcher.trigger_id)
-[ RECORD 11 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Sort (cost=45.94..46.12 rows=72 width=4)
-[ RECORD 12 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Sort Key: trigger_1.id
-[ RECORD 13 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Seq Scan on trigger trigger_1 (cost=0.00..43.72 rows=72 width=4)
-[ RECORD 14 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Sort (cost=2.52..2.52 rows=1 width=4)
-[ RECORD 15 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Sort Key: asset_watcher.trigger_id
-[ RECORD 16 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop (cost=0.29..2.51 rows=1 width=4)
-[ RECORD 17 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Seq Scan on asset_watcher (cost=0.00..0.00 rows=1 width=8)
-[ RECORD 18 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Only Scan using asset_pkey on asset (cost=0.29..2.51 rows=1 width=4)
-[ RECORD 19 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (id = asset_watcher.asset_id)
-[ RECORD 20 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Only Scan using idx_callback_trigger_id on callback (cost=0.15..0.32 rows=1 width=4)
-[ RECORD 21 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (trigger_id = trigger_1.id)
-[ RECORD 22 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Only Scan using ti_trigger_id on task_instance (cost=0.44..1.66 rows=1 width=4)
-[ RECORD 23 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (trigger_id = trigger_1.id)
-[ RECORD 24 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Scan using trigger_pkey on trigger (cost=0.27..2.49 rows=1 width=10)
-[ RECORD 25 ]--------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (id = "ANY_subquery".id)

NEW QUERY:

EXPLAIN
DELETE
FROM trigger
WHERE trigger.id IN (SELECT trigger.id
FROM trigger
WHERE NOT (EXISTS (SELECT 1
FROM asset_watcher
WHERE trigger.id = asset_watcher.trigger_id
AND (EXISTS (SELECT 1
FROM asset
WHERE asset.id = asset_watcher.asset_id)))
)
AND NOT (EXISTS (SELECT 1
FROM callback
WHERE trigger.id = callback.trigger_id))
AND NOT (EXISTS (SELECT 1
FROM task_instance
WHERE trigger.id = task_instance.trigger_id)) FOR
UPDATE SKIP LOCKED
);

yields

-[ RECORD 1 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Delete on trigger (cost=328.23..372.97 rows=0 width=0)
-[ RECORD 2 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Hash Semi Join (cost=328.23..372.97 rows=73 width=34)
-[ RECORD 3 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Hash Cond: (trigger.id = "ANY_subquery".id)
-[ RECORD 4 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Seq Scan on trigger (cost=0.00..43.73 rows=73 width=10)
-[ RECORD 5 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Hash (cost=327.32..327.32 rows=73 width=32)
-[ RECORD 6 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Subquery Scan on "ANY_subquery" (cost=0.88..327.32 rows=73 width=32)
-[ RECORD 7 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> LockRows (cost=0.88..326.59 rows=73 width=34)
-[ RECORD 8 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop Anti Join (cost=0.88..325.86 rows=73 width=34)
-[ RECORD 9 ]-----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop Anti Join (cost=0.44..129.83 rows=73 width=28)
-[ RECORD 10 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop Anti Join (cost=0.29..47.33 rows=73 width=22)
-[ RECORD 11 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Join Filter: (trigger_1.id = asset_watcher.trigger_id)
-[ RECORD 12 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Seq Scan on trigger trigger_1 (cost=0.00..43.73 rows=73 width=10)
-[ RECORD 13 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Materialize (cost=0.29..2.51 rows=1 width=16)
-[ RECORD 14 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Nested Loop (cost=0.29..2.51 rows=1 width=16)
-[ RECORD 15 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Seq Scan on asset_watcher (cost=0.00..0.00 rows=1 width=14)
-[ RECORD 16 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Scan using asset_pkey on asset (cost=0.29..2.51 rows=1 width=10)
-[ RECORD 17 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (id = asset_watcher.asset_id)
-[ RECORD 18 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Scan using idx_callback_trigger_id on callback (cost=0.15..1.12 rows=1 width=10)
-[ RECORD 19 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (trigger_id = trigger_1.id)
-[ RECORD 20 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | -> Index Scan using ti_trigger_id on task_instance (cost=0.44..2.66 rows=1 width=10)
-[ RECORD 21 ]----------------------------------------------------------------------------------------------------------------------------------
QUERY PLAN | Index Cond: (trigger_id = trigger_1.id)
Also we stopped having queries that crashed the triggerer image
Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)
    Claude Sonnet 1M

  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

@boring-cyborg

Copy link
Copy Markdown

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
Here are some useful points:

  • Pay attention to the quality of your code (ruff, mypy and type annotations). Our prek-hooks will help you with that.
  • In case of a new feature add useful documentation (in docstrings or in docs/ directory). Adding a new operator? Check this short guide Consider adding an example Dag that shows how users should use it.
  • Consider using Breeze environment for testing locally, it's a heavy docker but it ships with a working Airflow and a lot of integrations.
  • Be patient and persistent. It might take some time to get a review or get the final approval from Committers.
  • Please follow ASF Code of Conduct for all communication including (but not limited to) comments on Pull Requests, Mailing list and Slack.
  • Be sure to read the Airflow Coding style.
  • Always keep your Pull Requests rebased, otherwise your build might fail due to changes not related to your commits.
    Apache Airflow is a community-driven project and together we are making it better 🚀.
    In case of doubts contact the developers at:
    Mailing List: dev@airflow.apache.org
    Slack: https://s.apache.org/airflow-slack

@AntonioBergonziAntonioBergonzi changed the title Use NOT EXISTS anti-join in Trigger.clean_unused instead of aggregateUse NOT EXISTS anti-join in Trigger.clean_unused and add index in callback tableJun 12, 2026
@AntonioBergonziAntonioBergonzi changed the title Use NOT EXISTS anti-join in Trigger.clean_unused and add index in callback tableUse NOT EXISTS anti-join in Trigger.clean_unused instead of aggregatingJun 12, 2026
@AntonioBergonziAntonioBergonzi changed the title Use NOT EXISTS anti-join in Trigger.clean_unused instead of aggregatingUse NOT EXISTS anti-join in Trigger.clean_unused and added skip lockedJun 12, 2026
@AntonioBergonzi
AntonioBergonzi marked this pull request as ready for review June 12, 2026 14:06

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

Pull request overview

This PR optimizes Trigger.clean_unused() by switching from an OUTER JOIN + GROUP BY/HAVING count-based approach to a NOT EXISTS-style anti-join when identifying unused triggers, and adds row-level locking with SKIP LOCKED to reduce interference when multiple triggerers are running concurrently.

Changes:

  • Replace JOIN/aggregate “no TaskInstance rows” check with ~cls.task_instance.has() (anti-join / NOT EXISTS semantics).
  • Add SKIP LOCKED row locking to avoid concurrent triggerers contending for the same trigger rows during cleanup.

Comment threadairflow-core/src/airflow/models/trigger.py Outdated

@hussein-awalahussein-awala 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.

LGTM, I went through this carefully since it rewrites the delete predicate, and I'm satisfied it's a safe, well-motivated optimization.

For SKIP LOCKED: Good call for multi-triggerer, a single clean_unused() pass may now skip rows locked by a concurrent triggerer and leave them for the next run instead of always sweeping everything, which is the right trade-off.

@hussein-awalahussein-awala added the type:improvement Changelog: Improvements label Jun 17, 2026
@hussein-awalahussein-awala added this to the Airflow 3.3.0 milestone Jun 17, 2026
@hussein-awalahussein-awala added the ready for maintainer review Set after triaging when all criteria pass. label Jun 17, 2026

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

Code looks okay, please update the PR title to say what it fixes/does, not what change it makes to the code.

(See the guide in our PT template on writing commit messages, that applies to PR title too.)

@vatsrahul1001vatsrahul1001 added the backport-to-v3-3-test Backport to v3-3-test label Jul 24, 2026
@eladkal
eladkalforce-pushed the ab/improve_slow_delete branch from ab47768 to f894790CompareJuly 24, 2026 18:09
@AntonioBergonziAntonioBergonzi changed the title Use NOT EXISTS anti-join in Trigger.clean_unused and added skip lockedFix Triggerer crashes caused by slow Trigger.clean_unused query Jul 27, 2026
@AntonioBergonzi

Copy link
Copy Markdown
ContributorAuthor

please update the PR title to say what it fixes/does, not what change it makes to the code.

Good point! Done 😄

@AntonioBergonziAntonioBergonzi changed the title Fix Triggerer crashes caused by slow Trigger.clean_unused query Speed up Trigger.clean_unused query to prevent Triggerer crashes Jul 27, 2026
@hussein-awala
hussein-awala requested a review from ashbJuly 27, 2026 09:32
@vatsrahul1001
vatsrahul1001 merged commit 2c1035a into apache:mainJul 29, 2026
78 checks passed
@boring-cyborg

Copy link
Copy Markdown

Awesome work, congrats on your first merged pull request! You are invited to check our Issue Tracker for additional contributions.

@github-actions

Copy link
Copy Markdown
Contributor

Backport successfully created: v3-3-test

Note: As of Merging PRs targeted for Airflow 3.X
the committer who merges the PR is responsible for backporting the PRs that are bug fixes (generally speaking) to the maintenance branches.

In matter of doubt please ask in #release-management Slack channel.

StatusBranchResult
v3-3-testPR Link

@hussein-awala

Copy link
Copy Markdown
Member

Congrats @AntonioBergonzi on your first commit 🎉

potiuk pushed a commit that referenced this pull request Jul 30, 2026
…crashes (#68244) (#70668)
* Use NOT EXISTS anti-join in Trigger.clean_unused instead of LEFT JOIN + aggregate
* add FOR UPDATE SKIP LOCKED to Trigger.clean_unused to avoid deadlocks between concurrent triggerer pods
* use with_row_locks helper and scope lock to trigger table
(cherry picked from commit 2c1035a)
Co-authored-by: AntonioBergonzi <48454032+AntonioBergonzi@users.noreply.github.com>
Co-authored-by: Rahul Vats <43964496+vatsrahul1001@users.noreply.github.com>
vatsrahul1001 added a commit that referenced this pull request Aug 5, 2026
…crashes (#68244) (#70668)
* Use NOT EXISTS anti-join in Trigger.clean_unused instead of LEFT JOIN + aggregate
* add FOR UPDATE SKIP LOCKED to Trigger.clean_unused to avoid deadlocks between concurrent triggerer pods
* use with_row_locks helper and scope lock to trigger table
(cherry picked from commit 2c1035a)
Co-authored-by: AntonioBergonzi <48454032+AntonioBergonzi@users.noreply.github.com>
Co-authored-by: Rahul Vats <43964496+vatsrahul1001@users.noreply.github.com>
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
…che#68244)
* Use NOT EXISTS anti-join in Trigger.clean_unused instead of LEFT JOIN + aggregate
* add FOR UPDATE SKIP LOCKED to Trigger.clean_unused to avoid deadlocks between concurrent triggerer pods
* use with_row_locks helper and scope lock to trigger table
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Triggererbackport-to-v3-3-testBackport to v3-3-testready for maintainer reviewSet after triaging when all criteria pass.type:improvementChangelog: Improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@AntonioBergonzi@hussein-awala@ashb@vatsrahul1001@hanxdatadog