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

Lee-W 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 1b8b0ff40e5 Extract seams for Dag processor callback fetching to allow 
DB-to-API swap (#65543)
1b8b0ff40e5 is described below

commit 1b8b0ff40e5db84b3e96e968be5f27c590921158
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Tue Apr 28 09:41:57 2026 +0100

    Extract seams for Dag processor callback fetching to allow DB-to-API swap 
(#65543)
---
 airflow-core/src/airflow/dag_processing/manager.py |  30 ++++-
 .../tests/unit/dag_processing/test_manager.py      | 147 +++++++++++++++++++--
 2 files changed, 163 insertions(+), 14 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/manager.py 
b/airflow-core/src/airflow/dag_processing/manager.py
index 8b1f6f7d546..4065baf88b5 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -471,7 +471,7 @@ class DagFileProcessorManager(LoggingMixin):
 
             self._collect_results()
 
-            for callback in self._fetch_callbacks():
+            for callback in self.fetch_callbacks():
                 self._add_callback_to_queue(callback)
             self._scan_stale_dags()
             self._cleanup_stale_bundle_versions()
@@ -568,9 +568,17 @@ class DagFileProcessorManager(LoggingMixin):
             session.delete(request)
         return files
 
+    def fetch_callbacks(self) -> list[CallbackRequest]:
+        """
+        Fetch and claim callbacks for this manager's bundles.
+
+        Default implementation reads from the metadata DB; override to source 
callbacks from an API.
+        """
+        return self._fetch_callbacks_from_db()
+
     @provide_session
     @retry_db_transaction
-    def _fetch_callbacks(
+    def _fetch_callbacks_from_db(
         self,
         session: Session = NEW_SESSION,
     ) -> list[CallbackRequest]:
@@ -602,12 +610,17 @@ class DagFileProcessorManager(LoggingMixin):
             guard.commit()
         return callback_queue
 
-    def _add_callback_to_queue(self, request: CallbackRequest):
-        self.log.debug("Queuing %s CallbackRequest: %s", 
type(request).__name__, request)
+    def prepare_callback_bundle(self, request: CallbackRequest) -> 
BaseDagBundle | None:
+        """
+        Return the bundle to run the callback against, or ``None`` to skip the 
callback.
+
+        Default implementation looks the bundle up via 
:class:`DagBundlesManager` and, for
+        versioned requests on bundles that support versioning, calls 
``bundle.initialize()``.
+        Override to source the bundle from an API.
+        """
         try:
             bundle = DagBundlesManager().get_bundle(name=request.bundle_name, 
version=request.bundle_version)
         except ValueError:
-            # Bundle no longer configured
             self.log.error("Bundle %s no longer configured, skipping 
callback", request.bundle_name)
             return None
         if bundle.supports_versioning and request.bundle_version:
@@ -620,6 +633,13 @@ class DagFileProcessorManager(LoggingMixin):
                     request.bundle_version,
                 )
                 return None
+        return bundle
+
+    def _add_callback_to_queue(self, request: CallbackRequest) -> None:
+        self.log.debug("Queuing %s CallbackRequest: %s", 
type(request).__name__, request)
+        bundle = self.prepare_callback_bundle(request)
+        if bundle is None:
+            return
 
         file_info = DagFileInfo(
             rel_path=Path(request.filepath),
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index e44cf43c9da..5b6ff6af014 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -1281,7 +1281,7 @@ class TestDagFileProcessorManager:
 
     @conf_vars({("core", "load_examples"): "False"})
     def test_fetch_callbacks_from_database(self, configure_testing_dag_bundle):
-        """Test _fetch_callbacks returns callbacks ordered by priority_weight 
desc."""
+        """Test _fetch_callbacks_from_db returns callbacks ordered by 
priority_weight desc."""
 
         dag_filepath = TEST_DAG_FOLDER / "test_on_failure_callback_dag.py"
 
@@ -1311,7 +1311,7 @@ class TestDagFileProcessorManager:
             manager._dag_bundles = 
list(DagBundlesManager().get_all_dag_bundles())
 
             with create_session() as session:
-                callbacks = manager._fetch_callbacks(session=session)
+                callbacks = manager._fetch_callbacks_from_db(session=session)
 
                 # Should return callbacks ordered by priority_weight desc 
(highest first)
                 assert callbacks[0].run_id == "123"
@@ -1326,7 +1326,7 @@ class TestDagFileProcessorManager:
         }
     )
     def test_fetch_callbacks_from_database_max_per_loop(self, tmp_path, 
configure_testing_dag_bundle):
-        """Test DagFileProcessorManager._fetch_callbacks method"""
+        """Test DagFileProcessorManager.fetch_callbacks method."""
         dag_filepath = TEST_DAG_FOLDER / "test_on_failure_callback_dag.py"
 
         with create_session() as session:
@@ -1385,7 +1385,7 @@ class TestDagFileProcessorManager:
             manager._dag_bundles = 
list(DagBundlesManager().get_all_dag_bundles())
 
             with create_session() as session:
-                callbacks = manager._fetch_callbacks(session=session)
+                callbacks = manager._fetch_callbacks_from_db(session=session)
 
                 # Only the matching callback should be returned
                 assert [c.run_id for c in callbacks] == ["match"]
@@ -1441,7 +1441,7 @@ class TestDagFileProcessorManager:
             manager._dag_bundles = 
list(DagBundlesManager().get_all_dag_bundles())
 
             with create_session() as session:
-                callbacks = manager._fetch_callbacks(session=session)
+                callbacks = manager._fetch_callbacks_from_db(session=session)
 
                 assert [c.run_id for c in callbacks] == ["match"]
 
@@ -1564,9 +1564,107 @@ class TestDagFileProcessorManager:
             assert dag2_path not in manager._callback_to_execute
 
     @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
-    def test_add_callback_initializes_versioned_bundle(self, 
mock_bundle_manager):
+    def test_prepare_callback_bundle_initializes_versioned_bundle(self, 
mock_bundle_manager):
         manager = DagFileProcessorManager(max_runs=1)
-        bundle = MagicMock()
+        bundle = MagicMock(spec=BaseDagBundle)
+        bundle.supports_versioning = True
+        mock_bundle_manager.return_value.get_bundle.return_value = bundle
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version="some_commit_hash",
+            msg=None,
+        )
+
+        assert manager.prepare_callback_bundle(request) is bundle
+        bundle.initialize.assert_called_once()
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def 
test_prepare_callback_bundle_skips_initialize_for_unversioned_request(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        bundle = MagicMock(spec=BaseDagBundle)
+        bundle.supports_versioning = True
+        mock_bundle_manager.return_value.get_bundle.return_value = bundle
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version=None,
+            msg=None,
+        )
+
+        assert manager.prepare_callback_bundle(request) is bundle
+        bundle.initialize.assert_not_called()
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def 
test_prepare_callback_bundle_skips_initialize_for_non_versioning_bundle(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        bundle = MagicMock(spec=BaseDagBundle)
+        bundle.supports_versioning = False
+        mock_bundle_manager.return_value.get_bundle.return_value = bundle
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version="some_commit_hash",
+            msg=None,
+        )
+
+        assert manager.prepare_callback_bundle(request) is bundle
+        bundle.initialize.assert_not_called()
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def test_prepare_callback_bundle_returns_none_when_unconfigured(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        mock_bundle_manager.return_value.get_bundle.side_effect = 
ValueError("missing bundle")
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version="some_commit_hash",
+            msg=None,
+        )
+
+        assert manager.prepare_callback_bundle(request) is None
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def test_prepare_callback_bundle_returns_none_when_initialize_fails(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        bundle = MagicMock(spec=BaseDagBundle)
+        bundle.supports_versioning = True
+        bundle.initialize.side_effect = RuntimeError("clone failed")
+        mock_bundle_manager.return_value.get_bundle.return_value = bundle
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version="some_commit_hash",
+            msg=None,
+        )
+
+        assert manager.prepare_callback_bundle(request) is None
+        bundle.initialize.assert_called_once()
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def test_add_callback_queues_file_info_on_success(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        bundle = MagicMock(spec=BaseDagBundle)
         bundle.supports_versioning = True
         bundle.path = Path("/tmp/bundle")
         mock_bundle_manager.return_value.get_bundle.return_value = bundle
@@ -1584,11 +1682,16 @@ class TestDagFileProcessorManager:
         manager._add_callback_to_queue(request)
 
         bundle.initialize.assert_called_once()
+        assert manager._callback_to_execute
+        [(file_info, requests)] = manager._callback_to_execute.items()
+        assert file_info.rel_path == Path("file1.py")
+        assert file_info.bundle_name == "testing"
+        assert requests == [request]
 
     @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
     def test_add_callback_skips_when_bundle_init_fails(self, 
mock_bundle_manager):
         manager = DagFileProcessorManager(max_runs=1)
-        bundle = MagicMock()
+        bundle = MagicMock(spec=BaseDagBundle)
         bundle.supports_versioning = True
         bundle.initialize.side_effect = Exception("clone failed")
         mock_bundle_manager.return_value.get_bundle.return_value = bundle
@@ -1606,7 +1709,33 @@ class TestDagFileProcessorManager:
         manager._add_callback_to_queue(request)
 
         bundle.initialize.assert_called_once()
-        assert len(manager._callback_to_execute) == 0
+        assert not manager._callback_to_execute
+
+    @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+    def test_add_callback_skips_when_bundle_unconfigured(self, 
mock_bundle_manager):
+        manager = DagFileProcessorManager(max_runs=1)
+        mock_bundle_manager.return_value.get_bundle.side_effect = 
ValueError("missing bundle")
+
+        request = DagCallbackRequest(
+            filepath="file1.py",
+            dag_id="dag1",
+            run_id="run1",
+            is_failure_callback=False,
+            bundle_name="testing",
+            bundle_version="some_commit_hash",
+            msg=None,
+        )
+
+        manager._add_callback_to_queue(request)
+
+        assert not manager._callback_to_execute
+
+    def test_fetch_callbacks_delegates_to_private_method(self):
+        manager = DagFileProcessorManager(max_runs=1)
+        expected: list = [mock.sentinel.callback]
+        with mock.patch.object(manager, "_fetch_callbacks_from_db", 
return_value=expected) as private:
+            assert manager.fetch_callbacks() is expected
+        private.assert_called_once_with()
 
     def test_dag_with_assets(self, session, configure_testing_dag_bundle):
         """'Integration' test to ensure that the assets get parsed and stored 
correctly for parsed dags."""

Reply via email to