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

ephraimanierobi 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 56d1b1ffaa5 Extract persist_parsing_result from 
DagFileProcessorManager for DB call Isolation (#65313)
56d1b1ffaa5 is described below

commit 56d1b1ffaa5886458d2112f120483116f72ce4e2
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Fri Apr 17 15:10:32 2026 +0100

    Extract persist_parsing_result from DagFileProcessorManager for DB call 
Isolation (#65313)
    
    * Extract persist_parsing_result from DagFileProcessorManager for DB call 
isolation
    
    Split process_parse_results into stat/metrics creation and DB persistence
    so we can swap direct database writes with API calls by overriding
    a single method — persist_parsing_result.
    
    * fixup! Extract persist_parsing_result from DagFileProcessorManager for DB 
call isolation
    
    * fixup! fixup! Extract persist_parsing_result from DagFileProcessorManager 
for DB call isolation
---
 airflow-core/src/airflow/dag_processing/manager.py | 158 ++++++++++++++-------
 .../tests/unit/dag_processing/test_manager.py      | 105 +++++++++++++-
 .../tests/unit/dag_processing/test_processor.py    |  47 +++++-
 3 files changed, 254 insertions(+), 56 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/manager.py 
b/airflow-core/src/airflow/dag_processing/manager.py
index 7fcafa98bea..5fc34140524 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -982,6 +982,109 @@ class DagFileProcessorManager(LoggingMixin):
                 processor.logger_filehandle.close()
                 self._file_stats.pop(file, None)
 
+    def handle_parsing_result(
+        self, file: DagFileInfo, proc: DagFileProcessorProcess, *, session: 
Session
+    ) -> None:
+        """
+        Post-process a single finished parse result.
+
+        Detects callback-only processing, updates file stats, emits metrics,
+        and persists DAGs/import-errors via :meth:`persist_parsing_result`.
+        Extracted from ``_collect_results`` to keep result handling and
+        persistence separate.
+
+        If persistence fails, the error is logged and the previous persisted
+        DAG/import-error counts are preserved while a minimal timestamp update
+        throttles immediate retries, so other files in the same
+        ``_collect_results`` cycle still run.
+        """
+        is_callback_only = proc.had_callbacks and proc.parsing_result is None
+        if is_callback_only:
+            self.log.debug("Detected callback-only processing for %s", file)
+
+        run_duration = time.monotonic() - proc.start_time
+        finish_time = timezone.utcnow()
+        next_stat = process_parse_results(
+            run_duration=run_duration,
+            finish_time=finish_time,
+            run_count=self._file_stats[file].run_count,
+            bundle_name=file.bundle_name,
+            bundle_version=self._bundle_versions[file.bundle_name],
+            parsing_result=proc.parsing_result,
+            is_callback_only=is_callback_only,
+            relative_fileloc=str(file.rel_path),
+        )
+
+        if proc.parsing_result is not None:
+            try:
+                self.persist_parsing_result(
+                    bundle_name=file.bundle_name,
+                    bundle_version=self._bundle_versions[file.bundle_name],
+                    parsing_result=proc.parsing_result,
+                    run_duration=run_duration,
+                    relative_fileloc=str(file.rel_path),
+                    session=session,
+                )
+            except Exception:
+                self.log.exception(
+                    "Failed to persist parsing result for %s in bundle %s; "
+                    "keeping previous persisted stats while throttling 
retries. "
+                    "Other files in this cycle are still processed.",
+                    str(file.rel_path),
+                    file.bundle_name,
+                )
+                current_stat = self._file_stats[file]
+                self._file_stats[file] = DagFileStat(
+                    num_dags=current_stat.num_dags,
+                    import_errors=current_stat.import_errors,
+                    last_finish_time=finish_time,
+                    last_duration=run_duration,
+                    run_count=current_stat.run_count + 1,
+                    last_num_of_db_queries=current_stat.last_num_of_db_queries,
+                )
+                return
+
+        self._file_stats[file] = next_stat
+
+    def persist_parsing_result(
+        self,
+        *,
+        bundle_name: str,
+        bundle_version: str | None,
+        parsing_result: DagFileParsingResult,
+        run_duration: float,
+        relative_fileloc: str | None,
+        session: Session,
+    ) -> None:
+        """Persist parsed DAG data to the metadata database."""
+        import_errors: dict[tuple[str, str], str] = {}
+        if parsing_result.import_errors:
+            import_errors = {
+                (bundle_name, rel_path): error for rel_path, error in 
parsing_result.import_errors.items()
+            }
+
+        # Build the set of files that were parsed. This includes the file that 
was parsed,
+        # even if it no longer contains DAGs, so we can clear old import 
errors.
+        files_parsed: set[tuple[str, str]] | None = None
+        if relative_fileloc is not None:
+            files_parsed = {(bundle_name, relative_fileloc)}
+            files_parsed.update(import_errors.keys())
+
+        warnings = parsing_result.warnings or []
+        if warnings and isinstance(warnings[0], dict):
+            warnings = [DagWarning(**warn) for warn in warnings]
+
+        update_dag_parsing_results_in_db(
+            bundle_name=bundle_name,
+            bundle_version=bundle_version,
+            dags=parsing_result.serialized_dags,
+            import_errors=import_errors,
+            parse_duration=run_duration,
+            warnings=set(warnings),
+            session=session,
+            files_parsed=files_parsed,
+        )
+
     @provide_session
     def _collect_results(self, session: Session = NEW_SESSION):
         # TODO: Use an explicit session in this fn
@@ -991,25 +1094,7 @@ class DagFileProcessorManager(LoggingMixin):
                 # This processor hasn't finished yet, or we haven't read all 
the output from it yet
                 continue
             finished.append(file)
-
-            # Detect if this was callback-only processing
-            # For such-cases, we don't serialize the dags and hence send 
parsing_result as None.
-            is_callback_only = proc.had_callbacks and proc.parsing_result is 
None
-            if is_callback_only:
-                self.log.debug("Detected callback-only processing for %s", 
file)
-
-            # Collect the DAGS and import errors into the DB, emit metrics etc.
-            self._file_stats[file] = process_parse_results(
-                run_duration=time.monotonic() - proc.start_time,
-                finish_time=timezone.utcnow(),
-                run_count=self._file_stats[file].run_count,
-                bundle_name=file.bundle_name,
-                bundle_version=self._bundle_versions[file.bundle_name],
-                parsing_result=proc.parsing_result,
-                session=session,
-                is_callback_only=is_callback_only,
-                relative_fileloc=str(file.rel_path),
-            )
+            self.handle_parsing_result(file, proc, session=session)
 
         for file in finished:
             processor = self._processors.pop(file)
@@ -1344,12 +1429,16 @@ def process_parse_results(
     bundle_name: str,
     bundle_version: str | None,
     parsing_result: DagFileParsingResult | None,
-    session: Session,
     *,
     is_callback_only: bool = False,
     relative_fileloc: str | None = None,
 ) -> DagFileStat:
-    """Take the parsing result and stats about the parser process and convert 
it into a DagFileStat."""
+    """
+    Create a DagFileStat from parsing results and emit metrics.
+
+    This function handles stat creation and metrics only — database persistence
+    is handled separately by 
``DagFileProcessorManager.persist_parsing_result``.
+    """
     if is_callback_only:
         # Callback-only processing - don't update timestamps to avoid stale 
DAG detection issues
         stat = DagFileStat(
@@ -1384,33 +1473,6 @@ def process_parse_results(
         if not is_callback_only:
             stat.import_errors = 1
     else:
-        # record DAGs and import errors to database
-        import_errors = {}
-        if parsing_result.import_errors:
-            import_errors = {
-                (bundle_name, rel_path): error for rel_path, error in 
parsing_result.import_errors.items()
-            }
-
-        # Build the set of files that were parsed. This includes the file that 
was parsed,
-        # even if it no longer contains DAGs, so we can clear old import 
errors.
-        files_parsed: set[tuple[str, str]] | None = None
-        if relative_fileloc is not None:
-            files_parsed = {(bundle_name, relative_fileloc)}
-            files_parsed.update(import_errors.keys())
-
-        if (warnings := parsing_result.warnings) and isinstance(warnings[0], 
dict):
-            warnings = [DagWarning(**warn) for warn in warnings]
-
-        update_dag_parsing_results_in_db(
-            bundle_name=bundle_name,
-            bundle_version=bundle_version,
-            dags=parsing_result.serialized_dags,
-            import_errors=import_errors,
-            parse_duration=run_duration,
-            warnings=set(warnings or []),
-            session=session,
-            files_parsed=files_parsed,
-        )
         stat.num_dags = len(parsing_result.serialized_dags)
         if parsing_result.import_errors:
             stat.import_errors = len(parsing_result.import_errors)
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 46f23e6e3b5..8f2cb9b8cb4 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import copy
 import json
 import logging
 import os
@@ -51,7 +52,7 @@ from airflow.dag_processing.manager import (
     DagFileProcessorManager,
     DagFileStat,
 )
-from airflow.dag_processing.processor import DagFileProcessorProcess
+from airflow.dag_processing.processor import DagFileParsingResult, 
DagFileProcessorProcess
 from airflow.models import DagModel, DbCallbackRequest
 from airflow.models.asset import TaskOutletAssetReference
 from airflow.models.dag_version import DagVersion
@@ -831,6 +832,108 @@ class TestDagFileProcessorManager:
             manager._kill_timed_out_processors()
         mock_kill.assert_not_called()
 
+    def 
test_handle_parsing_result_throttles_retry_when_first_persist_fails(self, 
session):
+        """Persist errors should throttle retries without claiming persistence 
succeeded."""
+        manager = DagFileProcessorManager(max_runs=1)
+        file = DagFileInfo(bundle_name="testing", rel_path=Path("abc.txt"), 
bundle_path=TEST_DAGS_FOLDER)
+        original_stat = DagFileStat()
+        manager._file_stats[file] = original_stat
+        manager._bundle_versions["testing"] = "v1"
+        manager._file_process_interval = 60
+
+        processor, _ = self.mock_processor(start_time=time.monotonic() - 1)
+        processor.had_callbacks = False
+        processor.parsing_result = DagFileParsingResult(fileloc="abc.txt", 
serialized_dags=[])
+
+        with mock.patch.object(manager, "persist_parsing_result", 
side_effect=RuntimeError("boom")):
+            manager.handle_parsing_result(file, processor, session=session)
+
+        assert manager._file_stats[file] is not original_stat
+        assert manager._file_stats[file].num_dags == 0
+        assert manager._file_stats[file].import_errors == 0
+        assert manager._file_stats[file].run_count == 1
+        assert manager._file_stats[file].last_finish_time is not None
+        assert manager._file_stats[file].last_duration is not None
+        assert manager.processed_recently(timezone.utcnow(), file) is True
+
+    def 
test_handle_parsing_result_updates_stats_after_successful_persist(self, 
session):
+        manager = DagFileProcessorManager(max_runs=1)
+        file = DagFileInfo(bundle_name="testing", rel_path=Path("abc.txt"), 
bundle_path=TEST_DAGS_FOLDER)
+        original_stat = DagFileStat(
+            num_dags=1,
+            import_errors=0,
+            last_finish_time=timezone.utcnow() - timedelta(hours=2),
+            last_duration=1.0,
+            run_count=3,
+            last_num_of_db_queries=0,
+        )
+        manager._file_stats[file] = original_stat
+        manager._bundle_versions["testing"] = "v1"
+
+        processor, _ = self.mock_processor(start_time=time.monotonic() - 1)
+        processor.had_callbacks = False
+        processor.parsing_result = DagFileParsingResult(fileloc="abc.txt", 
serialized_dags=[])
+
+        with mock.patch.object(manager, "persist_parsing_result") as 
mock_persist:
+            manager.handle_parsing_result(file, processor, session=session)
+
+        mock_persist.assert_called_once_with(
+            bundle_name="testing",
+            bundle_version="v1",
+            parsing_result=processor.parsing_result,
+            run_duration=mock.ANY,
+            relative_fileloc="abc.txt",
+            session=session,
+        )
+        assert manager._file_stats[file] is not original_stat
+        assert manager._file_stats[file].run_count == 4
+        assert manager._file_stats[file].last_finish_time is not None
+        assert manager._file_stats[file].last_finish_time > 
original_stat.last_finish_time
+        assert manager._file_stats[file].num_dags == 0
+
+    def 
test_collect_results_processes_remaining_files_when_one_persist_fails(self, 
session):
+        manager = DagFileProcessorManager(max_runs=1)
+        file_a = DagFileInfo(bundle_name="testing", rel_path=Path("a.py"), 
bundle_path=TEST_DAGS_FOLDER)
+        file_b = DagFileInfo(bundle_name="testing", rel_path=Path("b.py"), 
bundle_path=TEST_DAGS_FOLDER)
+        base_stat = DagFileStat(
+            num_dags=0,
+            import_errors=0,
+            last_finish_time=timezone.utcnow() - timedelta(hours=1),
+            last_duration=1.0,
+            run_count=1,
+            last_num_of_db_queries=0,
+        )
+        manager._file_stats[file_a] = base_stat
+        manager._file_stats[file_b] = copy.copy(base_stat)
+        manager._bundle_versions["testing"] = "v1"
+
+        proc_a, _ = self.mock_processor(start_time=time.monotonic() - 1)
+        proc_a.had_callbacks = False
+        proc_a.parsing_result = DagFileParsingResult(fileloc="a.py", 
serialized_dags=[])
+        proc_b, _ = self.mock_processor(start_time=time.monotonic() - 1)
+        proc_b.had_callbacks = False
+        proc_b.parsing_result = DagFileParsingResult(fileloc="b.py", 
serialized_dags=[])
+
+        manager._processors = {file_a: proc_a, file_b: proc_b}
+        stat_a_before = manager._file_stats[file_a]
+        stat_b_before = manager._file_stats[file_b]
+
+        with mock.patch.object(
+            manager,
+            "persist_parsing_result",
+            side_effect=[RuntimeError("boom"), None],
+        ):
+            manager._collect_results(session=session)
+
+        assert manager._file_stats[file_a] is not stat_a_before
+        assert manager._file_stats[file_a].num_dags == stat_a_before.num_dags
+        assert manager._file_stats[file_a].import_errors == 
stat_a_before.import_errors
+        assert manager._file_stats[file_a].run_count == 
stat_a_before.run_count + 1
+        assert manager._file_stats[file_a].last_finish_time is not None
+        assert manager._file_stats[file_b] is not stat_b_before
+        assert manager._file_stats[file_b].run_count == 2
+        assert len(manager._processors) == 0
+
     @pytest.mark.usefixtures("testing_dag_bundle")
     @pytest.mark.parametrize(
         ("callbacks", "path", "expected_body"),
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py 
b/airflow-core/tests/unit/dag_processing/test_processor.py
index c46aceda794..15842c1ce9e 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -49,7 +49,7 @@ from airflow.callbacks.callback_requests import (
     TaskCallbackRequest,
 )
 from airflow.dag_processing.dagbag import DagBag
-from airflow.dag_processing.manager import process_parse_results
+from airflow.dag_processing.manager import DagFileProcessorManager, 
process_parse_results
 from airflow.dag_processing.processor import (
     DagFileParseRequest,
     DagFileParsingResult,
@@ -645,7 +645,7 @@ def test_parse_file_static_check_with_default_warning():
     )
 
 
-def test_callback_processing_does_not_update_timestamps(session):
+def test_callback_processing_does_not_update_timestamps():
     """Callback processing should not update last_finish_time to prevent stale 
DAG detection."""
     stat = process_parse_results(
         run_duration=1.0,
@@ -654,7 +654,6 @@ def 
test_callback_processing_does_not_update_timestamps(session):
         bundle_name="test",
         bundle_version=None,
         parsing_result=None,
-        session=session,
         is_callback_only=True,
     )
 
@@ -662,7 +661,7 @@ def 
test_callback_processing_does_not_update_timestamps(session):
     assert stat.run_count == 5
 
 
-def test_normal_parsing_updates_timestamps(session):
+def test_normal_parsing_updates_timestamps():
     """last_finish_time should be updated when parsing a dag file."""
     finish_time = timezone.utcnow()
 
@@ -673,7 +672,6 @@ def test_normal_parsing_updates_timestamps(session):
         bundle_name="test-bundle",
         bundle_version="v1",
         parsing_result=DagFileParsingResult(fileloc="test.py", 
serialized_dags=[]),
-        session=session,
         is_callback_only=False,
     )
 
@@ -682,7 +680,7 @@ def test_normal_parsing_updates_timestamps(session):
     assert stat.import_errors == 0
 
 
-def test_import_error_updates_timestamps(session):
+def test_import_error_updates_timestamps():
     """last_finish_time should be updated when parsing a dag file results in 
import errors."""
     finish_time = timezone.utcnow()
 
@@ -693,7 +691,6 @@ def test_import_error_updates_timestamps(session):
         bundle_name="test-bundle",
         bundle_version="v1",
         parsing_result=None,
-        session=session,
         is_callback_only=False,
     )
 
@@ -702,6 +699,42 @@ def test_import_error_updates_timestamps(session):
     assert stat.import_errors == 1
 
 
+def test_persist_parsing_result_calls_update_db():
+    """persist_parsing_result should delegate to 
update_dag_parsing_results_in_db with transformed args."""
+    parsing_result = DagFileParsingResult(
+        fileloc="test.py",
+        serialized_dags=[],
+        import_errors={"dags/broken.py": "SyntaxError"},
+        warnings=[],
+    )
+
+    manager = MagicMock(spec=DagFileProcessorManager)
+    session = MagicMock()
+    # Call the real method on the mock instance
+    with patch(
+        "airflow.dag_processing.manager.update_dag_parsing_results_in_db", 
autospec=True
+    ) as mock_update:
+        DagFileProcessorManager.persist_parsing_result(
+            manager,
+            bundle_name="test-bundle",
+            bundle_version="v1",
+            parsing_result=parsing_result,
+            run_duration=1.5,
+            relative_fileloc="dags/test.py",
+            session=session,
+        )
+
+    mock_update.assert_called_once()
+    call_kwargs = mock_update.call_args.kwargs
+    assert call_kwargs["bundle_name"] == "test-bundle"
+    assert call_kwargs["bundle_version"] == "v1"
+    assert call_kwargs["parse_duration"] == 1.5
+    assert call_kwargs["session"] is session
+    assert call_kwargs["import_errors"] == {("test-bundle", "dags/broken.py"): 
"SyntaxError"}
+    assert ("test-bundle", "dags/test.py") in call_kwargs["files_parsed"]
+    assert ("test-bundle", "dags/broken.py") in call_kwargs["files_parsed"]
+
+
 class TestExecuteCallbacks:
     def test_execute_callbacks_locks_bundle_version(self):
         callbacks = [

Reply via email to