This is an automated email from the ASF dual-hosted git repository.
eladkal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 1ab249327b5 Speed up trigger cleanup callback lookups (#75228)
1ab249327b5 is described below
commit 1ab249327b50eb0bddfad3cb01976b47dfe85833
Author: Shivam Rastogi <[email protected]>
AuthorDate: Mon Nov 2 03:38:46 2026 -0800
Speed up trigger cleanup callback lookups (#75228)
---
airflow-core/docs/migrations-ref.rst | 4 +-
.../0153_3_5_0_add_index_on_callback_trigger_id.py | 71 ++++++
airflow-core/src/airflow/models/callback.py | 11 +-
airflow-core/src/airflow/utils/db.py | 2 +-
.../test_0153_add_index_on_callback_trigger_id.py | 249 +++++++++++++++++++++
airflow-core/tests/unit/models/test_trigger.py | 18 +-
6 files changed, 350 insertions(+), 5 deletions(-)
diff --git a/airflow-core/docs/migrations-ref.rst
b/airflow-core/docs/migrations-ref.rst
index 413d4b3bd64..324dda5a79f 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are
executed via when you ru
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| Revision ID | Revises ID | Airflow Version | Description
|
+=========================+==================+===================+==============================================================+
-| ``5fbfa42a2d01`` (head) | ``38c8df3fcb9c`` | ``3.5.0`` | Add
recorded state and owner to trigger. |
+| ``16d7301f124b`` (head) | ``5fbfa42a2d01`` | ``3.5.0`` | Add index
on callback trigger ID. |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``5fbfa42a2d01`` | ``38c8df3fcb9c`` | ``3.5.0`` | Add
recorded state and owner to trigger. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| ``38c8df3fcb9c`` | ``c71065a1b14f`` | ``3.5.0`` | Add index
on dag_run (dag_id, run_after). |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git
a/airflow-core/src/airflow/migrations/versions/0153_3_5_0_add_index_on_callback_trigger_id.py
b/airflow-core/src/airflow/migrations/versions/0153_3_5_0_add_index_on_callback_trigger_id.py
new file mode 100644
index 00000000000..f88b451c7e5
--- /dev/null
+++
b/airflow-core/src/airflow/migrations/versions/0153_3_5_0_add_index_on_callback_trigger_id.py
@@ -0,0 +1,71 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Add index on callback trigger ID.
+
+Revision ID: 16d7301f124b
+Revises: 5fbfa42a2d01
+Create Date: 2026-10-19 15:11:12.337117
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+from airflow.migrations.utils import disable_sqlite_fkeys
+
+revision = "16d7301f124b"
+down_revision = "5fbfa42a2d01"
+branch_labels = None
+depends_on = None
+airflow_version = "3.5.0"
+
+
+def upgrade():
+ """Add an index on callback.trigger_id."""
+ trigger_id_is_not_null = sa.column("trigger_id").is_not(None)
+ with disable_sqlite_fkeys(op):
+ with op.batch_alter_table("callback", schema=None) as batch_op:
+ batch_op.create_index(
+ "idx_callback_trigger_id",
+ ["trigger_id"],
+ unique=False,
+ postgresql_where=trigger_id_is_not_null,
+ sqlite_where=trigger_id_is_not_null,
+ )
+
+
+def downgrade():
+ """Remove the index on callback.trigger_id."""
+ with disable_sqlite_fkeys(op):
+ conn = op.get_bind()
+ with op.batch_alter_table("callback", schema=None) as batch_op:
+ if conn.dialect.name == "mysql":
+
batch_op.drop_constraint(batch_op.f("callback_trigger_id_fkey"),
type_="foreignkey")
+ batch_op.drop_index("idx_callback_trigger_id")
+ batch_op.create_foreign_key(
+ batch_op.f("callback_trigger_id_fkey"),
+ "trigger",
+ ["trigger_id"],
+ ["id"],
+ )
+ else:
+ batch_op.drop_index("idx_callback_trigger_id")
diff --git a/airflow-core/src/airflow/models/callback.py
b/airflow-core/src/airflow/models/callback.py
index 490a167d7cc..d1fa640bab6 100644
--- a/airflow-core/src/airflow/models/callback.py
+++ b/airflow-core/src/airflow/models/callback.py
@@ -26,7 +26,7 @@ from uuid import UUID
import structlog
import uuid6
-from sqlalchemy import ForeignKey, Integer, String, Text, Uuid
+from sqlalchemy import ForeignKey, Index, Integer, String, Text, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
# Re-exporting as _accepts_context for backward compatibility
@@ -160,6 +160,15 @@ class Callback(Base, BaseWorkload):
trigger_id: Mapped[int | None] = mapped_column(Integer,
ForeignKey("trigger.id"), nullable=True)
trigger = relationship("Trigger", back_populates="callback", uselist=False)
+ __table_args__ = (
+ Index(
+ "idx_callback_trigger_id",
+ trigger_id,
+ postgresql_where=trigger_id.is_not(None),
+ sqlite_where=trigger_id.is_not(None),
+ ),
+ )
+
def __init__(self, priority_weight: int = 1, prefix: str = "", **kwargs):
"""
Initialize a Callback. This is the base class so it shouldn't usually
need to be initialized.
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index a0956e49b1d..5aa8350c822 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -112,7 +112,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
"3.2.0": "1d6611b6ab7c",
"3.3.0": "d2f4e1b3c5a7",
"3.4.0": "b4f1a7d2c903",
- "3.5.0": "5fbfa42a2d01",
+ "3.5.0": "16d7301f124b",
}
# Prefix used to identify tables holding data moved during migration.
diff --git
a/airflow-core/tests/unit/migrations/test_0153_add_index_on_callback_trigger_id.py
b/airflow-core/tests/unit/migrations/test_0153_add_index_on_callback_trigger_id.py
new file mode 100644
index 00000000000..3ad2269a6c7
--- /dev/null
+++
b/airflow-core/tests/unit/migrations/test_0153_add_index_on_callback_trigger_id.py
@@ -0,0 +1,249 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for the callback trigger index migration."""
+
+from __future__ import annotations
+
+import re
+from datetime import UTC, datetime
+from importlib import import_module
+from uuid import UUID
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy import inspect
+from sqlalchemy.exc import IntegrityError
+
+from airflow import settings
+from airflow.models.callback import Callback
+from airflow.utils.db import downgrade, upgradedb
+
+pytestmark = pytest.mark.db_test
+
+_migration =
import_module("airflow.migrations.versions.0153_3_5_0_add_index_on_callback_trigger_id")
+REVISION = _migration.revision
+PREVIOUS_REVISION = _migration.down_revision
+INDEX_NAME = "idx_callback_trigger_id"
+CALLBACK_IDS = (UUID(int=9_014_901), UUID(int=9_014_902), UUID(int=9_014_903),
UUID(int=9_014_904))
+INVALID_CALLBACK_ID = UUID(int=9_014_999)
+TEST_TIMESTAMP = datetime(2026, 10, 19, tzinfo=UTC)
+TRIGGER_CLASSPATHS = (
+ "airflow.triggers.testing.CallbackIndexTrigger1",
+ "airflow.triggers.testing.CallbackIndexTrigger2",
+)
+
+
+def _reflect_tables(engine):
+ metadata = sa.MetaData()
+ return (
+ sa.Table("trigger", metadata, autoload_with=engine),
+ sa.Table("callback", metadata, sa.Column("id", sa.Uuid(),
primary_key=True), autoload_with=engine),
+ )
+
+
+def _delete_test_rows(engine):
+ trigger, callback = _reflect_tables(engine)
+ with engine.begin() as connection:
+
connection.execute(callback.delete().where(callback.c.id.in_((*CALLBACK_IDS,
INVALID_CALLBACK_ID))))
+
connection.execute(trigger.delete().where(trigger.c.classpath.in_(TRIGGER_CLASSPATHS)))
+
+
[email protected]
+def database_at_previous_revision():
+ """Move the database to the pre-index revision, then restore the current
head."""
+ original_async_conn = settings.SQL_ALCHEMY_CONN_ASYNC
+ settings.SQL_ALCHEMY_CONN_ASYNC = ""
+ settings.dispose_orm(do_log=False)
+ settings.configure_orm(disable_connection_pool=True)
+ try:
+ downgrade(to_revision=PREVIOUS_REVISION)
+ _delete_test_rows(settings.engine)
+ yield settings.engine
+ finally:
+ try:
+ upgradedb()
+ _delete_test_rows(settings.engine)
+ finally:
+ settings.SQL_ALCHEMY_CONN_ASYNC = original_async_conn
+ settings.reconfigure_orm()
+
+
[email protected]
+def stored_rows(database_at_previous_revision):
+ trigger, callback = _reflect_tables(database_at_previous_revision)
+ with database_at_previous_revision.begin() as connection:
+ trigger_ids = tuple(
+ connection.execute(
+ trigger.insert().values(
+ classpath=classpath,
+ kwargs="{}",
+ created_date=TEST_TIMESTAMP,
+ )
+ ).inserted_primary_key[0]
+ for classpath in TRIGGER_CLASSPATHS
+ )
+ connection.execute(
+ callback.insert(),
+ [
+ {
+ "id": CALLBACK_IDS[0],
+ "type": "triggerer",
+ "fetch_method": "import_path",
+ "data": {},
+ "state": "scheduled",
+ "priority_weight": 1,
+ "created_at": TEST_TIMESTAMP,
+ "trigger_id": None,
+ },
+ {
+ "id": CALLBACK_IDS[1],
+ "type": "triggerer",
+ "fetch_method": "import_path",
+ "data": {},
+ "state": "scheduled",
+ "priority_weight": 1,
+ "created_at": TEST_TIMESTAMP,
+ "trigger_id": trigger_ids[0],
+ },
+ {
+ "id": CALLBACK_IDS[2],
+ "type": "triggerer",
+ "fetch_method": "import_path",
+ "data": {},
+ "state": "scheduled",
+ "priority_weight": 1,
+ "created_at": TEST_TIMESTAMP,
+ "trigger_id": trigger_ids[0],
+ },
+ {
+ "id": CALLBACK_IDS[3],
+ "type": "triggerer",
+ "fetch_method": "import_path",
+ "data": {},
+ "state": "scheduled",
+ "priority_weight": 1,
+ "created_at": TEST_TIMESTAMP,
+ "trigger_id": trigger_ids[1],
+ },
+ ],
+ )
+ return database_at_previous_revision, trigger_ids
+
+
+def _get_callback_rows(engine):
+ _, callback = _reflect_tables(engine)
+ with engine.connect() as connection:
+ rows = connection.execute(
+ sa.select(callback.c.id, callback.c.trigger_id)
+ .where(callback.c.id.in_(CALLBACK_IDS))
+ .order_by(callback.c.id)
+ ).all()
+ return [(row.id, row.trigger_id) for row in rows]
+
+
+def _get_callback_trigger_indexes(engine):
+ return [
+ index
+ for index in inspect(engine).get_indexes("callback")
+ if index["column_names"] and index["column_names"][0] == "trigger_id"
+ ]
+
+
+def _assert_explicit_index(engine):
+ indexes = _get_callback_trigger_indexes(engine)
+ assert [index["name"] for index in indexes] == [INDEX_NAME]
+ index = indexes[0]
+ assert index["column_names"] == ["trigger_id"]
+ assert not index["unique"]
+
+ dialect = engine.dialect.name
+ if dialect in {"postgresql", "sqlite"}:
+ predicate = index["dialect_options"][f"{dialect}_where"]
+ normalized = re.sub(r'[\s()"`]+', "", str(predicate).lower())
+ assert normalized == "trigger_idisnotnull"
+
+
+def _assert_callback_foreign_key(engine):
+ foreign_key = next(
+ constraint
+ for constraint in inspect(engine).get_foreign_keys("callback")
+ if constraint["name"] == "callback_trigger_id_fkey"
+ )
+ assert foreign_key["constrained_columns"] == ["trigger_id"]
+ assert foreign_key["referred_table"] == "trigger"
+ assert foreign_key["referred_columns"] == ["id"]
+
+
+def _assert_mysql_foreign_key_is_enforced(engine):
+ if engine.dialect.name != "mysql":
+ return
+ _, callback = _reflect_tables(engine)
+ with pytest.raises(IntegrityError), engine.begin() as connection:
+ connection.execute(
+ callback.insert().values(
+ id=INVALID_CALLBACK_ID,
+ type="triggerer",
+ fetch_method="import_path",
+ data={},
+ state="scheduled",
+ priority_weight=1,
+ created_at=TEST_TIMESTAMP,
+ trigger_id=99_999_999,
+ )
+ )
+
+
+def test_callback_model_declares_partial_trigger_id_index():
+ indexes = [index for index in Callback.__table__.indexes if index.name ==
INDEX_NAME]
+ assert len(indexes) == 1
+ index = indexes[0]
+ assert [column.name for column in index.columns] == ["trigger_id"]
+ assert not index.unique
+ assert str(index.dialect_options["postgresql"]["where"]) ==
"callback.trigger_id IS NOT NULL"
+ assert str(index.dialect_options["sqlite"]["where"]) ==
"callback.trigger_id IS NOT NULL"
+
+
[email protected]("sqlite", "postgres", "mysql")
+def
test_upgrade_downgrade_reupgrade_preserves_rows_and_foreign_key(stored_rows):
+ engine, trigger_ids = stored_rows
+ expected_rows = [
+ (CALLBACK_IDS[0], None),
+ (CALLBACK_IDS[1], trigger_ids[0]),
+ (CALLBACK_IDS[2], trigger_ids[0]),
+ (CALLBACK_IDS[3], trigger_ids[1]),
+ ]
+
+ upgradedb(to_revision=REVISION)
+ _assert_explicit_index(engine)
+ _assert_callback_foreign_key(engine)
+ assert _get_callback_rows(engine) == expected_rows
+
+ downgrade(to_revision=PREVIOUS_REVISION)
+ trigger_indexes = _get_callback_trigger_indexes(engine)
+ if engine.dialect.name == "mysql":
+ assert len(trigger_indexes) == 1
+ assert trigger_indexes[0]["name"] != INDEX_NAME
+ else:
+ assert trigger_indexes == []
+ _assert_callback_foreign_key(engine)
+ _assert_mysql_foreign_key_is_enforced(engine)
+ assert _get_callback_rows(engine) == expected_rows
+
+ upgradedb(to_revision=REVISION)
+ _assert_explicit_index(engine)
+ _assert_callback_foreign_key(engine)
+ assert _get_callback_rows(engine) == expected_rows
diff --git a/airflow-core/tests/unit/models/test_trigger.py
b/airflow-core/tests/unit/models/test_trigger.py
index 66a9fd6565f..f696e61eb0d 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -28,7 +28,7 @@ import pytz
import sqlalchemy as sa
import time_machine
from cryptography.fernet import Fernet
-from sqlalchemy import delete, event, func, insert, select, update
+from sqlalchemy import delete, event, func, insert, select, text, update
from sqlalchemy.exc import OperationalError
from airflow._shared.timezones import timezone
@@ -55,7 +55,7 @@ from airflow.triggers.base import (
from airflow.utils.session import create_session
from airflow.utils.state import State, TaskInstanceState
-from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.asserts import assert_queries_count,
capture_orm_selects
from tests_common.test_utils.config import conf_vars
if TYPE_CHECKING:
@@ -125,6 +125,20 @@ def
test_fetch_trigger_ids_with_non_task_associations(session):
assert results == {asset_trigger.id, callback_trigger.id}
[email protected]("sqlite")
+def test_clean_unused_callback_lookup_uses_callback_trigger_index(session):
+ with capture_orm_selects("trigger") as statements:
+ Trigger._delete_unused_batch(500, session=session)
+ plan = [
+ row[-1]
+ for statement in statements
+ for row in session.execute(text(f"EXPLAIN QUERY PLAN
{statement.removesuffix(' FOR UPDATE')}")).all()
+ ]
+
+ assert statements
+ assert any("callback" in row and "idx_callback_trigger_id" in row for row
in plan), plan
+
+
def test_fetch_assignments_maps_surviving_rows_to_their_triggerer(session):
owned = Trigger(classpath="airflow.triggers.testing.SuccessTrigger1",
kwargs={})
owned.triggerer_id = 42