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 eacefc750c8 Pin the statement budget for persisting Dag parse results 
(#71720)
eacefc750c8 is described below

commit eacefc750c83f85c9760af20dfb603805f146fbd
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Thu Aug 20 08:55:51 2026 +0100

    Pin the statement budget for persisting Dag parse results (#71720)
    
    * Pin the statement budget for persisting Dag parse results
    
    The Dag processor's manager issues these statements once per parsed file, 
and how
    many was written down nowhere, so a change could add round trips to that 
path
    unnoticed.
    
    Measure it: a call costs 10 statements per file plus 3 per Dag the file 
defines,
    and a sweep pays that fixed price once per persistence call -- currently 
one call
    per file. Those counts are calibrated against Postgres, and the tests 
carrying
    them are marked for it, since statement counts differ by dialect.
    
    The sweep cost is measured through _collect_results and derives both prices 
from
    its own measurements, so it carries no dialect-specific number, runs on any
    backend, and tracks whatever strategy the manager uses.
    
    Timing this proved unreliable: on a throttled single-core container, repeat 
runs
    of identical code vary by more than a third, far larger than the savings 
under
    consideration. Counting statements is exact.
    
    * Keep the statement budget in the Dag processor's own test file
    
    Airflow's only stated rule for test layout is that a test file mirrors
    its source module, so a budget measured on dag_processing/manager.py
    belongs in test_manager.py. Kept apart, it also had to grow its own
    copy of the finished-processor fixture the manager's test class
    already provides.
---
 .../tests/unit/dag_processing/test_manager.py      | 198 ++++++++++++++++++++-
 1 file changed, 196 insertions(+), 2 deletions(-)

diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index a6f82b13375..88d835169b0 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -28,7 +28,8 @@ import signal
 import textwrap
 import time
 import zipfile
-from collections import OrderedDict, defaultdict, namedtuple
+from collections import Counter, OrderedDict, defaultdict, namedtuple
+from contextlib import contextmanager
 from datetime import datetime, timedelta
 from pathlib import Path
 from socket import socket, socketpair
@@ -38,7 +39,7 @@ from unittest.mock import MagicMock
 import msgspec
 import pytest
 import time_machine
-from sqlalchemy import func, select
+from sqlalchemy import event, func, select
 from sqlalchemy.exc import OperationalError
 from uuid6 import uuid7
 
@@ -46,6 +47,7 @@ from airflow._shared.timezones import timezone
 from airflow.callbacks.callback_requests import DagCallbackRequest
 from airflow.dag_processing.bundles.base import BaseDagBundle
 from airflow.dag_processing.bundles.manager import DagBundlesManager
+from airflow.dag_processing.collection import update_dag_parsing_results_in_db
 from airflow.dag_processing.dagbag import DagBag
 from airflow.dag_processing.manager import (
     BundleState,
@@ -61,6 +63,9 @@ from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagcode import DagCode
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.team import Team
+from airflow.providers.standard.operators.empty import EmptyOperator
+from airflow.sdk import DAG as SdkDAG
+from airflow.serialization.serialized_objects import LazyDeserializedDAG
 from airflow.utils.net import get_hostname
 from airflow.utils.session import create_session
 
@@ -191,6 +196,81 @@ def _create_zip_bundle_with_keywordless_dag(zip_path: 
Path) -> None:
         )
 
 
+def _make_serialized_dags(
+    dag_file: Path, dag_ids: list[str], rel_path: str, *, n_tasks: int = 1
+) -> list[LazyDeserializedDAG]:
+    """
+    Serialized Dags filed under ``rel_path``, backed by a real file.
+
+    DagCode reads the source off disk; without a real file the Dags fail to 
serialize and the
+    measured statements stop resembling a real parse.
+    """
+    dag_file.parent.mkdir(parents=True, exist_ok=True)
+    dag_file.write_text("# statement budget fixture\n")
+
+    dags = []
+    for dag_id in dag_ids:
+        dag = SdkDAG(dag_id=dag_id, schedule="@daily")
+        for task in range(n_tasks):
+            EmptyOperator(task_id=f"task{task}", dag=dag)
+        dag.fileloc = str(dag_file)
+        dag.relative_fileloc = rel_path
+        dags.append(LazyDeserializedDAG.from_dag(dag))
+    return dags
+
+
+def _classify_statement(statement: str) -> tuple[str, str]:
+    """Reduce a statement to (operation, table) so a budget failure says what 
changed, not just how much."""
+    collapsed = " ".join(statement.split()).lower()
+    operation = collapsed.split(" ", 1)[0]
+    patterns = {
+        "select": r"\bfrom\s+([a-z_][a-z0-9_]*)",
+        "delete": r"\bfrom\s+([a-z_][a-z0-9_]*)",
+        "insert": r"\binto\s+([a-z_][a-z0-9_]*)",
+        "update": r"\bupdate\s+([a-z_][a-z0-9_]*)",
+    }
+    match = re.search(patterns[operation], collapsed) if operation in patterns 
else None
+    return operation, (match.group(1) if match else "?")
+
+
+@contextmanager
+def _count_statements(session):
+    """
+    Count emitted statements, grouped by operation and table.
+
+    ``CountQueries`` groups by call site instead; a budget that moves needs to 
name the table that
+    gained a round trip. Counting on the bind rather than the session catches 
the sessions the
+    manager opens for itself.
+    """
+    counts: Counter[tuple[str, str]] = Counter()
+
+    def _capture(conn, cursor, statement, parameters, context, executemany):
+        counts[_classify_statement(statement)] += 1
+
+    bind = session.get_bind()
+    event.listen(bind, "before_cursor_execute", _capture)
+    try:
+        yield counts
+    finally:
+        event.remove(bind, "before_cursor_execute", _capture)
+
+
+def _statement_breakdown(counts: Counter[tuple[str, str]]) -> str:
+    return "\n".join(f"  {n:>3}  {op.upper():<6} {table}" for (op, table), n 
in sorted(counts.items()))
+
+
+# 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
+UNCHANGED_PER_DAG = 3
+REWRITE_PER_DAG = 5
+
+SWEEP_FILES = 4
+# Calls the manager takes for that sweep: one per file today, 1 if a sweep is 
ever batched.
+SWEEP_CALLS = 4
+
+
 class TestDagFileProcessorManager:
     @pytest.fixture(autouse=True)
     def _disable_examples(self):
@@ -3465,6 +3545,120 @@ class TestDagFileProcessorManager:
         assert manager._bundle_versions["mock_bundle"] == "newhash"
         assert manager._bundle_version_data["mock_bundle"] == test_data
 
+    # --- statement budget ---
+    #
+    # A change that adds round trips to persistence has to move a number here 
and account for it in
+    # review. The per-call counts are calibrated against Postgres and marked 
for it, since statement
+    # counts differ by dialect; the sweep test measures both of its prices, so 
it runs anywhere.
+
+    def _ready_processor(self, manager, rel_path: str, dag_dir: Path, dag_ids: 
list[str]):
+        """Register a finished parse of ``rel_path``, with its Dags backed by 
a real file."""
+        file = DagFileInfo(bundle_name="testing", rel_path=Path(rel_path), 
bundle_path=dag_dir)
+        manager._file_stats.setdefault(file, DagFileStat())
+        processor, _ = self.mock_processor(start_time=time.monotonic() - 1)
+        processor.had_callbacks = False
+        processor.parsing_result = DagFileParsingResult(
+            fileloc=str(dag_dir / rel_path),
+            serialized_dags=_make_serialized_dags(dag_dir / rel_path, dag_ids, 
rel_path),
+        )
+        manager._processors[file] = processor
+        return file
+
+    @staticmethod
+    def _measure_persistence_call(
+        session, dags: list[LazyDeserializedDAG], counted: 
list[LazyDeserializedDAG], rel_path: str
+    ) -> Counter[tuple[str, str]]:
+        """Count one steady-state call: the first pass inserts the rows, the 
counted pass re-persists."""
+        files_parsed = {("testing", rel_path)}
+        errors: dict = {}
+
+        update_dag_parsing_results_in_db(
+            "testing", None, dags, errors, 0.1, set(), session, 
files_parsed=files_parsed
+        )
+        session.commit()
+        assert not errors, f"fixture Dags must serialize cleanly: {errors}"
+
+        with _count_statements(session) as counts:
+            update_dag_parsing_results_in_db(
+                "testing", None, counted, errors, 0.1, set(), session, 
files_parsed=files_parsed
+            )
+            session.flush()
+        return counts
+
+    def _measure_sweep(self, session, tmp_path: Path, n_files: int, name: str, 
dags_per_file: int = 1) -> int:
+        """Count a steady-state sweep through ``_collect_results``."""
+        manager = DagFileProcessorManager(max_runs=1)
+        manager._bundle_versions["testing"] = None
+        sweep_dir = tmp_path / name
+        sweep_dir.mkdir()
+
+        def register():
+            for i in range(n_files):
+                self._ready_processor(
+                    manager, f"file_{i}.py", sweep_dir, [f"dag_{i}_{d}" for d 
in range(dags_per_file)]
+                )
+
+        register()
+        manager._collect_results()
+
+        # Collecting consumed the processors, so register a second set for the 
counted sweep.
+        register()
+        with _count_statements(session) as counts:
+            manager._collect_results()
+        return sum(counts.values())
+
+    @pytest.mark.backend("postgres")
+    @pytest.mark.parametrize("n_dags", [1, 5])
+    @pytest.mark.parametrize(
+        ("rewrite", "per_dag"),
+        [
+            pytest.param(False, UNCHANGED_PER_DAG, id="unchanged"),
+            pytest.param(True, REWRITE_PER_DAG, id="rewrite"),
+        ],
+    )
+    def test_persisting_one_file_stays_within_its_statement_budget(
+        self, rewrite, per_dag, n_dags, session, testing_dag_bundle, tmp_path
+    ):
+        """What one file's parse result costs to persist, unchanged and 
rewritten."""
+        rel_path = "budget_dags.py"
+        dag_ids = [f"budget_dag_{i}" for i in range(n_dags)]
+        dags = _make_serialized_dags(tmp_path / rel_path, dag_ids, rel_path)
+        # A moved hash is what sends the call down the write path; the update 
interval only gates how
+        # soon it can get there.
+        counted = (
+            _make_serialized_dags(tmp_path / rel_path, dag_ids, rel_path, 
n_tasks=2) if rewrite else dags
+        )
+
+        with conf_vars({("core", "min_serialized_dag_update_interval"): "0" if 
rewrite else "30"}):
+            counts = self._measure_persistence_call(session, dags, counted, 
rel_path)
+
+        expected = FIXED_PER_CALL + n_dags * per_dag
+        total = sum(counts.values())
+        assert total == expected, (
+            f"a {n_dags}-Dag file costs {total} statements, expected 
{expected} "
+            f"({FIXED_PER_CALL} per call + {per_dag} per 
Dag).\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.
+
+        Batching a sweep into one call moves ``SWEEP_CALLS`` to 1. Both prices 
are measured here, so
+        the assertion holds on any backend.
+        """
+        one_dag = self._measure_sweep(session, tmp_path, 1, "one")
+        two_dags = self._measure_sweep(session, tmp_path, 1, "two", 
dags_per_file=2)
+        per_dag = two_dags - one_dag
+        fixed = one_dag - per_dag
+
+        sweep = self._measure_sweep(session, tmp_path, SWEEP_FILES, "sweep")
+
+        expected = SWEEP_CALLS * fixed + SWEEP_FILES * per_dag
+        assert sweep == expected, (
+            f"a {SWEEP_FILES}-file sweep costs {sweep} statements, expected 
{expected} "
+            f"({SWEEP_CALLS} x {fixed} fixed + {SWEEP_FILES} x {per_dag} per 
Dag)."
+        )
+
 
 class TestMultiTeamMetrics:
     """Tests for team_name tag on dag processing metrics in multi-team mode."""

Reply via email to