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 b6d67b7fbb1 Fix Dag callbacks dropped when bundle versioning is
disabled (#72930)
b6d67b7fbb1 is described below
commit b6d67b7fbb1952debee089f1cbc71cbc935ffb4b
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Fri Sep 18 10:59:07 2026 +0100
Fix Dag callbacks dropped when bundle versioning is disabled (#72930)
* Fix Dag callbacks dropped when bundle versioning is disabled
A Dag run that is not pinned to a bundle version -- the normal case under
disable_bundle_versioning -- produces a callback request that carries no
bundle version. Callback queueing built a second, uninitialized bundle
instance for it and read a path off it, so for a versioned bundle whose path
is only defined after initialization the callback was queued against an
unresolved location and lost. Nothing surfaced to the user, because a
callback-only run records no import error.
Such a request wants the bundle's current contents, which is what this
processor's own instance already tracks, so serving it from there resolves
the
same path the file scan queues and keeps a fetch and checkout out of the
parsing loop. Nothing else is a valid answer for an unversioned request:
callbacks are claimed only for bundles this processor parses, and a
versioned
instance that is still uninitialized has just failed initialization in the
refresh that precedes callback fetching, which is also where it is retried.
closes: #64891
* Preserve Dag callbacks while bundles are unavailable
An initialization outage must not consume pending callbacks before the
processor can execute them. Callback work needs to survive temporary bundle
failures and remain diagnosable until recovery.
* Apply suggestion from @ephraimbuddy
---
.../src/airflow/dag_processing/bundles/base.py | 4 +-
airflow-core/src/airflow/dag_processing/manager.py | 38 ++-
airflow-core/src/airflow/utils/db_cleanup.py | 9 +-
.../tests/unit/dag_processing/test_manager.py | 300 ++++++++++++++++++++-
4 files changed, 329 insertions(+), 22 deletions(-)
diff --git a/airflow-core/src/airflow/dag_processing/bundles/base.py
b/airflow-core/src/airflow/dag_processing/bundles/base.py
index 344a3349fec..c5fa9f7bd0e 100644
--- a/airflow-core/src/airflow/dag_processing/bundles/base.py
+++ b/airflow-core/src/airflow/dag_processing/bundles/base.py
@@ -298,6 +298,9 @@ class BaseDagBundle(ABC):
supports_versioning: bool = False
+ is_initialized: bool = False
+ """Set by ``super().initialize()``; overrides must call it last."""
+
_locked: bool = False
def __init__(
@@ -313,7 +316,6 @@ class BaseDagBundle(ABC):
self.version = version
self.version_data = version_data
self.refresh_interval = refresh_interval
- self.is_initialized: bool = False
self.base_dir = get_bundle_base_folder(bundle_name=self.name)
"""Base directory for all bundle files for this bundle."""
diff --git a/airflow-core/src/airflow/dag_processing/manager.py
b/airflow-core/src/airflow/dag_processing/manager.py
index ee71d86a9a8..b1d891d5375 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -713,12 +713,23 @@ class DagFileProcessorManager(LoggingMixin):
*,
session: Session = NEW_SESSION,
) -> list[CallbackRequest]:
- """Fetch callbacks from database and add them to the internal queue
for execution."""
+ """Claim callbacks for ready bundles, leaving the rest pending."""
self.log.debug("Fetching callbacks from the database.")
callback_queue: list[CallbackRequest] = []
with prohibit_commit(session) as guard:
- bundle_names = [bundle.name for bundle in self._dag_bundles]
+ # Claiming deletes rows, so defer unavailable bundles before
applying the limit.
+ bundle_names = [
+ bundle.name
+ for bundle in self._dag_bundles
+ if not bundle.supports_versioning or bundle.is_initialized
+ ]
+ if unready_bundles := [
+ bundle.name
+ for bundle in self._dag_bundles
+ if bundle.supports_versioning and not bundle.is_initialized
+ ]:
+ self.log.debug("Skipping callback fetch for uninitialized
bundles: %s", unready_bundles)
query: Select[tuple[DbCallbackRequest]] = with_row_locks(
select(DbCallbackRequest)
.where(DbCallbackRequest.bundle_name.in_(bundle_names))
@@ -743,12 +754,22 @@ class DagFileProcessorManager(LoggingMixin):
def prepare_callback_bundle(self, request: CallbackRequest) ->
BaseDagBundle | None:
"""
- Return the bundle to run the callback against, or ``None`` to skip the
callback.
+ Return a usable bundle or ``None`` to skip; override for API-backed
bundles.
- 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.
+ Reuse loaded bundles for unversioned requests; versioning bundles must
be initialized.
"""
+ if request.bundle_version is None:
+ # Reuse the scan path without fetching or checking out per
callback.
+ loaded = next((b for b in self._dag_bundles if b.name ==
request.bundle_name), None)
+ if loaded is None:
+ self.log.error(
+ "Bundle %s is not parsed by this processor, skipping
callback", request.bundle_name
+ )
+ return None
+ if loaded.supports_versioning and not loaded.is_initialized:
+ self.log.error("Bundle %s is not initialized, skipping
callback", request.bundle_name)
+ return None
+ return loaded
try:
bundle = DagBundlesManager().get_bundle(
name=request.bundle_name,
@@ -758,7 +779,7 @@ class DagFileProcessorManager(LoggingMixin):
except ValueError:
self.log.error("Bundle %s no longer configured, skipping
callback", request.bundle_name)
return None
- if bundle.supports_versioning and request.bundle_version:
+ if bundle.supports_versioning:
try:
bundle.initialize()
except Exception:
@@ -1405,9 +1426,8 @@ class DagFileProcessorManager(LoggingMixin):
self._symlink_latest_log_directory()
self._latest_log_symlink_date = datetime.today()
- bundle = next(b for b in self._dag_bundles if b.name ==
dag_file.bundle_name)
relative_path = Path(dag_file.rel_path)
- return os.path.join(self._get_log_dir(), bundle.name,
f"{relative_path}.log")
+ return os.path.join(self._get_log_dir(), dag_file.bundle_name,
f"{relative_path}.log")
def _get_logger_for_dag_file(self, dag_file: DagFileInfo):
log_filename = self._render_log_filename(dag_file)
diff --git a/airflow-core/src/airflow/utils/db_cleanup.py
b/airflow-core/src/airflow/utils/db_cleanup.py
index 53e1971b8e7..a6213d7de9e 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -282,12 +282,9 @@ config_list: list[_TableConfig] = [
table_name="callback",
recency_column_name="created_at",
extra_columns=["id", "state"],
- # Purging a callback cascades to its deadline row, so only finished
callbacks are purged;
- # a state this code does not know keeps its rows. An unfired
deadline's callback sits in
- # SCHEDULED, which is neither active nor terminal, until the deadline
is missed; it is
- # purged only once no deadline references it, as deleting a Dag run
cascades away the
- # deadline at the database level and leaves the callback behind.
Dag-processor callbacks
- # carry no state and are deleted as they are dispatched.
+ # Callback deletion cascades to deadlines, so preserve active or
unknown states and
+ # SCHEDULED callbacks still referenced by a deadline. Stateless
Dag-processor
+ # callbacks are eligible even while pending, once older than the
cleanup cutoff.
extra_filters=[
or_(
column("state").in_(sorted(TERMINAL_STATES)),
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 8dd6a8eca98..fbdc9bc9b75 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -39,6 +39,7 @@ from unittest.mock import MagicMock
import msgspec
import pytest
+import structlog
import time_machine
from sqlalchemy import event, func, select
from sqlalchemy.exc import OperationalError
@@ -46,7 +47,7 @@ from uuid6 import uuid7
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.base import BaseDagBundle, BundleVersion
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
@@ -56,7 +57,12 @@ from airflow.dag_processing.manager import (
DagFileProcessorManager,
DagFileStat,
)
-from airflow.dag_processing.processor import DagFileParsingResult,
DagFileProcessorProcess
+from airflow.dag_processing.processor import (
+ DagFileParseRequest,
+ DagFileParsingResult,
+ DagFileProcessorProcess,
+ _parse_file,
+)
from airflow.models import DagModel, DbCallbackRequest
from airflow.models.asset import TaskOutletAssetReference
from airflow.models.dag_version import DagVersion
@@ -277,6 +283,24 @@ SWEEP_FILES = 4
SWEEP_CALLS = 4
+class LateResolvingBundle(BaseDagBundle):
+ supports_versioning = True
+
+ def __init__(self, *, resolved_path: Path, **kwargs):
+ super().__init__(**kwargs)
+ self._resolved_path = resolved_path
+
+ @property
+ def path(self) -> Path:
+ return self._resolved_path if self.is_initialized else
Path("/dev/null")
+
+ def get_current_version(self) -> BundleVersion:
+ return BundleVersion(version="some_commit_hash")
+
+ def refresh(self) -> None:
+ pass
+
+
class TestDagFileProcessorManager:
@pytest.fixture(autouse=True)
def _disable_examples(self):
@@ -2331,6 +2355,140 @@ class TestDagFileProcessorManager:
"other-bundle-b",
}
+ @mock.patch.object(LateResolvingBundle, "initialize", autospec=True)
+ def test_callback_executes_after_bundle_initialization_recovers(self,
mock_initialize, tmp_path):
+ dag_file = tmp_path / "callback_recovery.py"
+ dag_file.write_text(
+ textwrap.dedent(
+ """
+ from pathlib import Path
+ from airflow.sdk import DAG
+
+ def on_success(context):
+
Path(__file__).with_suffix(".callback").write_text(context["run_id"])
+
+ dag = DAG("callback_recovery", schedule=None,
on_success_callback=on_success)
+ """
+ )
+ )
+ bundle = LateResolvingBundle(name="testing", resolved_path=tmp_path)
+ manager = DagFileProcessorManager(max_runs=1)
+ manager._dag_bundles = [bundle]
+ request = DagCallbackRequest(
+ dag_id="callback_recovery",
+ run_id="run1",
+ filepath=dag_file.name,
+ bundle_name=bundle.name,
+ bundle_version=None,
+ is_failure_callback=False,
+ )
+ with create_session() as session:
+ session.add(DagBundleModel(name=bundle.name))
+ session.add(DbCallbackRequest(callback=request, priority_weight=1))
+
+ mock_initialize.side_effect = RuntimeError("Bundle unavailable")
+ known_files: dict[str, set[DagFileInfo]] = {}
+ manager._refresh_dag_bundles(known_files)
+ for _ in range(3):
+ assert manager.fetch_callbacks() == []
+ mock_initialize.assert_called_once_with(bundle)
+ assert not manager._force_refresh_bundles
+ with create_session() as session:
+ [pending] = session.scalars(select(DbCallbackRequest)).all()
+ assert pending.get_callback_request() == request
+
+ mock_initialize.side_effect = BaseDagBundle.initialize
+ manager._bundles_last_refreshed = 0
+ manager._refresh_dag_bundles(known_files)
+ assert bundle.is_initialized
+ claimed = manager.fetch_callbacks()
+ assert claimed == [request]
+ for callback in claimed:
+ manager._add_callback_to_queue(callback)
+
+ [(file_info, callbacks)] = manager._callback_to_execute.items()
+ _parse_file(
+ DagFileParseRequest(
+ file=str(file_info.absolute_path),
+ bundle_path=file_info.bundle_path,
+ bundle_name=file_info.bundle_name,
+ callback_requests=callbacks,
+ ),
+ log=structlog.get_logger(),
+ )
+ assert dag_file.with_suffix(".callback").read_text() == request.run_id
+ assert manager.fetch_callbacks() == []
+ with create_session() as session:
+ assert session.scalars(select(DbCallbackRequest)).all() == []
+
+ @pytest.mark.parametrize("bundle_version", ["", "v1"])
+ def test_fetch_pinned_callbacks_waits_for_bundle_initialization(self,
tmp_path, bundle_version):
+ bundle = LateResolvingBundle(name="testing", resolved_path=tmp_path)
+ manager = DagFileProcessorManager(max_runs=1)
+ manager._dag_bundles = [bundle]
+ request = DagCallbackRequest(
+ dag_id="dag1",
+ run_id="run1",
+ filepath="dag.py",
+ bundle_name=bundle.name,
+ bundle_version=bundle_version,
+ )
+ with create_session() as session:
+ session.add(DbCallbackRequest(callback=request, priority_weight=1))
+
+ assert manager.fetch_callbacks() == []
+ with create_session() as session:
+ [pending] = session.scalars(select(DbCallbackRequest)).all()
+ assert pending.get_callback_request() == request
+
+ bundle.initialize()
+ assert manager.fetch_callbacks() == [request]
+ with create_session() as session:
+ assert session.scalars(select(DbCallbackRequest)).all() == []
+
+ @pytest.mark.parametrize("supports_versioning", [False, True])
+ @conf_vars({("dag_processor", "max_callbacks_per_loop"): "1"})
+ def test_fetch_callbacks_filters_uninitialized_bundles_before_limit(
+ self, tmp_path, supports_versioning, caplog
+ ):
+ unavailable = LateResolvingBundle(name="unavailable",
resolved_path=tmp_path)
+ ready = MagicMock(spec=BaseDagBundle)
+ ready.name = "ready"
+ ready.supports_versioning = supports_versioning
+ ready.is_initialized = supports_versioning
+ manager = DagFileProcessorManager(max_runs=1)
+ manager._dag_bundles = [unavailable, ready]
+ requests = [
+ DagCallbackRequest(
+ dag_id="dag1",
+ run_id="run1",
+ filepath="dag.py",
+ bundle_name=bundle.name,
+ bundle_version=None,
+ )
+ for bundle in (unavailable, ready)
+ ]
+ with create_session() as session:
+ session.add(DbCallbackRequest(callback=requests[0],
priority_weight=100))
+ session.add(DbCallbackRequest(callback=requests[1],
priority_weight=1))
+
+ with caplog.at_level(logging.DEBUG):
+ assert manager.fetch_callbacks() == [requests[1]]
+ diagnostic = "Skipping callback fetch for uninitialized bundles:
['unavailable']"
+ assert {"event": diagnostic, "log_level": "debug"} in caplog
+ with create_session() as session:
+ [pending] = session.scalars(select(DbCallbackRequest)).all()
+ assert pending.get_callback_request() == requests[0]
+
+ unavailable.initialize()
+ caplog.clear()
+ with caplog.at_level(logging.DEBUG):
+ assert manager.fetch_callbacks() == [requests[0]]
+ assert not any(
+ entry["event"].startswith("Skipping callback fetch for
uninitialized bundles:")
+ for entry in caplog.entries
+ )
+
@mock.patch.object(DagFileProcessorManager, "_get_logger_for_dag_file")
def test_callback_queue(self, mock_get_logger,
configure_testing_dag_bundle):
mock_logger = MagicMock()
@@ -2486,9 +2644,104 @@ class TestDagFileProcessorManager:
name="testing", version="some_commit_hash",
version_data=version_data
)
- @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
- def
test_prepare_callback_bundle_skips_initialize_for_unversioned_request(self,
mock_bundle_manager):
+ @pytest.mark.parametrize(
+ ("supports_versioning", "is_initialized"),
+ [
+ pytest.param(True, True, id="versioned-and-initialized"),
+ pytest.param(False, False, id="non-versioning"),
+ ],
+ )
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager",
autospec=True)
+ def
test_prepare_callback_bundle_reuses_loaded_bundle_for_unversioned_request(
+ self, mock_bundle_manager, supports_versioning, is_initialized
+ ):
+ manager = DagFileProcessorManager(max_runs=1)
+ loaded = MagicMock(spec=BaseDagBundle)
+ loaded.name = "testing"
+ loaded.supports_versioning = supports_versioning
+ loaded.is_initialized = is_initialized
+ manager._dag_bundles = [loaded]
+
+ 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 loaded
+ loaded.initialize.assert_not_called()
+ mock_bundle_manager.return_value.get_bundle.assert_not_called()
+
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager",
autospec=True)
+ def test_prepare_callback_bundle_does_not_retry_uninitialized_bundle(self,
mock_bundle_manager):
manager = DagFileProcessorManager(max_runs=1)
+ loaded = MagicMock(spec=BaseDagBundle)
+ loaded.name = "testing"
+ loaded.supports_versioning = True
+ loaded.is_initialized = False
+ manager._dag_bundles = [loaded]
+
+ request = DagCallbackRequest(
+ filepath="file1.py",
+ dag_id="dag1",
+ run_id="run1",
+ is_failure_callback=False,
+ bundle_name="testing",
+ bundle_version=None,
+ msg=None,
+ )
+
+ for _ in range(3):
+ assert manager.prepare_callback_bundle(request) is None
+ loaded.initialize.assert_not_called()
+ mock_bundle_manager.return_value.get_bundle.assert_not_called()
+ assert not manager._force_refresh_bundles
+
+ @pytest.mark.parametrize(
+ "loaded_bundle_names",
+ [
+ pytest.param([], id="no-bundle-loaded"),
+ pytest.param(["other"], id="other-bundle-loaded"),
+ ],
+ )
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager",
autospec=True)
+ def
test_prepare_callback_bundle_skips_unversioned_request_for_unparsed_bundle(
+ self, mock_bundle_manager, loaded_bundle_names
+ ):
+ manager = DagFileProcessorManager(max_runs=1)
+ for name in loaded_bundle_names:
+ loaded = MagicMock(spec=BaseDagBundle)
+ loaded.name = name
+ loaded.supports_versioning = True
+ loaded.is_initialized = True
+ manager._dag_bundles.append(loaded)
+
+ 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 None
+ mock_bundle_manager.return_value.get_bundle.assert_not_called()
+
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager",
autospec=True)
+ def test_prepare_callback_bundle_keeps_empty_string_version_pinned(self,
mock_bundle_manager):
+ manager = DagFileProcessorManager(max_runs=1)
+ loaded = MagicMock(spec=BaseDagBundle)
+ loaded.name = "testing"
+ loaded.supports_versioning = True
+ loaded.is_initialized = True
+ manager._dag_bundles = [loaded]
+
bundle = MagicMock(spec=BaseDagBundle)
bundle.supports_versioning = True
mock_bundle_manager.return_value.get_bundle.return_value = bundle
@@ -2499,12 +2752,15 @@ class TestDagFileProcessorManager:
run_id="run1",
is_failure_callback=False,
bundle_name="testing",
- bundle_version=None,
+ bundle_version="",
msg=None,
)
assert manager.prepare_callback_bundle(request) is bundle
- bundle.initialize.assert_not_called()
+ mock_bundle_manager.return_value.get_bundle.assert_called_once_with(
+ name="testing", version="", version_data=None
+ )
+ bundle.initialize.assert_called_once()
@mock.patch("airflow.dag_processing.manager.DagBundlesManager")
def
test_prepare_callback_bundle_skips_initialize_for_non_versioning_bundle(self,
mock_bundle_manager):
@@ -2614,6 +2870,38 @@ class TestDagFileProcessorManager:
bundle.initialize.assert_called_once()
assert not manager._callback_to_execute
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager",
autospec=True)
+ def test_add_callback_reuses_loaded_bundle_path_for_unversioned_request(
+ self, mock_bundle_manager, tmp_path
+ ):
+ manager = DagFileProcessorManager(max_runs=1)
+ bundle = LateResolvingBundle(name="testing", resolved_path=tmp_path)
+ bundle.initialize()
+ manager._dag_bundles = [bundle]
+
+ request = DagCallbackRequest(
+ filepath="file1.py",
+ dag_id="dag1",
+ run_id="run1",
+ is_failure_callback=False,
+ bundle_name="testing",
+ bundle_version=None,
+ msg=None,
+ )
+
+ manager._add_callback_to_queue(request)
+
+ mock_bundle_manager.return_value.get_bundle.assert_not_called()
+ [(file_info, _)] = manager._callback_to_execute.items()
+ assert file_info.bundle_path == tmp_path
+ assert file_info in manager._file_queue
+
+ def test_render_log_filename_for_file_whose_bundle_is_not_loaded(self,
tmp_path):
+ manager = DagFileProcessorManager(max_runs=1,
base_log_dir=str(tmp_path))
+ dag_file = DagFileInfo(rel_path=Path("file1.py"),
bundle_name="testing", bundle_path=tmp_path)
+
+ assert
manager._render_log_filename(dag_file).endswith("/testing/file1.py.log")
+
@mock.patch("airflow.dag_processing.manager.DagBundlesManager")
def test_add_callback_skips_when_bundle_unconfigured(self,
mock_bundle_manager):
manager = DagFileProcessorManager(max_runs=1)