This is an automated email from the ASF dual-hosted git repository.

ephraimbuddy 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 8e14c2960eb Cut the per-file cost of persisting Dag parse results 
(#71621)
8e14c2960eb is described below

commit 8e14c2960ebdd3b5ccdf27a6e8ef17006a80124f
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Wed Aug 26 22:56:56 2026 +0100

    Cut the per-file cost of persisting Dag parse results (#71621)
    
    * Cut the per-file cost of persisting Dag parse results
    
    The Dag processor's manager persists parse results one file at a time, and 
on
    small deployments that process is the CPU bottleneck. Two costs were paid 
there
    for every parsed file.
    
    Import errors were read by loading the whole import_error table, even for 
files
    that had none. The lookup is now scoped to the keys being written. The 
table has
    no index on (bundle_name, filename), so this saves materialising every row
    rather than the scan itself.
    
    The serialized Dag was hashed twice per write, once for change detection and
    again in the model constructor, and hashing re-sorts and re-encodes the 
whole
    structure.
    
    The compressed-storage test parametrization never ran, because the module 
binds
    that flag at import. It is made real here, since this change moves code into
    that branch.
    
    From @seanmuth's investigation of the Airflow 2-to-3 dag-processor CPU
    regression: github.com/seanmuth/af2-af3-scheduler-cpu-repro. This is two of 
the
    smaller levers; the per-sweep result batching is left for follow-up.
    
    * Say less around the bounded import-error lookup
    
    Review feedback: the comments explained more than the code needed, and
    falling back with `or` reads better than a None check for a value that is
    always an md5 digest when it is set at all.
    
    * Drop the per-call statement price the bounded lookup saves
    
    The budget pinned in #71720 exists so a change to what persistence costs has
    to move a number and account for it. This one does: a file that parses
    cleanly no longer looks up import errors it has none of, so every call is a
    statement cheaper.
    
    * Pin what a file reporting an import error costs to persist
    
    The budget covered only files that parse cleanly, which is the half of the
    call this change makes cheaper. The half that still looks its errors up was
    unpriced, so nothing would notice a round trip appearing there.
---
 .../src/airflow/dag_processing/collection.py       |  22 +++--
 airflow-core/src/airflow/models/serialized_dag.py  |  21 ++--
 .../tests/unit/dag_processing/test_collection.py   | 100 ++++++++++++++++++-
 .../tests/unit/dag_processing/test_manager.py      |  44 ++++++++-
 .../tests/unit/models/test_serialized_dag.py       | 106 +++++++++++++++++++++
 5 files changed, 275 insertions(+), 18 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/collection.py 
b/airflow-core/src/airflow/dag_processing/collection.py
index 11e1099de7b..36b948abad6 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -382,16 +382,24 @@ def _update_dag_warnings(
 
 def _update_import_errors(
     files_parsed: set[tuple[str, str]],
-    bundle_name: str,
     import_errors: dict[tuple[str, str], str],
     session: Session,
 ):
     from airflow.listeners.listener import get_listener_manager
 
-    # Check existing import errors BEFORE deleting, so we can determine if we 
should update or create
-    existing_import_error_files = set(
-        session.execute(select(ParseImportError.bundle_name, 
ParseImportError.filename))
-    )
+    # Read before the delete below, so an error still present is updated 
rather than recreated.
+    # Scoped to the keys being written: the table is not indexed on 
(bundle_name, filename) so this
+    # still scans, but it no longer builds a row per import error the 
deployment is carrying.
+    existing_import_error_files: set[tuple[str | None, str | None]] = set()
+    if import_errors:
+        existing_import_error_files = {
+            (bundle, filename)
+            for bundle, filename in session.execute(
+                select(ParseImportError.bundle_name, 
ParseImportError.filename).where(
+                    tuple_(ParseImportError.bundle_name, 
ParseImportError.filename).in_(list(import_errors))
+                )
+            )
+        }
 
     # Delete errors for files that were parsed but don't have errors in 
import_errors
     # (i.e., files that were successfully parsed without errors)
@@ -439,7 +447,7 @@ def _update_import_errors(
         else:
             import_error = ParseImportError(
                 filename=relative_fileloc,
-                bundle_name=bundle_name,
+                bundle_name=bundle_name_,
                 timestamp=utcnow(),
                 stacktrace=stacktrace,
             )
@@ -459,7 +467,6 @@ def _update_import_errors(
             )
             .values(
                 has_import_errors=True,
-                bundle_name=bundle_name,
                 is_stale=True,
             )
             .execution_options(synchronize_session="fetch")
@@ -556,7 +563,6 @@ def update_dag_parsing_results_in_db(
     try:
         _update_import_errors(
             files_parsed=files_parsed if files_parsed is not None else set(),
-            bundle_name=bundle_name,
             import_errors=import_errors,
             session=session,
         )
diff --git a/airflow-core/src/airflow/models/serialized_dag.py 
b/airflow-core/src/airflow/models/serialized_dag.py
index 355c99f5291..d49c168013f 100644
--- a/airflow-core/src/airflow/models/serialized_dag.py
+++ b/airflow-core/src/airflow/models/serialized_dag.py
@@ -347,15 +347,20 @@ class SerializedDagModel(Base):
     load_op_links = True
     __table_args__ = (Index("idx_serialized_dag_dag_id_created_at", dag_id, 
created_at),)
 
-    def __init__(self, dag: LazyDeserializedDAG) -> None:
+    def __init__(self, dag: LazyDeserializedDAG, *, _dag_hash: str | None = 
None) -> None:
+        """
+        Build a serialized Dag row.
+
+        :param _dag_hash: hash of ``dag.data``, when the caller has already 
computed one. It has to
+            match the data as it stands, or the next parse misreads whether 
the Dag changed.
+        """
         self.dag_id = dag.dag_id
         dag_data = dag.data
-        self.dag_hash = SerializedDagModel.hash(dag_data)
-
-        # partially ordered json data
-        dag_data_json = json.dumps(dag_data, sort_keys=True).encode("utf-8")
+        self.dag_hash = _dag_hash or SerializedDagModel.hash(dag_data)
 
         if _COMPRESS_SERIALIZED_DAGS:
+            # partially ordered json data
+            dag_data_json = json.dumps(dag_data, 
sort_keys=True).encode("utf-8")
             self._data = None
             self._data_compressed = zlib.compress(dag_data_json)
         else:
@@ -734,7 +739,7 @@ class SerializedDagModel(Base):
             # This is for dynamic DAGs that the hashes changes often. We 
should update
             # the serialized dag, the dag_version and the dag_code instead of 
a new version
             # if the dag_version is not associated with any task instances
-            new_serialized_dag = cls(dag)
+            new_serialized_dag = cls(dag, _dag_hash=new_dag_hash)
 
             # Use direct UPDATE to avoid loading the full serialized DAG
             result = session.execute(
@@ -788,8 +793,10 @@ class SerializedDagModel(Base):
         if reused_deadline_data:
             deadline_uuid_mapping = {str(uuid6.uuid7()): data for data in 
reused_deadline_data.values()}
             dag.data["dag"]["deadline"] = list(deadline_uuid_mapping.keys())
+            # The data just changed, so the hash computed above no longer 
describes it.
+            new_dag_hash = cls.hash(dag.data)
 
-        new_serialized_dag = cls(dag)
+        new_serialized_dag = cls(dag, _dag_hash=new_dag_hash)
         new_serialized_dag.dag_version = dagv
         session.add(new_serialized_dag)
 
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py 
b/airflow-core/tests/unit/dag_processing/test_collection.py
index 8c21119305a..be36290abce 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -27,7 +27,7 @@ from unittest import mock
 from unittest.mock import patch
 
 import pytest
-from sqlalchemy import delete, func, inspect as sa_inspect, select
+from sqlalchemy import delete, event, func, inspect as sa_inspect, select
 from sqlalchemy.exc import OperationalError, SAWarning
 
 import airflow.dag_processing.collection
@@ -39,6 +39,7 @@ from airflow.dag_processing.collection import (
     _get_latest_runs_stmt,
     _get_latest_runs_stmt_partitioned,
     _update_dag_tags,
+    _update_import_errors,
     update_dag_parsing_results_in_db,
 )
 from airflow.exceptions import SerializationError
@@ -1467,6 +1468,103 @@ class TestUpdateDagParsingResults:
             assert orm_dag.max_consecutive_failed_dag_runs == 6
 
 
[email protected]_test
+class TestUpdateImportErrors:
+    """Tests for the ``_update_import_errors`` helper."""
+
+    @pytest.fixture(autouse=True)
+    def clean_import_errors(self):
+        clear_db_import_errors()
+        yield
+        clear_db_import_errors()
+
+    @pytest.fixture
+    def import_error_statements(self, session):
+        """
+        Collect every SQL statement issued against the ``import_error`` table.
+
+        Matching on the bare table name would also catch statements naming 
``dag.has_import_errors``,
+        so match the positions where the table itself can appear.
+        """
+        statements: list[str] = []
+
+        def _capture(conn, cursor, statement, parameters, context, 
executemany):
+            lowered = statement.lower()
+            if any(f"{keyword} import_error" in lowered for keyword in 
("from", "into", "update")):
+                statements.append(statement)
+
+        bind = session.get_bind()
+        event.listen(bind, "before_cursor_execute", _capture)
+        yield statements
+        event.remove(bind, "before_cursor_execute", _capture)
+
+    @staticmethod
+    def _selects(statements: list[str]) -> list[str]:
+        return [stmt for stmt in statements if 
stmt.lower().lstrip().startswith("select")]
+
+    def test_no_lookup_when_there_are_no_import_errors(self, session, 
import_error_statements):
+        session.add(ParseImportError(filename="broken.py", 
bundle_name="testing", stacktrace="boom"))
+        session.flush()
+        import_error_statements.clear()
+
+        # files_parsed is empty so no DELETE runs either: on backends without 
DELETE...RETURNING
+        # its synchronize_session fallback would emit a SELECT of its own and 
muddy the assertion.
+        _update_import_errors(
+            files_parsed=set(),
+            import_errors={},
+            session=session,
+        )
+
+        assert self._selects(import_error_statements) == []
+
+    @patch.object(ParseImportError, "full_file_path", return_value="broken.py")
+    def test_existing_error_lookup_is_bounded(self, _mock_full_path, session, 
import_error_statements):
+        session.add_all(
+            [
+                ParseImportError(filename="broken.py", bundle_name="testing", 
stacktrace="old"),
+                ParseImportError(filename="untouched.py", bundle_name="other", 
stacktrace="unrelated"),
+            ]
+        )
+        session.flush()
+        import_error_statements.clear()
+
+        _update_import_errors(
+            files_parsed={("testing", "broken.py")},
+            import_errors={("testing", "broken.py"): "new"},
+            session=session,
+        )
+
+        selects = self._selects(import_error_statements)
+        assert selects, "expected the existing-error lookup to run"
+        assert all("where" in stmt.lower() for stmt in selects), (
+            f"import_error must never be scanned unfiltered, got: {selects}"
+        )
+
+        rows = sorted(
+            (err.bundle_name, err.filename, err.stacktrace)
+            for err in session.scalars(select(ParseImportError))
+        )
+        assert rows == [
+            ("other", "untouched.py", "unrelated"),
+            ("testing", "broken.py", "new"),
+        ]
+
+    @patch.object(ParseImportError, "full_file_path", return_value="broken.py")
+    def test_new_errors_keep_their_own_bundle_name(self, _mock_full_path, 
session):
+        _update_import_errors(
+            files_parsed=set(),
+            import_errors={
+                ("bundle_a", "a.py"): "error a",
+                ("bundle_b", "b.py"): "error b",
+            },
+            session=session,
+        )
+        session.flush()
+
+        rows = {(err.bundle_name, err.filename) for err in 
session.scalars(select(ParseImportError))}
+        assert rows == {("bundle_a", "a.py"), ("bundle_b", "b.py")}
+
+
 @pytest.mark.db_test
 class TestUpdateDagTags:
     @pytest.fixture(autouse=True)
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 88d835169b0..0c6fdbd1deb 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -261,10 +261,15 @@ def _statement_breakdown(counts: Counter[tuple[str, 
str]]) -> str:
 
 # Per persistence call, and per Dag in the file. A call leaves the serialized 
Dag alone while the
 # content is unchanged; once the hash has moved and [core] 
min_serialized_dag_update_interval has
-# lapsed it rewrites it, which costs two more statements per Dag and nothing 
extra per call.
-FIXED_PER_CALL = 10
+# lapsed it rewrites it, which costs two more statements per Dag and nothing 
extra per call. The
+# per-call price is a file that parsed cleanly: one reporting import errors 
also looks up whichever
+# of them are already recorded.
+FIXED_PER_CALL = 9
 UNCHANGED_PER_DAG = 3
 REWRITE_PER_DAG = 5
+# A file that failed to parse and so defines no Dags. Two of the five are 
import_error SELECTs: the
+# bounded lookup, and the listener re-reading the row the update beside it 
already had.
+IMPORT_ERROR_PER_CALL = 5
 
 SWEEP_FILES = 4
 # Calls the manager takes for that sweep: one per file today, 1 if a sweep is 
ever batched.
@@ -3607,6 +3612,24 @@ class TestDagFileProcessorManager:
             manager._collect_results()
         return sum(counts.values())
 
+    @staticmethod
+    def _measure_import_error_call(session, rel_path: str) -> 
Counter[tuple[str, str]]:
+        """Count one steady-state call for a file that fails to parse with its 
error already recorded."""
+        files_parsed = {("testing", rel_path)}
+        recorded = {("testing", rel_path): "boom"}
+        update_dag_parsing_results_in_db(
+            "testing", None, [], recorded, 0.1, set(), session, 
files_parsed=files_parsed
+        )
+        session.commit()
+
+        again = {("testing", rel_path): "boom again"}
+        with _count_statements(session) as counts:
+            update_dag_parsing_results_in_db(
+                "testing", None, [], again, 0.1, set(), session, 
files_parsed=files_parsed
+            )
+            session.flush()
+        return counts
+
     @pytest.mark.backend("postgres")
     @pytest.mark.parametrize("n_dags", [1, 5])
     @pytest.mark.parametrize(
@@ -3639,6 +3662,23 @@ class TestDagFileProcessorManager:
             f"({FIXED_PER_CALL} per call + {per_dag} per 
Dag).\n{_statement_breakdown(counts)}"
         )
 
+    @pytest.mark.backend("postgres")
+    def test_a_file_reporting_an_import_error_stays_within_its_budget(
+        self, session, testing_dag_bundle, tmp_path
+    ):
+        """
+        The path a clean parse skips: a file that looks up the errors already 
recorded for it.
+
+        Not comparable to ``FIXED_PER_CALL``, which is priced with Dags to 
write; this file has none.
+        """
+        counts = self._measure_import_error_call(session, "broken.py")
+
+        total = sum(counts.values())
+        assert total == IMPORT_ERROR_PER_CALL, (
+            f"a file reporting one import error costs {total}, expected 
{IMPORT_ERROR_PER_CALL}."
+            f"\n{_statement_breakdown(counts)}"
+        )
+
     def test_a_sweep_pays_the_fixed_cost_once_per_call(self, session, 
testing_dag_bundle, tmp_path):
         """
         How a sweep scales with the number of persistence calls it takes.
diff --git a/airflow-core/tests/unit/models/test_serialized_dag.py 
b/airflow-core/tests/unit/models/test_serialized_dag.py
index f3d720ddcc6..7609c57ba01 100644
--- a/airflow-core/tests/unit/models/test_serialized_dag.py
+++ b/airflow-core/tests/unit/models/test_serialized_dag.py
@@ -29,6 +29,7 @@ import pytest
 from sqlalchemy import delete, func, select, update
 
 import airflow.example_dags as example_dags_module
+import airflow.models.serialized_dag as serialized_dag_module
 from airflow._shared.observability.metrics.base_stats_logger import StatsLogger
 from airflow.dag_processing.dagbag import DagBag
 from airflow.models.asset import AssetActive, AssetAliasModel, AssetModel
@@ -106,6 +107,9 @@ class TestSerializedDagModel:
         db.clear_db_dags()
         db.clear_db_runs()
         db.clear_db_serialized_dags()
+        # The module reads the config once at import time, so conf_vars alone 
leaves the already
+        # bound constant untouched and the compressed branch never runs.
+        monkeypatch.setattr(serialized_dag_module, 
"_COMPRESS_SERIALIZED_DAGS", request.param)
         with conf_vars({("core", "compress_serialized_dags"): 
str(request.param)}):
             yield
         db.clear_db_serialized_dags()
@@ -1172,6 +1176,108 @@ class TestSerializedDagModel:
         old_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id == 
orig_serdag.id))
         assert old_alert is not None
 
+    def test_write_dag_hashes_the_dag_once_when_inserting(self, 
testing_dag_bundle, session):
+        """The hash computed for change detection is reused for the new row 
rather than recomputed."""
+        dag_id = "test_hash_once_insert"
+        dag = DAG(dag_id=dag_id)
+        EmptyOperator(task_id="task1", dag=dag)
+        scheduler_dag = sync_dag_to_db(dag, session=session)
+
+        # Task instances on the current version force write_dag to insert a 
new row.
+        scheduler_dag.create_dagrun(
+            run_id="test1",
+            run_after=DEFAULT_DATE,
+            state=DagRunState.QUEUED,
+            logical_date=DEFAULT_DATE,
+            data_interval=(DEFAULT_DATE, DEFAULT_DATE),
+            triggered_by=DagRunTriggeredByType.TEST,
+            run_type=DagRunType.MANUAL,
+        )
+        session.commit()
+
+        EmptyOperator(task_id="task2", dag=dag)
+        with mock.patch.object(SDM, "hash", wraps=SDM.hash) as hash_spy:
+            SDM.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name="testing", session=session)
+        session.commit()
+
+        assert hash_spy.call_count == 1
+        assert session.scalar(select(func.count()).where(SDM.dag_id == 
dag_id)) == 2
+        # Expunge so ``data`` is read back from the row rather than the cache 
``__init__`` populated.
+        session.expunge_all()
+        serdag = session.scalar(select(SDM).where(SDM.dag_id == 
dag_id).order_by(SDM.created_at.desc()))
+        assert serdag.dag_hash == SDM.hash(serdag.data)
+
+    def test_write_dag_hashes_the_dag_once_when_updating_in_place(self, 
testing_dag_bundle, session):
+        """The in-place UPDATE branch reuses the hash too."""
+        dag_id = "test_hash_once_update"
+        dag = DAG(dag_id=dag_id)
+        EmptyOperator(task_id="task1", dag=dag)
+        sync_dag_to_db(dag, session=session)
+        session.commit()
+        original_hash = session.scalar(select(SDM.dag_hash).where(SDM.dag_id 
== dag_id))
+
+        # No task instances exist for the current version, so a changed Dag 
updates the row in place.
+        EmptyOperator(task_id="task2", dag=dag)
+        with mock.patch.object(SDM, "hash", wraps=SDM.hash) as hash_spy:
+            SDM.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name="testing", session=session)
+        session.commit()
+
+        assert hash_spy.call_count == 1
+        assert session.scalar(select(func.count()).where(SDM.dag_id == 
dag_id)) == 1
+        # Expunge so ``data`` is read back from the row rather than the cache 
``__init__`` populated.
+        session.expunge_all()
+        serdag = session.scalar(select(SDM).where(SDM.dag_id == dag_id))
+        # Without this the test would also pass if write_dag had done nothing 
at all: one hash is
+        # computed for change detection either way and an unwritten row is 
trivially consistent.
+        assert serdag.dag_hash != original_hash
+        assert serdag.dag_hash == SDM.hash(serdag.data)
+
+    def test_deadline_uuid_regeneration_keeps_hash_consistent(self, 
testing_dag_bundle, session):
+        """Regenerated deadline UUIDs rewrite dag.data, so the stored hash 
must describe the rewrite."""
+        dag_id = "test_hash_deadline_regenerated"
+
+        dag = DAG(
+            dag_id=dag_id,
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                interval=timedelta(minutes=5),
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+        EmptyOperator(task_id="task1", dag=dag)
+        scheduler_dag = sync_dag_to_db(dag, session=session)
+
+        # Task instances on the current version force write_dag down the 
INSERT branch, where the
+        # reused deadline UUIDs are regenerated after the change-detection 
hash was computed.
+        scheduler_dag.create_dagrun(
+            run_id="test1",
+            run_after=DEFAULT_DATE,
+            state=DagRunState.QUEUED,
+            logical_date=DEFAULT_DATE,
+            data_interval=(DEFAULT_DATE, DEFAULT_DATE),
+            triggered_by=DagRunTriggeredByType.TEST,
+            run_type=DagRunType.MANUAL,
+        )
+        session.commit()
+
+        session.expunge_all()
+        original = session.scalar(select(SDM).where(SDM.dag_id == 
dag_id).order_by(SDM.created_at.desc()))
+        original_deadlines = list(original.data["dag"]["deadline"])
+
+        # Non-deadline edit, so the deadline definitions still match and their 
UUIDs are reused.
+        EmptyOperator(task_id="task2", dag=dag)
+        SDM.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name="testing", session=session)
+        session.commit()
+
+        # Expunge so ``data`` is read back from the row rather than the cache 
``__init__`` populated.
+        session.expunge_all()
+        serdag = session.scalar(select(SDM).where(SDM.dag_id == 
dag_id).order_by(SDM.created_at.desc()))
+        # Proves the reuse branch actually ran: it swaps the reused UUIDs for 
fresh ones after the
+        # change-detection hash was taken. Without this the hash assertion 
holds vacuously when
+        # write_dag does nothing.
+        assert serdag.data["dag"]["deadline"] != original_deadlines
+        assert serdag.dag_hash == SDM.hash(serdag.data)
+
     def test_non_deadline_edit_preserves_alert_in_update_branch(self, 
testing_dag_bundle, session):
         """UPDATE branch (no task instances): existing deadline_alert stays 
linked after non-deadline edit."""
         dag_id = "test_deadline_update_branch"

Reply via email to