This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new cffbf913ae6 [v3-3-test] Guard 2 to 3+ migration against custom Dag
bundle configs using startup-based initialization (#63185) (#70994)
cffbf913ae6 is described below
commit cffbf913ae66cca32e32ea4993597355595e2586
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Mon Aug 3 23:58:07 2026 +0800
[v3-3-test] Guard 2 to 3+ migration against custom Dag bundle configs using
startup-based initialization (#63185) (#70994)
* Reassign Dags from unconfigured bundles at DFP startup
The 0082_3_1_0_make_bundle_name_not_nullable migration writes a single
hard-coded ``bundle_name='dags-folder'`` on every legacy DagModel row,
so deployments whose runtime config uses any other bundle name (or
multiple custom bundles) cannot resolve the row at trigger time and
fail with ``Requested bundle 'dags-folder' is not configured.``
(see #63323).
Fixing this inside the Alembic migration is wrong: migrations must not
import application code, and the migration runs before bundles are
constructible, so the user's real bundle config is not available there.
Instead, at DagFileProcessorManager startup -- after sync_bundles_to_db
flushes the latest bundle state -- scan DagModel for legacy-candidate
rows (NULL relative_fileloc and no DagVersion for that dag_id) and
route each row to the most-specific configured bundle whose absolute
path contains the Dag's fileloc, writing relative_fileloc at the same
time so fileloc-based stale-detection works later. Rows whose fileloc
is not under any configured bundle's path are left untouched: writing
bundle_name without a verified relative_fileloc would produce an
active row task workers cannot execute. Skipped rows then self-heal
via the staleness lifecycle -- no manual ``airflow dags reserialize``
required.
Concurrency and edge-case hardening on the repair path:
* Fast-skip via ``EXISTS(DagVersion)`` -- DagVersion is written only
by the parse path, which overwrites both bundle_name and
relative_fileloc on every parse (DagModelOperation.update_dags), so
once any 3.x parse has run the parse path is the source of truth
and reassign has no work it would not do itself. PK-index probe vs.
a sequential scan of ``dag`` (no index on relative_fileloc).
* Chunked UPDATEs (_REASSIGN_BATCH_SIZE=1000) ordered by dag_id, one
internally-owned transaction per chunk via create_session(), so the
row-lock window stays bounded and the repair never commits a
caller-provided session. Per-row compare-and-swap WHERE clause
re-asserts the legacy-candidate predicate on the UPDATE so a
concurrent parser write wins the race.
* SELECT and UPDATE chunks run in separate sessions; per-row fileloc
matching runs without a DB connection held.
* Parent-traversal guard in _best_bundle_for_fileloc lexically
normalises both sides with os.path.normpath and rejects any
relative result that is still absolute or contains ``..``, so a
stored fileloc like ``/dags/foo/../../outside.py`` cannot escape a
bundle root. Lexical only -- no symlink resolution.
* multi_team-safe because a bundle path belongs to at most one team.
* Stale-Dag scan skips rows with NULL relative_fileloc and emits one
INFO line per cycle with the skip count, so operator-visible legacy
rows that the repair could not route stay observable.
Tests cover custom bundle names, multiple bundles, overlapping paths
(deepest wins), unmatched fileloc (row skipped), missing fileloc,
FK-safety when a configured bundle is missing from dag_bundle, the
legacy relative_fileloc backfill path, concurrent-DFP startup,
chunk-boundary batching, and the full sync -> repair -> stale-scan
-> re-parse lifecycle.
closes: #63323
* Add newsfragment and mention the workaround
* Clarify bundle path matching and NULL relative_fileloc handling
Normalize bundle paths once when building the active-bundle map so the
fileloc match uses plain Path.relative_to instead of mixing os.path with
pathlib per iteration, and explain why the lexical normpath is required.
Expand the cryptic stale-check comment to describe the legacy 2.x NULL
relative_fileloc case and link the tracking issue.
* Use plain Path.relative_to for bundle fileloc routing
Match BaseDagImporter.get_relative_path instead of normalizing with
os.path.normpath. Filelocs come from the Dag processor parsing
admin-controlled bundle files, so they are trusted and need no
path-traversal defense, and using the same relative_to check means the
startup repair writes the same relative_fileloc the next parse computes.
* Add critial regression guard test case
* Order reassignment scan by bundle name instead of re-sorting logs
Scanning in bundle_name order groups the movements log by source
bundle naturally, so the Python-side sorted() can go; the keyset
cursor becomes compound (bundle_name, dag_id) to keep pagination
correct under the new ordering.
Addresses review feedback in
https://github.com/apache/airflow/pull/63185#discussion_r3501607963
* Combine the top create_session for the fast prob path
* Rename _best_bundle_for_fileloc to _guess_best_bundle_for_fileloc and
parameterize its tests
The old name implied a definitive match; the function is a best-effort
guess based on configured bundle paths. Consolidating its four
near-identical test cases into one parametrized test also makes it
easier to see the input/expected shape at a glance, and the DagVersion
import moves to module scope since nothing about the test requires a
lazy import.
* Shorten significant.rst
* Guard Dag bundle reassignment so a failure can't crash DFP startup
Reassigning legacy Dags to configured bundles is a best-effort repair run
during Dag processor startup. A failure there must not take down the processor,
since affected Dags already recover on the next successful parse. The
reassignment log line is also worded for accuracy: the source bundle may be a
configured-but-wrong bundle, not necessarily an unconfigured one.
* Experiment: drop redundant session.flush() from sync_bundles_to_db
Every caller commits before reading bundle state (dag_reserialize and
DAG.test commit immediately; the DFP and utils/cli go through provide_session,
which commits on exit), and reassign_dags_with_unconfigured_bundles reads in
its own session. The explicit flush no longer guards any read. Pushed to
observe whether CI still depends on it.
(cherry picked from commit 40ced09754fd50556de2a950bdea3a83261b25b0)
---
airflow-core/newsfragments/63185.significant.rst | 3 +
.../src/airflow/dag_processing/bundles/manager.py | 232 +++-
airflow-core/src/airflow/dag_processing/manager.py | 29 +-
.../bundles/test_dag_bundle_manager.py | 1269 +++++++++++++++++++-
.../tests/unit/dag_processing/test_manager.py | 189 +++
5 files changed, 1713 insertions(+), 9 deletions(-)
diff --git a/airflow-core/newsfragments/63185.significant.rst
b/airflow-core/newsfragments/63185.significant.rst
new file mode 100644
index 00000000000..cab2d663bb3
--- /dev/null
+++ b/airflow-core/newsfragments/63185.significant.rst
@@ -0,0 +1,3 @@
+Fix 2.x to 3.0+ upgrade failure when a custom Dag bundle is configured
+
+The ``0082_3_1_0_make_bundle_name_not_nullable`` migration assigned every
legacy row ``bundle_name='dags-folder'``, so triggering a DagRun raised
``Requested bundle 'dags-folder' is not configured.`` on any deployment that
uses a bundle other than the default ``dags-folder``.
``DagFileProcessorManager`` now runs a one-shot, best-effort backfill at
startup that routes each affected Dag to the correct bundle based on its file
path; unmatched Dags self-heal on the next successful parse (or [...]
diff --git a/airflow-core/src/airflow/dag_processing/bundles/manager.py
b/airflow-core/src/airflow/dag_processing/bundles/manager.py
index 2a26f224ae1..1d195d85154 100644
--- a/airflow-core/src/airflow/dag_processing/bundles/manager.py
+++ b/airflow-core/src/airflow/dag_processing/bundles/manager.py
@@ -20,11 +20,13 @@ import importlib
import logging
import os
import warnings
-from typing import TYPE_CHECKING
+from collections import defaultdict
+from pathlib import Path
+from typing import TYPE_CHECKING, cast
from itsdangerous import URLSafeSerializer
from pydantic import BaseModel, ValidationError
-from sqlalchemy import delete, select
+from sqlalchemy import and_, delete, exists, or_, select, update
from airflow._shared.module_loading import import_string
from airflow.configuration import conf
@@ -34,17 +36,48 @@ from airflow.models.dagbundle import DagBundleModel
from airflow.models.team import Team
from airflow.providers_manager import ProvidersManager
from airflow.utils.log.logging_mixin import LoggingMixin
-from airflow.utils.session import NEW_SESSION, provide_session
+from airflow.utils.session import NEW_SESSION, create_session, provide_session
if TYPE_CHECKING:
from collections.abc import Iterable
+ from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
_example_dag_bundle_name = "example_dags"
+# Chunk size for the one-time startup repair of unconfigured bundles.
+_REASSIGN_BATCH_SIZE = 1000
+
+
+def _guess_best_bundle_for_fileloc(
+ fileloc: str, descending_bundle_paths: dict[str, Path]
+) -> tuple[str, str] | None:
+ """
+ Return ``(bundle_name, relative_fileloc)`` for the first bundle whose path
contains ``fileloc``.
+
+ ``descending_bundle_paths`` must be sorted by path length descending so
+ the deepest bundle wins when paths overlap.
+
+ Returns ``None`` when ``fileloc`` is not under any bundle's path.
+
+ Uses the same plain ``Path.relative_to`` check as
+ ``BaseDagImporter.get_relative_path``, so the ``relative_fileloc`` written
+ here matches what the next parse computes for the same file. Filelocs are
+ produced by the Dag processor parsing admin-controlled bundle files, so
they
+ are trusted and need no path-traversal normalization.
+ """
+ file_path = Path(fileloc)
+ for name, path in descending_bundle_paths.items():
+ try:
+ relative = file_path.relative_to(path)
+ except ValueError:
+ continue
+ return name, str(relative)
+ return None
+
class _ExternalBundleConfig(BaseModel):
"""Schema defining the user-specified configuration for a DAG bundle."""
@@ -379,6 +412,199 @@ class DagBundlesManager(LoggingMixin):
session.execute(delete(ParseImportError).where(ParseImportError.bundle_name ==
name))
self.log.info("Deleted import errors for bundle %s which is no
longer configured", name)
+ def reassign_dags_with_unconfigured_bundles(self) -> int:
+ """
+ Reassign Dags pointing at unconfigured bundles to a configured one.
+
+ Side effect of the ``0082_3_1_0_make_bundle_name_not_nullable``
+ migration (#63323): legacy rows get ``bundle_name='dags-folder'``
+ and NULL ``relative_fileloc``, which raises ``Requested bundle
+ '{name}' is not configured.`` at trigger time when the deployment
+ uses a custom bundle.
+
+ Each legacy-candidate row (NULL ``relative_fileloc`` and no
+ ``DagVersion``) is routed to the most-specific configured bundle
+ whose path contains its ``fileloc``, writing ``relative_fileloc``
+ atomically. Rows whose ``fileloc`` is under no configured bundle
+ are left untouched -- writing ``bundle_name`` without a verified
+ ``relative_fileloc`` would produce a row task workers cannot
+ execute -- and instead self-heal via the normal staleness
+ lifecycle: ``sync_bundles_to_db`` deactivates the old bundle, the
+ stale-scan marks the row stale, and the next successful parse
+ from any configured bundle resets everything via
+ ``update_dag_parsing_results_in_db``. No manual ``airflow dags
+ reserialize`` is required.
+
+ Multi-team-safe because a bundle path belongs to at most one team.
+ Each chunk runs in its own internally-owned transaction so the
+ row-lock window stays bounded and no caller-provided session is
+ committed.
+
+ :return: Number of Dags reassigned.
+ """
+ # Import here to avoid circular import
+ # (manager -> dag -> dagrun -> taskinstance -> dag_version -> manager)
+ from airflow.models.dag import DagModel
+ from airflow.models.dag_version import DagVersion
+
+ # Fast-skip once any 3.x parse cycle has run. DagVersion is written
+ # only by the parse path, and that path overwrites both bundle_name
+ # and relative_fileloc on every parse (see
+ # ``DagModelOperation.update_dags``), so any legacy row whose file
+ # is under a configured bundle self-heals at the next parse and any
+ # row whose file is under no configured bundle self-heals via the
+ # staleness lifecycle -- reassign has no work the parse path will
+ # not do itself. The probe is an index hit on dag_version's PK
+ # vs. a sequential scan of dag (no index on relative_fileloc).
+ with create_session() as session:
+ if session.scalar(select(DagVersion.id).limit(1)) is not None:
+ return 0
+
+ if not (active_bundle_paths :=
self._resolve_active_bundle_paths(session=session)):
+ self.log.info(
+ "No active Dag bundles with resolvable paths; skipping
reassignment of Dags "
+ "with unconfigured bundles."
+ )
+ return 0
+
+ # Chunked UPDATEs ordered by (bundle_name, dag_id), one transaction per
+ # chunk; repaired rows drop out of the predicate because writing
+ # relative_fileloc makes the IS NULL clause false. Scanning in
+ # bundle_name order groups the movements log by source bundle without
+ # a Python-side re-sort.
+ #
+ # Legacy-candidate predicate (rows never parsed in 3.x): NULL
+ # relative_fileloc (the 0082 migration leaves it NULL) AND NOT EXISTS
+ # DagVersion (the parse path writes DagModel.bundle_name before the
+ # DagVersion). Equivalent under that invariant; both stated as
+ # defense in depth and repeated on the UPDATE itself as a CAS guard
+ # so a concurrent parser write wins the race.
+ movements: dict[tuple[str | None, str], int] = defaultdict(int)
+ total_reassigned = 0
+ total_backfilled = 0
+ total_skipped = 0
+ last_seen: tuple[str, str] | None = None
+
+ while True:
+ with create_session() as session:
+ query = (
+ select(DagModel.dag_id, DagModel.bundle_name,
DagModel.fileloc)
+ .where(
+ DagModel.relative_fileloc.is_(None),
+ ~exists().where(DagVersion.dag_id == DagModel.dag_id),
+ )
+ .order_by(DagModel.bundle_name, DagModel.dag_id)
+ .limit(_REASSIGN_BATCH_SIZE)
+ )
+ if last_seen is not None:
+ last_bundle, last_dag_id = last_seen
+ query = query.where(
+ or_(
+ DagModel.bundle_name > last_bundle,
+ and_(DagModel.bundle_name == last_bundle,
DagModel.dag_id > last_dag_id),
+ )
+ )
+
+ if not (chunk := session.execute(query).all()):
+ break
+ last_seen = (chunk[-1].bundle_name, chunk[-1].dag_id)
+
+ # Route every legacy row by fileloc, not just those on
+ # unconfigured bundles, so a migration-assigned dags-folder
+ # row whose file lives under a different configured bundle
+ # gets relocated instead of stranded. Classify as skip
+ # (no match), backfill (match == current bundle), or
+ # reassign (match != current bundle).
+ chunk_updates: list[tuple[str, str | None, str, str]] = []
+ for row in chunk:
+ match = (
+ _guess_best_bundle_for_fileloc(row.fileloc,
active_bundle_paths) if row.fileloc else None
+ )
+ if match is None:
+ total_skipped += 1
+ continue
+ target, relative = match
+ chunk_updates.append((row.dag_id, row.bundle_name, target,
relative))
+
+ if not chunk_updates:
+ continue
+
+ with create_session() as session:
+ # create_session commits on context exit, bounding the
+ # row-lock window to one chunk.
+ for dag_id, prev_bundle, target, relative in chunk_updates:
+ result = cast(
+ "CursorResult",
+ session.execute(
+ update(DagModel)
+ .where(
+ DagModel.dag_id == dag_id,
+ DagModel.relative_fileloc.is_(None),
+ ~exists().where(DagVersion.dag_id ==
DagModel.dag_id),
+ )
+ .values(relative_fileloc=relative,
bundle_name=target)
+ .execution_options(synchronize_session=False)
+ ),
+ )
+
+ if result.rowcount:
+ # Rowcount is the source of truth for whether the CAS
actually fired
+ if target == prev_bundle:
+ total_backfilled += 1
+ else:
+ movements[(prev_bundle, target)] += 1
+ total_reassigned += 1
+ else:
+ self.log.debug("Skipping repair for Dag '%s': lost
race to parser.", dag_id)
+
+ for (prev, target), n in movements.items():
+ self.log.info(
+ "Reassigning %d Dag(s) from bundle '%s' to '%s'",
+ n,
+ prev,
+ target,
+ )
+
+ if total_backfilled:
+ self.log.info("Backfilled relative_fileloc for %d legacy Dag(s).",
total_backfilled)
+
+ if total_skipped:
+ self.log.warning(
+ "Skipped %d legacy Dag(s) whose fileloc is not under any
configured bundle; "
+ "triggering them will keep raising \"Requested bundle '{name}'
is not configured.\" "
+ "until a bundle whose path contains the fileloc is added to "
+ "dag_bundle_config_list. The next parse will then restore them
automatically, "
+ "or run `airflow dags reserialize` to force the parse path to
rewrite "
+ "bundle_name and relative_fileloc immediately.",
+ total_skipped,
+ )
+
+ return total_reassigned
+
+ def _resolve_active_bundle_paths(self, *, session: Session) -> dict[str,
Path]:
+ """
+ Return paths for configured-and-active bundles.
+
+ A bundle is "configured-and-active" when it is both in the manager's
+ config and persisted as ``active=True`` in ``dag_bundle`` -- bundles
+ missing from ``dag_bundle`` are excluded so they can't trigger an FK
+ violation if used as a reassignment target.
+
+ The returned dict is ``{name: path}`` ordered by path length descending
+ so the most specific bundle wins in ``_guess_best_bundle_for_fileloc``.
+ """
+ active_db_names = set(
+
session.scalars(select(DagBundleModel.name).where(DagBundleModel.active.is_(True)))
+ )
+
+ active_bundle_paths: dict[str, Path] = {}
+ for bundle in self.get_all_dag_bundles():
+ if bundle.name not in active_db_names:
+ continue
+ active_bundle_paths[bundle.name] = bundle.path
+
+ return dict(sorted(active_bundle_paths.items(), key=lambda item:
len(str(item[1])), reverse=True))
+
@staticmethod
def _extract_template_params(bundle_instance: BaseDagBundle) -> dict:
"""
diff --git a/airflow-core/src/airflow/dag_processing/manager.py
b/airflow-core/src/airflow/dag_processing/manager.py
index c936d3548f6..47a1d5f8059 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -335,7 +335,14 @@ class DagFileProcessorManager(LoggingMixin):
"""Sync configured DAG bundles to the metadata database."""
# When this processor only parses a subset of bundles, it does not see
the full
# bundle configuration and must not deactivate bundles owned by other
processors.
- DagBundlesManager().sync_bundles_to_db(deactivate_missing=not
self.bundle_names_to_parse)
+ dag_bundle_manager = DagBundlesManager()
+ dag_bundle_manager.sync_bundles_to_db(deactivate_missing=not
self.bundle_names_to_parse)
+ # Best-effort legacy repair: a failure here must not crash DFP startup.
+ # Affected Dags self-heal on the next successful parse.
+ try:
+ dag_bundle_manager.reassign_dags_with_unconfigured_bundles()
+ except Exception:
+ self.log.exception("Failed to reassign Dags with unconfigured
bundles during startup")
def get_all_bundles(self) -> list[BaseDagBundle]:
"""Return configured DAG bundles filtered by ``bundle_names_to_parse``
if provided."""
@@ -466,6 +473,7 @@ class DagFileProcessorManager(LoggingMixin):
).where(~DagModel.is_stale)
dags_parsed = session.execute(query)
+ stuck_legacy_rows = 0
for dag in dags_parsed:
# Dags whose bundle has been removed from config (bundle no longer
active) are stale —
# the processor has stopped parsing their files, so the time-based
check below would never fire.
@@ -485,6 +493,16 @@ class DagFileProcessorManager(LoggingMixin):
)
to_deactivate.add(dag.dag_id)
continue
+ # A Dag upgraded from Airflow 2.x can still have a NULL
relative_fileloc:
+ # the 0082 migration adds the column as nullable, and the startup
repair
+ # in DagBundlesManager only backfills it when the Dag's fileloc
resolves to
+ # a configured bundle. Rows whose fileloc matches no bundle stay
NULL, so
+ # the time-based stale check below would build Path(None) and
crash. Skip
+ # them here and count them so the total is surfaced after the loop.
+ # See https://github.com/apache/airflow/issues/63323.
+ if dag.relative_fileloc is None:
+ stuck_legacy_rows += 1
+ continue
# When the Dag's last_parsed_time is more than the
stale_dag_threshold older than the
# Dag file's last_finish_time, the Dag is considered stale as has
apparently been removed from the file,
# This is especially relevant for Dag files that generate Dags in
a dynamic manner.
@@ -521,6 +539,15 @@ class DagFileProcessorManager(LoggingMixin):
else:
raise
+ if stuck_legacy_rows:
+ # Surface how many legacy rows the startup repair could not route;
+ # each one keeps raising "Requested bundle is not configured."
until
+ # a matching bundle is added to dag_bundle_config_list.
+ self.log.info(
+ "Skipped stale check for %d legacy Dag(s) with NULL
relative_fileloc.",
+ stuck_legacy_rows,
+ )
+
def _run_parsing_loop(self):
# initialize cache to mutualize calls to Variable.get in DAGs
# needs to be done before this process is forked to create the DAG
parsing processes.
diff --git
a/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py
b/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py
index 4b3fc1078ab..bbd18ec80d8 100644
--- a/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py
+++ b/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py
@@ -17,22 +17,28 @@
from __future__ import annotations
+import contextlib
import json
import os
from contextlib import nullcontext
+from pathlib import Path
+from unittest import mock
from unittest.mock import patch
import pytest
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
from airflow.dag_processing.bundles.base import BaseDagBundle
-from airflow.dag_processing.bundles.manager import DagBundlesManager
+from airflow.dag_processing.bundles.manager import DagBundlesManager,
_guess_best_bundle_for_fileloc
from airflow.exceptions import AirflowConfigException
+from airflow.models.dag import DagModel
+from airflow.models.dag_version import DagVersion
from airflow.models.dagbundle import DagBundleModel
from airflow.models.errors import ParseImportError
+from airflow.utils.session import create_session
from tests_common.test_utils.config import conf_vars
-from tests_common.test_utils.db import clear_db_dag_bundles
+from tests_common.test_utils.db import clear_db_dag_bundles, clear_db_dags
@pytest.mark.parametrize(
@@ -107,8 +113,9 @@ class BasicBundle(BaseDagBundle):
def get_current_version(self):
pass
- def path(self):
- pass
+ @property
+ def path(self) -> Path:
+ return Path("/__basic_bundle_unmatched__")
BASIC_BUNDLE_CONFIG = [
@@ -118,6 +125,13 @@ BASIC_BUNDLE_CONFIG = [
"kwargs": {"refresh_interval": 1},
}
]
+SECOND_BUNDLE_CONFIG = [
+ {
+ "name": "second-bundle",
+ "classpath":
"unit.dag_processing.bundles.test_dag_bundle_manager.BasicBundle",
+ "kwargs": {"refresh_interval": 1},
+ }
+]
OTHER_BUNDLE_CONFIG = [
{
@@ -532,3 +546,1248 @@ def test_get_all_bundle_names():
# the naming suffix instead of pinning an exact list.
extra = [n for n in bundle_names if n not in {"dags-folder",
"example_dags"}]
assert all(n.endswith("-example-dags") for n in extra)
+
+
[email protected]
+def clear_dags_and_bundles():
+ clear_db_dags()
+ clear_db_dag_bundles()
+ yield
+ clear_db_dags()
+ clear_db_dag_bundles()
+
+
+def _add_dag(session, dag_id: str, bundle_name: str) -> DagModel:
+ dag = DagModel(dag_id=dag_id, bundle_name=bundle_name,
fileloc=f"/tmp/{dag_id}.py")
+ session.add(dag)
+ session.flush()
+ return dag
+
+
+class TestGuessBestBundleForFileloc:
+ """Tests for ``_guess_best_bundle_for_fileloc`` path matching."""
+
+ @pytest.mark.parametrize(
+ ("fileloc", "bundle_paths", "expected"),
+ [
+ pytest.param(
+ "/dags/team_x/dag.py",
+ {"team-x": Path("/dags/team_x")},
+ ("team-x", "dag.py"),
+ id="match",
+ ),
+ pytest.param("/elsewhere/dag.py", {"team-x":
Path("/dags/team_x")}, None, id="no_match"),
+ pytest.param("/dags/dag.py", {}, None, id="empty_paths"),
+ pytest.param(
+ # ``Path`` itself collapses ``//`` and ``.`` segments on
construction,
+ # so the helper matches without any explicit normalization.
+ "/dags//team_x/./dag.py",
+ {"team-x": Path("/dags/team_x")},
+ ("team-x", "dag.py"),
+ id="redundant_separators_and_dots",
+ ),
+ ],
+ )
+ def test_guess_best_bundle_for_fileloc(self, fileloc, bundle_paths,
expected) -> None:
+ assert _guess_best_bundle_for_fileloc(fileloc, bundle_paths) ==
expected
+
+
[email protected]_test
+class TestReassignDagsWithUnconfiguredBundles:
+ """Tests for DagBundlesManager.reassign_dags_with_unconfigured_bundles."""
+
+ def _manager_with_bundle_names(self, names: list[str]) ->
DagBundlesManager:
+ """Return a manager whose ``_resolve_active_bundle_paths`` reports
*names* with non-matching paths.
+
+ The fake paths are absolute roots that cannot contain any
+ ``/tmp/{dag_id}.py`` test fileloc, so rows are routed as unmatched
+ unless a test explicitly arranges otherwise.
+ """
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(BASIC_BUNDLE_CONFIG)},
+ ):
+ manager = DagBundlesManager()
+ paths = {name: Path(f"/__unmatched_for_test__/{name}") for name in
names}
+ manager._resolve_active_bundle_paths = lambda *, session: paths #
type: ignore[method-assign]
+ return manager
+
+ def test_no_configured_bundles_is_noop(self, clear_dags_and_bundles,
session):
+ """Return 0 without raising when no bundles are configured."""
+ manager = self._manager_with_bundle_names([])
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+
+ def test_already_configured_is_noop(self, clear_dags_and_bundles, session)
-> None:
+ """No reassignment when every Dag already points at a configured
bundle."""
+ bundle = DagBundleModel(name="bundle-a")
+ bundle.active = True
+ session.add(bundle)
+ session.flush()
+ _add_dag(session, "dag-1", "bundle-a")
+ session.commit()
+
+ manager = self._manager_with_bundle_names(["bundle-a"])
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+ assert session.get(DagModel, "dag-1").bundle_name == "bundle-a"
+
+ def test_unmatched_fileloc_leaves_row_untouched(self,
clear_dags_and_bundles, session, caplog) -> None:
+ """Rows whose fileloc has no configured bundle path keep their
original bundle.
+
+ The fallback that wrote ``bundle_name`` without a verified
+ ``relative_fileloc`` produced active-but-un-runnable rows, so the
+ helper now leaves such rows on their unconfigured bundle and emits a
+ single warning naming the count.
+ """
+ active = DagBundleModel(name="active")
+ active.active = True
+ removed = DagBundleModel(name="removed-bundle")
+ removed.active = False
+ session.add(active)
+ session.add(removed)
+ session.flush()
+ _add_dag(session, "dag-1", "removed-bundle")
+ _add_dag(session, "dag-2", "removed-bundle")
+ session.commit()
+
+ manager = self._manager_with_bundle_names(["active"])
+ with caplog.at_level("WARNING",
logger="airflow.dag_processing.bundles.manager.DagBundlesManager"):
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+
+ session.expire_all()
+ for dag_id in ("dag-1", "dag-2"):
+ row = session.get(DagModel, dag_id)
+ assert row.bundle_name == "removed-bundle"
+ assert row.relative_fileloc is None
+ assert any("Skipped 2 legacy Dag(s)" in record.message for record in
caplog.records)
+
+ def test_row_with_populated_relative_fileloc_is_left_alone(self,
clear_dags_and_bundles, session) -> None:
+ """A 3.x row whose bundle is no longer configured must keep its bundle
assignment.
+
+ Only rows the 0082 migration touched (``relative_fileloc IS NULL``) are
+ candidates for reassignment. Rows that already carry a relative path
+ were written by 3.x serialization and must be left on their bundle so
+ the regular stale-Dag deactivation path can handle a removed bundle.
+ """
+ active_bundle = DagBundleModel(name="active")
+ active_bundle.active = True
+ removed_bundle = DagBundleModel(name="removed-bundle")
+ removed_bundle.active = False
+ session.add(active_bundle)
+ session.add(removed_bundle)
+ session.flush()
+
+ dag = DagModel(
+ dag_id="dag-1",
+ bundle_name="removed-bundle",
+ fileloc="/tmp/dag-1.py",
+ )
+ dag.relative_fileloc = "dag-1.py"
+ dag.bundle_version = "abc123"
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ manager = self._manager_with_bundle_names(["active"])
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "dag-1")
+ assert refreshed.bundle_name == "removed-bundle"
+ assert refreshed.relative_fileloc == "dag-1.py"
+ assert refreshed.bundle_version == "abc123"
+
+ def test_row_with_existing_dag_version_is_left_alone(self,
clear_dags_and_bundles, session) -> None:
+ """A Dag with any DagVersion row must not be reassigned.
+
+ Defended at two layers: (1) the global DagVersion-existence fast-skip
+ short-circuits before any work, and (2) the per-row predicate
+ ``NOT EXISTS DagVersion`` excludes the row even if the fast-skip is
+ bypassed. The parse path is the source of truth for any Dag with a
+ DagVersion -- touching only the DagModel would leave the DagVersion
+ stale, and scheduler/executor paths prefer DagVersion.bundle_name
+ when building task workloads.
+ """
+ active = DagBundleModel(name="active")
+ active.active = True
+ removed = DagBundleModel(name="removed-bundle")
+ removed.active = False
+ session.add(active)
+ session.add(removed)
+ session.flush()
+
+ # NULL relative_fileloc would normally make this a repair candidate;
+ # the DagVersion row should exclude it from the predicate.
+ dag = DagModel(
+ dag_id="versioned",
+ bundle_name="removed-bundle",
+ fileloc="/tmp/versioned.py",
+ )
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+
+ version = DagVersion(
+ dag_id="versioned",
+ version_number=1,
+ bundle_name="removed-bundle",
+ bundle_version="v1",
+ )
+ session.add(version)
+ session.flush()
+ session.commit()
+
+ manager = self._manager_with_bundle_names(["active"])
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "versioned")
+ assert refreshed.bundle_name == "removed-bundle"
+ assert refreshed.relative_fileloc is None
+ refreshed_version = session.get(DagVersion, version.id)
+ assert refreshed_version.bundle_name == "removed-bundle"
+ assert refreshed_version.bundle_version == "v1"
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_runs_under_multi_team_mode(self, clear_dags_and_bundles, session,
tmp_path) -> None:
+ """Multi-team mode still repairs legacy rows.
+
+ Each team's bundle owns a distinct on-disk path, so routing a legacy
+ row to the most-specific bundle whose path contains its ``fileloc``
+ cannot cross a team boundary. The repair therefore runs unchanged
+ under ``core.multi_team`` and must not blanket-skip rows that have a
+ safe target.
+ """
+ bundle_dir = tmp_path / "team_x"
+ bundle_dir.mkdir()
+ legacy_file = bundle_dir / "legacy.py"
+ legacy_file.write_text("# legacy dag")
+
+ config = [
+ {
+ "name": "team-x-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ active_bundle = DagBundleModel(name="team-x-bundle")
+ active_bundle.active = True
+ removed_bundle = DagBundleModel(name="removed-bundle")
+ removed_bundle.active = False
+ session.add(active_bundle)
+ session.add(removed_bundle)
+ session.flush()
+
+ dag = DagModel(dag_id="legacy", bundle_name="removed-bundle",
fileloc=str(legacy_file))
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+
+ assert count == 1
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy")
+ assert refreshed.bundle_name == "team-x-bundle"
+ assert refreshed.relative_fileloc == "legacy.py"
+
+
[email protected]_test
+class TestBackfillRelativeFileloc:
+ """Tests for the legacy ``relative_fileloc`` backfill triggered by
reassignment."""
+
+ @pytest.mark.parametrize(
+ ("fileloc_under_bundle", "expected_bundle_name",
"expected_relative_fileloc"),
+ [
+ pytest.param(True, "my-bundle", "legacy.py",
id="fileloc_under_bundle_path_is_backfilled"),
+ pytest.param(False, "orphan-bundle", None,
id="fileloc_outside_bundle_path_is_left_alone"),
+ ],
+ )
+ def test_backfill_behavior(
+ self,
+ clear_dags_and_bundles,
+ session,
+ tmp_path,
+ fileloc_under_bundle: bool,
+ expected_bundle_name: str,
+ expected_relative_fileloc: str | None,
+ ) -> None:
+ """Reassignment only happens when ``fileloc`` lies under a configured
bundle path.
+
+ When the fileloc matches, both ``bundle_name`` and ``relative_fileloc``
+ are written atomically. When it does not match, the row is left on its
+ unconfigured bundle so it cannot become an active-but-un-runnable row.
+
+ :param fileloc_under_bundle: Whether the legacy Dag's absolute
``fileloc`` lies under
+ the configured bundle's path.
+ :param expected_bundle_name: Expected ``bundle_name`` after
reassignment.
+ :param expected_relative_fileloc: Expected ``relative_fileloc`` after
reassignment.
+ """
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ legacy_file_under_bundle = bundle_dir / "legacy.py"
+ legacy_file_under_bundle.write_text("# legacy dag")
+ legacy_fileloc = str(legacy_file_under_bundle) if fileloc_under_bundle
else "/elsewhere/foo.py"
+
+ config = [
+ {
+ "name": "my-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ active_bundle = DagBundleModel(name="my-bundle")
+ active_bundle.active = True
+ orphan_bundle = DagBundleModel(name="orphan-bundle")
+ orphan_bundle.active = False
+ session.add(active_bundle)
+ session.add(orphan_bundle)
+ session.flush()
+
+ dag = DagModel(dag_id="legacy", bundle_name="orphan-bundle",
fileloc=legacy_fileloc)
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ manager.reassign_dags_with_unconfigured_bundles()
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy")
+ assert refreshed.bundle_name == expected_bundle_name
+ assert refreshed.relative_fileloc == expected_relative_fileloc
+
+ def test_post_2x_to_3x_migration_with_renamed_bundle(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """End-to-end: 2.x→3.x upgrade where the operator's bundle is not
named ``dags-folder``.
+
+ Reproduces the original incident: the migration sets
``bundle_name='dags-folder'`` on
+ legacy Dag rows and leaves ``relative_fileloc`` NULL, but the operator
has configured a
+ single LocalDagBundle named ``custom-bundle`` pointing at the same
on-disk dags folder.
+ After ``reassign_dags_with_unconfigured_bundles`` runs at DFP startup,
the legacy Dags
+ should be:
+
+ 1. Reassigned to ``custom-bundle`` so triggering DagRuns no longer
fails with
+ "Requested bundle 'dags-folder' is not configured."
+ 2. Have ``relative_fileloc`` backfilled from ``fileloc`` so the
standard fileloc-based
+ stale-detection path can later detect real deletions.
+ """
+ dags_folder = tmp_path / "dags"
+ dags_folder.mkdir()
+ legacy_file = dags_folder / "my_dag.py"
+ legacy_file.write_text("# 2.x dag")
+
+ config = [
+ {
+ "name": "custom-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(dags_folder), "refresh_interval": 1},
+ }
+ ]
+ # State right after the 0082 migration: ``dags-folder`` row exists in
dag_bundle (added
+ # by migration backfill) but is not in the user's config; the user's
``custom-bundle``
+ # has not been registered yet (sync_bundles_to_db does that on
startup).
+ legacy_default_bundle = DagBundleModel(name="dags-folder")
+ legacy_default_bundle.active = True
+ session.add(legacy_default_bundle)
+ session.flush()
+
+ legacy_dag = DagModel(
+ dag_id="legacy_dag",
+ bundle_name="dags-folder",
+ fileloc=str(legacy_file),
+ )
+ legacy_dag.relative_fileloc = None
+ session.add(legacy_dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ # DFP startup order: register configured bundles, then reassign.
+ manager.sync_bundles_to_db(session=session)
+ session.commit()
+ count = manager.reassign_dags_with_unconfigured_bundles()
+
+ assert count == 1
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy_dag")
+ assert refreshed.bundle_name == "custom-bundle"
+ assert refreshed.relative_fileloc == "my_dag.py"
+
+ def test_backfill_commits_between_chunks(
+ self, clear_dags_and_bundles, session, tmp_path, monkeypatch
+ ) -> None:
+ """The legacy backfill chunks and commits like the reassignment loop.
+
+ Without chunked commits, a deployment where every legacy Dag is
+ already on a configured bundle would still UPDATE the whole set in
+ one transaction, holding row locks on the dag table for the full
+ DFP startup.
+ """
+ from airflow.dag_processing.bundles import manager as
bundles_manager_mod
+
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ for i in range(5):
+ (bundle_dir / f"legacy_{i}.py").write_text(f"# legacy {i}")
+
+ config = [
+ {
+ "name": "my-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ active_bundle = DagBundleModel(name="my-bundle")
+ active_bundle.active = True
+ session.add(active_bundle)
+ session.flush()
+
+ for i in range(5):
+ dag = DagModel(
+ dag_id=f"legacy_{i}",
+ bundle_name="my-bundle",
+ fileloc=str(bundle_dir / f"legacy_{i}.py"),
+ )
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ monkeypatch.setattr(bundles_manager_mod, "_REASSIGN_BATCH_SIZE", 2)
+ # Each chunk opens its own ``create_session`` (which commits on exit),
+ # so counting context-manager entries equals counting batch commits.
+ session_open_count = [0]
+ real_create_session = bundles_manager_mod.create_session
+
+ @contextlib.contextmanager
+ def _counting_create_session(*args, **kwargs):
+ session_open_count[0] += 1
+ with real_create_session(*args, **kwargs) as s:
+ yield s
+
+ monkeypatch.setattr(bundles_manager_mod, "create_session",
_counting_create_session)
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ # No unconfigured rows exist, so the reassignment loop returns
+ # immediately and the helper drives the chunking we are testing.
+ manager.reassign_dags_with_unconfigured_bundles()
+
+ # 1 initial session (DagVersion probe + active-bundle read), then per
loop:
+ # 5 rows / batch size 2 => SELECT chunks of 2, 2, 1, plus one empty
chunk that
+ # terminates the loop (4 SELECT sessions), and one UPDATE session per
non-empty
+ # chunk (3). Total: 1 + 4 + 3 = 8.
+ assert session_open_count[0] == 8
+
+ session.expire_all()
+ for i in range(5):
+ refreshed = session.get(DagModel, f"legacy_{i}")
+ assert refreshed.relative_fileloc == f"legacy_{i}.py"
+
+ def test_backfill_does_not_overwrite_concurrent_parser_write(
+ self, clear_dags_and_bundles, session, tmp_path, monkeypatch
+ ) -> None:
+ """A concurrent parser write between SELECT and UPDATE keeps its value.
+
+ Mirrors the race-safety regression on the reassignment UPDATE; the
+ backfill UPDATE re-asserts ``relative_fileloc IS NULL`` so the
+ parser's write is authoritative.
+ """
+ from airflow.dag_processing.bundles import manager as
bundles_manager_mod
+
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ legacy_file = bundle_dir / "legacy.py"
+ legacy_file.write_text("# legacy")
+
+ config = [
+ {
+ "name": "my-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ active_bundle = DagBundleModel(name="my-bundle")
+ active_bundle.active = True
+ session.add(active_bundle)
+ session.flush()
+
+ dag = DagModel(
+ dag_id="legacy",
+ bundle_name="my-bundle",
+ fileloc=str(legacy_file),
+ )
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ # Drive the race: the helper invokes ``Path(fileloc)`` and then
+ # ``.relative_to(bundle_path)``. Wrap the Path constructor so the
+ # returned object commits a parser write before delegating to the
+ # real ``relative_to``.
+ original_path_cls = bundles_manager_mod.Path
+
+ def _racey_path_factory(arg):
+ real_path = original_path_cls(arg)
+
+ def _racey_relative_to(*args, **kwargs):
+ with create_session() as racer:
+ racer.execute(
+ update(DagModel)
+ .where(DagModel.dag_id == "legacy")
+ .values(relative_fileloc="parser_wrote_this.py")
+ )
+ return type(real_path).relative_to(real_path, *args, **kwargs)
+
+ patcher = mock.MagicMock(wraps=real_path)
+ patcher.relative_to = _racey_relative_to
+ return patcher
+
+ monkeypatch.setattr(bundles_manager_mod, "Path", _racey_path_factory)
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ manager.reassign_dags_with_unconfigured_bundles()
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy")
+ assert refreshed.relative_fileloc == "parser_wrote_this.py"
+
+
[email protected]_test
+class TestSmarterRouting:
+ """Tests for per-row best-bundle routing in
``reassign_dags_with_unconfigured_bundles``."""
+
+ def test_routes_to_bundle_whose_path_contains_fileloc(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """When multiple bundles are configured, route each Dag to the bundle
whose path matches."""
+ bundle_a_dir = tmp_path / "bundle_a"
+ bundle_b_dir = tmp_path / "bundle_b"
+ bundle_a_dir.mkdir()
+ bundle_b_dir.mkdir()
+ dag_a_file = bundle_a_dir / "dag_a.py"
+ dag_b_file = bundle_b_dir / "dag_b.py"
+ dag_a_file.write_text("# a")
+ dag_b_file.write_text("# b")
+
+ config = [
+ {
+ "name": "bundle-a",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_a_dir), "refresh_interval": 1},
+ },
+ {
+ "name": "bundle-b",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_b_dir), "refresh_interval": 1},
+ },
+ ]
+ for name, active in [("bundle-a", True), ("bundle-b", True),
("orphan", False)]:
+ b = DagBundleModel(name=name)
+ b.active = active
+ session.add(b)
+ session.flush()
+
+ for dag_id, fileloc in [("dag-a", str(dag_a_file)), ("dag-b",
str(dag_b_file))]:
+ dag = DagModel(dag_id=dag_id, bundle_name="orphan",
fileloc=fileloc)
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ count = manager.reassign_dags_with_unconfigured_bundles()
+
+ assert count == 2
+ session.expire_all()
+ # Each Dag goes to the bundle whose path contains its fileloc, not
just the first one.
+ a = session.get(DagModel, "dag-a")
+ assert a.bundle_name == "bundle-a"
+ assert a.relative_fileloc == "dag_a.py"
+ b = session.get(DagModel, "dag-b")
+ assert b.bundle_name == "bundle-b"
+ assert b.relative_fileloc == "dag_b.py"
+
+ def test_longest_matching_path_wins_for_overlapping_bundles(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """When bundle paths nest, route to the most-specific bundle."""
+ outer_dir = tmp_path / "outer"
+ inner_dir = outer_dir / "team_x"
+ inner_dir.mkdir(parents=True)
+ dag_file = inner_dir / "deep_dag.py"
+ dag_file.write_text("# deep")
+
+ config = [
+ {
+ "name": "outer-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(outer_dir), "refresh_interval": 1},
+ },
+ {
+ "name": "team-x-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(inner_dir), "refresh_interval": 1},
+ },
+ ]
+ for name in ("outer-bundle", "team-x-bundle"):
+ b = DagBundleModel(name=name)
+ b.active = True
+ session.add(b)
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = False
+ session.add(orphan)
+ session.flush()
+
+ dag = DagModel(dag_id="deep", bundle_name="orphan",
fileloc=str(dag_file))
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ manager.reassign_dags_with_unconfigured_bundles()
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "deep")
+ # Most-specific (deeper) bundle wins.
+ assert refreshed.bundle_name == "team-x-bundle"
+ assert refreshed.relative_fileloc == "deep_dag.py"
+
+ def test_no_path_match_leaves_row_unchanged(
+ self, clear_dags_and_bundles, session, tmp_path, caplog
+ ) -> None:
+ """A Dag whose fileloc is outside every configured bundle is left
untouched.
+
+ Writing ``bundle_name`` without a verified ``relative_fileloc`` would
+ produce an active row that task workloads cannot execute (no
+ ``dag_rel_path``). The row stays on its unconfigured bundle so the
+ existing "Requested bundle '{name}' is not configured." error at
+ trigger time gives the operator an actionable signal.
+ """
+ bundle_dir = tmp_path / "configured"
+ bundle_dir.mkdir()
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ configured = DagBundleModel(name="configured-bundle")
+ configured.active = True
+ session.add(configured)
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = False
+ session.add(orphan)
+ session.flush()
+
+ dag = DagModel(dag_id="elsewhere", bundle_name="orphan",
fileloc="/somewhere/else/dag.py")
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with (
+ patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ),
+ caplog.at_level("WARNING",
logger="airflow.dag_processing.bundles.manager.DagBundlesManager"),
+ ):
+ manager = DagBundlesManager()
+ assert manager.reassign_dags_with_unconfigured_bundles() == 0
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "elsewhere")
+ assert refreshed.bundle_name == "orphan"
+ assert refreshed.relative_fileloc is None
+ assert any("Skipped 1 legacy Dag(s)" in record.message for record in
caplog.records)
+
+ def test_legacy_row_on_active_default_routed_to_better_match(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """Migration-assigned ``dags-folder`` rows are re-routed when another
bundle owns the file.
+
+ Migration 0082 assigns every legacy 2.x row to ``dags-folder`` by
+ default. An operator that keeps a configured ``dags-folder`` bundle
+ alongside another bundle whose path contains the Dag's ``fileloc``
+ must see the Dag reassigned to the better-matching bundle -- not
+ stranded on ``dags-folder`` just because that name is still active.
+ """
+ dags_folder_dir = tmp_path / "dags-folder"
+ team_x_dir = tmp_path / "team_x"
+ dags_folder_dir.mkdir()
+ team_x_dir.mkdir()
+ team_x_file = team_x_dir / "team_x_dag.py"
+ team_x_file.write_text("# team-x dag")
+
+ config = [
+ {
+ "name": "dags-folder",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(dags_folder_dir), "refresh_interval":
1},
+ },
+ {
+ "name": "team-x-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(team_x_dir), "refresh_interval": 1},
+ },
+ ]
+ for name in ("dags-folder", "team-x-bundle"):
+ bundle = DagBundleModel(name=name)
+ bundle.active = True
+ session.add(bundle)
+ session.flush()
+
+ # Migration state: bundle_name set to ``dags-folder`` (still in the
+ # active config!) but fileloc actually lives under ``team-x-bundle``.
+ dag = DagModel(dag_id="team_x_dag", bundle_name="dags-folder",
fileloc=str(team_x_file))
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+
+ assert count == 1
+ session.expire_all()
+ refreshed = session.get(DagModel, "team_x_dag")
+ assert refreshed.bundle_name == "team-x-bundle"
+ assert refreshed.relative_fileloc == "team_x_dag.py"
+
+ def test_legacy_row_on_correct_bundle_only_backfills_relative_fileloc(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """A legacy row whose ``fileloc`` matches its current bundle keeps the
bundle.
+
+ The repair must not rewrite ``bundle_name`` when the best match is
+ the same bundle the row already points at; it only needs to fill in
+ the missing ``relative_fileloc``.
+ """
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ legacy_file = bundle_dir / "legacy.py"
+ legacy_file.write_text("# legacy")
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ configured = DagBundleModel(name="configured-bundle")
+ configured.active = True
+ session.add(configured)
+ session.flush()
+
+ dag = DagModel(dag_id="legacy", bundle_name="configured-bundle",
fileloc=str(legacy_file))
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ # The reassignment counter only includes rows whose bundle_name
+ # changed; a same-bundle backfill is not counted as a reassignment.
+ count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+
+ assert count == 0
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy")
+ assert refreshed.bundle_name == "configured-bundle"
+ assert refreshed.relative_fileloc == "legacy.py"
+
+
[email protected]_test
+class TestBatching:
+ """Tests for the chunked-commit pattern in
``reassign_dags_with_unconfigured_bundles``."""
+
+ def test_repair_commits_between_chunks(
+ self, clear_dags_and_bundles, session, tmp_path, monkeypatch
+ ) -> None:
+ """All matched rows are repaired and the loop commits between chunks.
+
+ Each ``session.commit()`` bounds the row-lock window to one chunk,
+ which is the load-bearing property on a large 2.x-upgraded
+ deployment.
+ """
+ from airflow.dag_processing.bundles import manager as
bundles_manager_mod
+
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ dag_files = []
+ for i in range(5):
+ dag_file = bundle_dir / f"dag_{i}.py"
+ dag_file.write_text(f"# dag {i}")
+ dag_files.append(dag_file)
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ configured = DagBundleModel(name="configured-bundle")
+ configured.active = True
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = False
+ session.add(configured)
+ session.add(orphan)
+ session.flush()
+
+ for i, dag_file in enumerate(dag_files):
+ dag = DagModel(
+ dag_id=f"dag_{i}",
+ bundle_name="orphan",
+ fileloc=str(dag_file),
+ )
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit() # baseline state visible to the repair's own commits
+
+ monkeypatch.setattr(bundles_manager_mod, "_REASSIGN_BATCH_SIZE", 2)
+ # Each chunk opens its own ``create_session`` (which commits on exit),
+ # so counting context-manager entries equals counting batch commits.
+ session_open_count = [0]
+ real_create_session = bundles_manager_mod.create_session
+
+ @contextlib.contextmanager
+ def _counting_create_session(*args, **kwargs):
+ session_open_count[0] += 1
+ with real_create_session(*args, **kwargs) as s:
+ yield s
+
+ monkeypatch.setattr(bundles_manager_mod, "create_session",
_counting_create_session)
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ count = manager.reassign_dags_with_unconfigured_bundles()
+
+ assert count == 5
+ # 1 session for the active-bundle read + 5 rows / batch size 2
+ # = chunks of 2, 2, 1, then an empty chunk that terminates the loop.
+ assert session_open_count[0] >= 4
+
+ session.expire_all()
+ for i in range(5):
+ refreshed = session.get(DagModel, f"dag_{i}")
+ assert refreshed.bundle_name == "configured-bundle"
+ assert refreshed.relative_fileloc == f"dag_{i}.py"
+
+
[email protected]_test
+class TestRaceSafety:
+ """Tests that the repair UPDATE does not overwrite concurrent parser
writes."""
+
+ def test_concurrent_parse_between_select_and_update_wins(
+ self, clear_dags_and_bundles, session, tmp_path, monkeypatch
+ ) -> None:
+ """A parser that lands a write between our SELECT and UPDATE keeps its
values.
+
+ The repair's UPDATE re-asserts ``relative_fileloc IS NULL`` (and the
+ DagVersion absence) so SQL evaluates against committed state when the
+ UPDATE runs. A concurrent parse that wrote the real values first
+ makes our UPDATE match zero rows; we must not overwrite it.
+ """
+ from airflow.dag_processing.bundles import manager as
bundles_manager_mod
+
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ dag_file = bundle_dir / "raced.py"
+ dag_file.write_text("# raced")
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ configured = DagBundleModel(name="configured-bundle")
+ configured.active = True
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = False
+ session.add(configured)
+ session.add(orphan)
+ session.flush()
+
+ dag = DagModel(dag_id="raced", bundle_name="orphan",
fileloc=str(dag_file))
+ dag.relative_fileloc = None
+ session.add(dag)
+ session.flush()
+ session.commit()
+
+ # Drive the race: before _guess_best_bundle_for_fileloc returns (which
+ # sits between the chunk SELECT and the per-row UPDATE), simulate a
+ # concurrent parser that has already committed the real values.
+ original = bundles_manager_mod._guess_best_bundle_for_fileloc
+
+ def _racey_match(fileloc, active_bundle_paths):
+ with create_session() as racer:
+ racer.execute(
+ update(DagModel)
+ .where(DagModel.dag_id == "raced")
+ .values(
+ bundle_name="configured-bundle",
+ relative_fileloc="parser_wrote_this.py",
+ )
+ )
+ return original(fileloc, active_bundle_paths)
+
+ monkeypatch.setattr(bundles_manager_mod,
"_guess_best_bundle_for_fileloc", _racey_match)
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ count = manager.reassign_dags_with_unconfigured_bundles()
+
+ # CAS guard prevents the repair from clobbering the parser write.
+ assert count == 0
+ session.expire_all()
+ refreshed = session.get(DagModel, "raced")
+ assert refreshed.bundle_name == "configured-bundle"
+ assert refreshed.relative_fileloc == "parser_wrote_this.py"
+
+
[email protected]_test
+class TestHighAvailabilityStartup:
+ """Two DFPs entering the repair path concurrently must converge safely."""
+
+ def _build_dataset(self, session, bundle_dir):
+ """Insert one reassignment-eligible row and one backfill-eligible
row."""
+ reassign_file = bundle_dir / "reassign.py"
+ backfill_file = bundle_dir / "backfill.py"
+ reassign_file.write_text("# reassign")
+ backfill_file.write_text("# backfill")
+
+ configured = DagBundleModel(name="configured-bundle")
+ configured.active = True
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = False
+ session.add(configured)
+ session.add(orphan)
+ session.flush()
+
+ # Hits the reassignment branch: bundle is unconfigured, fileloc lies
+ # under a configured bundle's path.
+ reassign_dag = DagModel(dag_id="reassign", bundle_name="orphan",
fileloc=str(reassign_file))
+ reassign_dag.relative_fileloc = None
+ session.add(reassign_dag)
+
+ # Hits the backfill branch: bundle is already configured, but
+ # relative_fileloc is NULL (legacy row).
+ backfill_dag = DagModel(
+ dag_id="backfill", bundle_name="configured-bundle",
fileloc=str(backfill_file)
+ )
+ backfill_dag.relative_fileloc = None
+ session.add(backfill_dag)
+ session.flush()
+ session.commit()
+
+ def test_sequential_repeat_is_idempotent(self, clear_dags_and_bundles,
session, tmp_path) -> None:
+ """Running the full repair twice over the same dataset must be a no-op
the second pass.
+
+ Simulates two DFPs starting up one after the other (or the same DFP
+ restarting): the second pass's SELECT must find an empty set because
+ the first pass either repaired the row (now non-NULL
+ relative_fileloc) or skipped it (cursor advances past on the first
+ pass alone, but the second pass's predicate would also exclude
+ repaired rows directly).
+ """
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ self._build_dataset(session, bundle_dir)
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ first_count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+ session.expire_all()
+ after_first = {
+ row.dag_id: (row.bundle_name, row.relative_fileloc)
+ for row in session.execute(select(DagModel)).scalars()
+ }
+
+ second_count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+ session.expire_all()
+ after_second = {
+ row.dag_id: (row.bundle_name, row.relative_fileloc)
+ for row in session.execute(select(DagModel)).scalars()
+ }
+
+ assert first_count == 1 # one reassignment fires on pass 1
+ assert second_count == 0 # nothing to do on pass 2
+ assert after_first == after_second
+ assert after_first["reassign"] == ("configured-bundle", "reassign.py")
+ assert after_first["backfill"] == ("configured-bundle", "backfill.py")
+
+ def test_interleaved_dfps_do_not_overwrite_each_other(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ """Two DFPs both SELECT the same row; the second to UPDATE must no-op.
+
+ Mirrors a multi-DFP startup where both processes hit the repair at
+ the same time. The CAS guards on the UPDATE statements ensure that
+ whichever transaction commits second sees zero rows match its
+ WHERE clause, leaving the first commit authoritative.
+ """
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ self._build_dataset(session, bundle_dir)
+
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+
+ # Hand-driven interleaving: simulate DFP A and DFP B both observing
+ # the unrepaired state, then DFP A commits first, then DFP B's
+ # UPDATEs run against committed state with the CAS guards in place.
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ # DFP B reads the legacy state first (still NULL relative_fileloc).
+ with create_session() as session_b:
+ pre_b = session_b.execute(
+ select(DagModel.dag_id,
DagModel.relative_fileloc).order_by(DagModel.dag_id)
+ ).all()
+ assert [r[1] for r in pre_b] == [None, None]
+
+ # DFP A runs the full repair (commits internally per chunk).
+ DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+
+ # DFP B now runs its repair against the post-A committed state.
+ # The CAS guards (relative_fileloc IS NULL) make every UPDATE a
+ # no-op because A has already filled in the values.
+ b_count =
DagBundlesManager().reassign_dags_with_unconfigured_bundles()
+ assert b_count == 0
+
+ session.expire_all()
+ reassign_row = session.get(DagModel, "reassign")
+ backfill_row = session.get(DagModel, "backfill")
+ assert reassign_row.bundle_name == "configured-bundle"
+ assert reassign_row.relative_fileloc == "reassign.py"
+ assert backfill_row.bundle_name == "configured-bundle"
+ assert backfill_row.relative_fileloc == "backfill.py"
+
+
[email protected]_test
+class TestSyncAndReassign:
+ """Tests for sync_bundles_to_db followed by
reassign_dags_with_unconfigured_bundles."""
+
+ def _sync_and_reassign(self, config: list[dict], session) -> None:
+ """Sync bundles to DB and reassign DAGs with unconfigured bundles.
+
+ :param config: Bundle config list to use for this sync cycle.
+ :param session: SQLAlchemy session.
+ """
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ manager.sync_bundles_to_db(session=session)
+ session.commit()
+ manager.reassign_dags_with_unconfigured_bundles()
+
+ @conf_vars({("core", "LOAD_EXAMPLES"): "False"})
+ @pytest.mark.parametrize(
+ "second_config",
+ [
+ pytest.param(SECOND_BUNDLE_CONFIG,
id="bundle_removed_no_path_match"),
+ pytest.param(BASIC_BUNDLE_CONFIG,
id="bundle_active_dag_unchanged"),
+ ],
+ )
+ def test_sync_preserves_dag_bundle_without_path_match(
+ self,
+ clear_dags_and_bundles,
+ session,
+ second_config: list[dict],
+ ) -> None:
+ """Sync + reassign keeps the original bundle when no configured path
matches the fileloc.
+
+ Both parameter cases insert a Dag whose ``fileloc`` is
``/tmp/dag-1.py``,
+ which is not under any test bundle's path (``BasicBundle.path`` is a
+ no-op). When the original bundle is removed from config the helper used
+ to silently rewrite ``bundle_name`` to the first configured bundle even
+ with no path match; it now leaves the row alone so the row never
+ becomes active-but-un-runnable. When the bundle is still configured the
+ row is untouched as before.
+
+ :param second_config: Bundle config for the second sync cycle.
+ """
+ self._sync_and_reassign(BASIC_BUNDLE_CONFIG, session)
+
+ _add_dag(session, "dag-1", "my-test-bundle")
+ session.commit()
+
+ self._sync_and_reassign(second_config, session)
+
+ dag = session.get(DagModel, "dag-1")
+ assert dag.bundle_name == "my-test-bundle"
+
+
[email protected]_test
+class TestSkippedRowLifecycle:
+ """End-to-end coverage for the sync -> repair skip -> stale-scan ->
re-parse path.
+
+ A legacy 2.x row with an unconfigured bundle and a fileloc outside every
+ configured bundle is intentionally left untouched by the repair. The
+ inactive-bundle branch of ``deactivate_stale_dags`` then marks it stale,
+ and a later successful parse from a now-configured bundle must restore it
+ end-to-end. Each unit-level test exercises one stage of this lifecycle;
+ this test pins the whole sequence so a future refactor can't silently
+ break the recovery contract.
+ """
+
+ def test_skipped_row_is_recoverable_after_operator_fix(
+ self, clear_dags_and_bundles, session, tmp_path
+ ) -> None:
+ from airflow.dag_processing.collection import
update_dag_parsing_results_in_db
+ from airflow.dag_processing.manager import DagFileProcessorManager
+ from airflow.sdk import DAG
+ from airflow.serialization.serialized_objects import
LazyDeserializedDAG
+
+ bundle_dir = tmp_path / "dags"
+ bundle_dir.mkdir()
+ legacy_file = bundle_dir / "legacy.py"
+ legacy_file.write_text("# legacy dag")
+
+ # Pre-sync state: legacy row points at the orphan bundle, fileloc is
+ # outside any configured bundle path, relative_fileloc is NULL.
+ orphan = DagBundleModel(name="orphan")
+ orphan.active = True
+ session.add(orphan)
+ session.flush()
+ legacy = DagModel(
+ dag_id="legacy_dag",
+ bundle_name="orphan",
+ fileloc="/elsewhere/legacy.py",
+ last_parsed_time=None,
+ )
+ legacy.relative_fileloc = None
+ legacy.is_stale = False
+ session.add(legacy)
+ session.commit()
+
+ # Operator removes the orphan bundle and configures a different one
+ # whose path doesn't contain the legacy fileloc.
+ config = [
+ {
+ "name": "configured-bundle",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(bundle_dir), "refresh_interval": 1},
+ }
+ ]
+ with patch.dict(
+ os.environ,
+ {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST":
json.dumps(config)},
+ ):
+ manager = DagBundlesManager()
+ manager.sync_bundles_to_db(session=session)
+ session.commit()
+ # Orphan bundle is now inactive; legacy row's bundle_name still
points at it.
+ assert session.get(DagBundleModel, "orphan").active is False
+
+ # Repair skips the row -- no fileloc match means no atomic
+ # bundle_name + relative_fileloc write is possible.
+ reassigned = manager.reassign_dags_with_unconfigured_bundles()
+ assert reassigned == 0
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy_dag")
+ assert refreshed.bundle_name == "orphan"
+ assert refreshed.relative_fileloc is None
+ assert refreshed.is_stale is False
+
+ # Stale-Dag scan deactivates the row via the inactive-bundle
+ # branch, which runs before the NULL relative_fileloc guard.
+ dfp_manager = DagFileProcessorManager(max_runs=1,
processor_timeout=10 * 60)
+ dfp_manager.deactivate_stale_dags(last_parsed={})
+ session.expire_all()
+ stale_row = session.get(DagModel, "legacy_dag")
+ assert stale_row.is_stale is True
+
+ # Operator fix: the legacy file is now inside the configured
+ # bundle's path. The parser re-parses it and the standard
+ # collection write resets is_stale, bundle_name, and
+ # relative_fileloc in a single transaction.
+ recovered_dag = DAG(dag_id="legacy_dag")
+ recovered_dag.fileloc = str(legacy_file)
+ recovered_dag.relative_fileloc = "legacy.py"
+ update_dag_parsing_results_in_db(
+ "configured-bundle",
+ None,
+ [LazyDeserializedDAG.from_dag(recovered_dag)],
+ {},
+ None,
+ set(),
+ session,
+ )
+ session.commit()
+
+ session.expire_all()
+ restored = session.get(DagModel, "legacy_dag")
+ assert restored.is_stale is False
+ assert restored.bundle_name == "configured-bundle"
+ assert restored.relative_fileloc == "legacy.py"
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 23e1d97e6f7..327acf75d0a 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -1109,6 +1109,68 @@ class TestDagFileProcessorManager:
assert session.scalar(select(DagModel.is_stale).where(DagModel.dag_id
== "legacy_dag"))
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def test_deactivate_stale_dags_tolerates_null_relative_fileloc(self,
session):
+ """An active Dag with ``relative_fileloc=None`` must not crash the
stale scan.
+
+ Upgrade leftover: the 0082 migration writes ``bundle_name`` without
+ ``relative_fileloc``, and the startup repair leaves a row untouched
when its
+ ``fileloc`` is not under any configured bundle's path. The scanner
must skip the
+ file-based check for such rows rather than calling ``Path(None)``.
+ """
+ session.add(
+ DagModel(
+ dag_id="dag_null_relfileloc",
+ bundle_name="testing",
+ fileloc="/not/under/any/bundle.py",
+ relative_fileloc=None,
+ last_parsed_time=timezone.utcnow(),
+ is_stale=False,
+ )
+ )
+ session.flush()
+
+ manager = DagFileProcessorManager(max_runs=1, processor_timeout=10 *
60)
+ manager.deactivate_stale_dags(last_parsed={})
+
+ is_stale =
session.scalar(select(DagModel.is_stale).where(DagModel.dag_id ==
"dag_null_relfileloc"))
+ assert is_stale is False
+
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def test_deactivate_stale_dags_logs_stuck_legacy_row_count(self, session):
+ """Skipped NULL-``relative_fileloc`` rows are counted and surfaced via
a single INFO log.
+
+ Operators need visibility into how many legacy migration rows the
+ startup repair could not route, because every such row keeps raising
+ ``Requested bundle is not configured.`` at trigger time until the
+ operator restores a matching bundle. The log line is the only
+ operator-facing signal, so this test asserts it is emitted with the
+ expected count. (Allowed exception to the "don't assert on log
+ output" convention: the log line *is* the behaviour under test.)
+ """
+ for dag_id in ("legacy_a", "legacy_b", "legacy_c"):
+ session.add(
+ DagModel(
+ dag_id=dag_id,
+ bundle_name="testing",
+ fileloc=f"/not/under/any/{dag_id}.py",
+ relative_fileloc=None,
+ last_parsed_time=timezone.utcnow(),
+ is_stale=False,
+ )
+ )
+ session.flush()
+
+ manager = DagFileProcessorManager(max_runs=1, processor_timeout=10 *
60)
+ with mock.patch.object(manager.log, "info") as mock_info:
+ manager.deactivate_stale_dags(last_parsed={})
+
+ legacy_log_calls = [
+ call for call in mock_info.call_args_list if "legacy Dag" in
(call.args[0] if call.args else "")
+ ]
+ assert len(legacy_log_calls) == 1
+ assert legacy_log_calls[0].args[1] == 3
+
@mock.patch("airflow.dag_processing.manager.is_lock_not_available_error")
@pytest.mark.usefixtures("testing_dag_bundle")
def test_deactivate_stale_dags_handles_lock_timeout(self,
mock_is_lock_not_available, session, caplog):
@@ -1664,6 +1726,29 @@ class TestDagFileProcessorManager:
assert session.get(DagModel, "test_dag1").is_stale is
expected_dag1_stale
assert session.get(DagModel, "test_dag2").is_stale is
expected_dag2_stale
+ def test_deactivate_deleted_dags_marks_null_relative_fileloc_stale(self,
dag_maker, session):
+ """DAGs with NULL ``relative_fileloc`` are also stale-marked when not
in the observed file set.
+
+ Legacy 2.x rows that the bundle backfill couldn't recover (``fileloc``
not under any active
+ bundle path) get treated as deleted on the first parse cycle. Alive
rows self-heal when the
+ parser next succeeds and resets ``is_stale`` via ``update_dags``.
+ """
+ with dag_maker("parsed_dag") as dag1:
+ dag1.relative_fileloc = "parsed_dag.py"
+ with dag_maker("legacy_dag") as dag2:
+ dag2.relative_fileloc = None
+ dag_maker.sync_dagbag_to_db()
+
+ any_deactivated = DagModel.deactivate_deleted_dags(
+ bundle_name="dag_maker",
+ rel_filelocs=set(),
+ session=session,
+ )
+
+ assert any_deactivated is True
+ assert session.get(DagModel, "parsed_dag").is_stale is True
+ assert session.get(DagModel, "legacy_dag").is_stale is True
+
@pytest.mark.parametrize(
("active_files", "should_call_cleanup"),
[
@@ -2174,6 +2259,110 @@ class TestDagFileProcessorManager:
assert manager.fetch_callbacks() is expected
private.assert_called_once_with()
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+ def test_reassign_called_once_at_startup_not_on_refresh(self,
mock_bundle_manager):
+ """
+ reassign_dags_with_unconfigured_bundles is called exactly once by
+ sync_bundles, not by _refresh_dag_bundles.
+ """
+ manager = DagFileProcessorManager(max_runs=1)
+ manager._dag_bundles = []
+
+ manager.sync_bundles()
+
mock_bundle_manager.return_value.reassign_dags_with_unconfigured_bundles.assert_called_once()
+
+ manager._refresh_dag_bundles(known_files={})
+
mock_bundle_manager.return_value.reassign_dags_with_unconfigured_bundles.assert_called_once()
+
+ @mock.patch("airflow.dag_processing.manager.DagBundlesManager")
+ def test_reassign_failure_during_startup_is_logged_and_swallowed(self,
mock_bundle_manager):
+ """A reassignment failure must not crash DFP startup; it is logged and
execution continues."""
+ manager = DagFileProcessorManager(max_runs=1)
+ manager._dag_bundles = []
+ manager._log = mock.MagicMock()
+
mock_bundle_manager.return_value.reassign_dags_with_unconfigured_bundles.side_effect
= RuntimeError(
+ "boom"
+ )
+
+ manager.sync_bundles()
+
+ manager._log.exception.assert_called_once_with(
+ "Failed to reassign Dags with unconfigured bundles during startup"
+ )
+
+ @pytest.mark.parametrize(
+ "apply_patch",
+ [False, True],
+ ids=["without_patch", "with_patch"],
+ )
+ def test_sync_bundles_repairs_legacy_bundle_before_parsing_loop(
+ self, apply_patch, session, tmp_path, configure_dag_bundles
+ ):
+ """DFP initial setup alone re-homes a 2.x->3.x legacy Dag to its
configured bundle.
+
+ Window right after ``airflow db migrate`` but before the parsing loop:
the ``0082``
+ migration left the row with ``bundle_name='dags-folder'`` and NULL
``relative_fileloc``,
+ and ``0047`` emptied ``serialized_dag``/``dag_version`` (so the Dag is
unserialized and
+ ``get_bundle`` -- what the worker calls at run time -- is the probe,
not the REST trigger).
+ ``sync_bundles()`` runs ``sync_bundles_to_db`` and, on the patched
build,
+ ``reassign_dags_with_unconfigured_bundles``; without it (reassign
mocked off) the row stays
+ on the unconfigured ``dags-folder`` and ``get_bundle`` raises. See
+ https://github.com/apache/airflow/issues/63323.
+ """
+ dags_folder = tmp_path / "dags"
+ dags_folder.mkdir()
+ legacy_file = dags_folder / "af2_upgrade_af3_dag.py"
+ legacy_file.write_text("# 2.x dag")
+
+ # State right after the 0082 migration: the ``dags-folder`` row exists
in dag_bundle but the
+ # operator's bundle is not registered yet (sync_bundles_to_db does
that below), and
+ # serialized_dag/dag_version are empty (0047 wiped them; setup_method
clears them too).
+ legacy_default_bundle = DagBundleModel(name="dags-folder")
+ legacy_default_bundle.active = True
+ session.add(legacy_default_bundle)
+ session.flush()
+ legacy_dag = DagModel(
+ dag_id="legacy_dag",
+ bundle_name="dags-folder",
+ fileloc=str(legacy_file),
+ )
+ legacy_dag.relative_fileloc = None
+ session.add(legacy_dag)
+ session.commit()
+
+ bundle_name = "upgrade_test_dag_bundle"
+ with configure_dag_bundles({bundle_name: dags_folder}):
+ manager = DagFileProcessorManager(max_runs=1)
+ if apply_patch:
+ manager.sync_bundles()
+ else:
+ # Pre-patch sync_bundles ran sync_bundles_to_db only; mocking
reassign to a no-op
+ # reproduces that build without forking the source under test.
+ with mock.patch.object(
+ DagBundlesManager,
+ "reassign_dags_with_unconfigured_bundles",
+ return_value=0,
+ ):
+ manager.sync_bundles()
+
+ session.expire_all()
+ refreshed = session.get(DagModel, "legacy_dag")
+
+ if apply_patch:
+ assert refreshed.bundle_name == bundle_name
+ assert refreshed.relative_fileloc == "af2_upgrade_af3_dag.py"
+ # The worker can now resolve the bundle: the symptom is gone.
+ assert DagBundlesManager().get_bundle(bundle_name).name ==
bundle_name
+ else:
+ assert refreshed.bundle_name == "dags-folder"
+ assert refreshed.relative_fileloc is None
+ # The worker would hit the original incident at run time.
+ with pytest.raises(
+ ValueError,
+ match=re.escape("Requested bundle 'dags-folder' is not
configured."),
+ ):
+ DagBundlesManager().get_bundle(refreshed.bundle_name)
+
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."""
test_dag_path = str(TEST_DAG_FOLDER / "test_assets.py")