This is an automated email from the ASF dual-hosted git repository.
kaxil 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 254758d5093 Fix dag.test() to sync sibling DAGs across calls (#64884)
(#66205)
254758d5093 is described below
commit 254758d50930c171bcced3932dadd192dc122745
Author: Kaxil Naik <[email protected]>
AuthorDate: Sat May 2 11:39:17 2026 +0100
Fix dag.test() to sync sibling DAGs across calls (#64884) (#66205)
`DAG.test()` previously skipped its bundle-sync loop when the parent DAG
was already serialized in the metadata DB. Sibling DAGs that the parent
triggers via `TriggerDagRunOperator` were therefore never written, and
the in-process Execution API returned 404 for them.
The fix:
- Look up `DagModel.bundle_name` for `self.dag_id`. If found and the
bundle is still configured, re-walk only that one bundle so sibling
DAGs reach the DB without re-parsing every other configured bundle on
every call.
- If the recorded bundle is no longer configured (renamed / removed),
fall back to walking all configured bundles.
- Drop `_airflow_parsing_context_manager(dag_id=self.dag_id)` from the
re-walk: setting `_AIRFLOW_PARSING_CONTEXT_DAG_ID` to the parent's id
would cause dynamic-DAG files using `get_parsing_context().dag_id` to
early-return and omit the trigger target from the bag, defeating the
re-sync.
Tests cover:
- The original repro (parent already synced, target absent).
- Dynamic-DAG variant using `get_parsing_context()` early-return.
- Stale recorded bundle name no longer in current config.
- Only the owning bundle is parsed when the parent is already in DB.
closes: #64884
closes: https://github.com/apache/airflow/pull/64945
---
.../dags/test_dag_test_with_dynamic_trigger.py | 55 +++++++++
.../tests/unit/dags/test_dag_test_with_trigger.py | 51 ++++++++
airflow-core/tests/unit/models/test_dag.py | 132 +++++++++++++++++++++
task-sdk/src/airflow/sdk/definitions/dag.py | 70 +++++++----
4 files changed, 283 insertions(+), 25 deletions(-)
diff --git a/airflow-core/tests/unit/dags/test_dag_test_with_dynamic_trigger.py
b/airflow-core/tests/unit/dags/test_dag_test_with_dynamic_trigger.py
new file mode 100644
index 00000000000..79f074a4ed9
--- /dev/null
+++ b/airflow-core/tests/unit/dags/test_dag_test_with_dynamic_trigger.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Dynamic-DAG file using ``get_parsing_context()`` to early-return.
+
+Used by the regression test for the case where ``DAG.test()`` must not set
+``_AIRFLOW_PARSING_CONTEXT_DAG_ID`` while re-walking the owning bundle, or it
+would strip the trigger target out of the bag.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from airflow.providers.standard.operators.empty import EmptyOperator
+from airflow.providers.standard.operators.trigger_dagrun import
TriggerDagRunOperator
+from airflow.sdk import DAG, get_parsing_context
+
+DEFAULT_DATE = datetime(2024, 1, 1)
+
+PARENT_ID = "test_dag_test_dynamic_trigger_parent"
+TARGET_ID = "test_dag_test_dynamic_trigger_target"
+
+current = get_parsing_context().dag_id
+
+if current is None or current == PARENT_ID:
+ with DAG(
+ dag_id=PARENT_ID,
+ schedule=None,
+ start_date=DEFAULT_DATE,
+ is_paused_upon_creation=False,
+ ):
+ TriggerDagRunOperator(task_id="trigger_target",
trigger_dag_id=TARGET_ID)
+
+if current is None or current == TARGET_ID:
+ with DAG(
+ dag_id=TARGET_ID,
+ schedule=None,
+ start_date=DEFAULT_DATE,
+ is_paused_upon_creation=False,
+ ):
+ EmptyOperator(task_id="target_task")
diff --git a/airflow-core/tests/unit/dags/test_dag_test_with_trigger.py
b/airflow-core/tests/unit/dags/test_dag_test_with_trigger.py
new file mode 100644
index 00000000000..dc50324bf15
--- /dev/null
+++ b/airflow-core/tests/unit/dags/test_dag_test_with_trigger.py
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""DAGs used by ``test_dag_test_triggers_sibling_dag``.
+
+A parent DAG that triggers a sibling DAG defined in the same file. Used to
+exercise the path where ``DAG.test()`` must ensure the trigger target is
+present in the metadata DB even when the parent was serialized previously.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from airflow.providers.standard.operators.empty import EmptyOperator
+from airflow.providers.standard.operators.trigger_dagrun import
TriggerDagRunOperator
+from airflow.sdk import DAG
+
+DEFAULT_DATE = datetime(2024, 1, 1)
+
+with DAG(
+ dag_id="test_dag_test_trigger_parent",
+ schedule=None,
+ start_date=DEFAULT_DATE,
+ is_paused_upon_creation=False,
+):
+ TriggerDagRunOperator(
+ task_id="trigger_target",
+ trigger_dag_id="test_dag_test_trigger_target",
+ )
+
+with DAG(
+ dag_id="test_dag_test_trigger_target",
+ schedule=None,
+ start_date=DEFAULT_DATE,
+ is_paused_upon_creation=False,
+):
+ EmptyOperator(task_id="target_task")
diff --git a/airflow-core/tests/unit/models/test_dag.py
b/airflow-core/tests/unit/models/test_dag.py
index 4f437b44f51..b34ab12dd4a 100644
--- a/airflow-core/tests/unit/models/test_dag.py
+++ b/airflow-core/tests/unit/models/test_dag.py
@@ -222,6 +222,138 @@ class TestDag:
assert ser is not None
assert session.scalar(select(DagRun).where(DagRun.dag_id == dag_id))
is not None
+ @conf_vars({("core", "load_examples"): "false"})
+ def test_dag_test_syncs_sibling_for_trigger_dagrun(self, test_dags_bundle,
session):
+ """
+ Regression test for #64884.
+
+ ``DAG.test()`` must sync sibling DAGs in the bundle even when the
parent
+ DAG is already serialized in the metadata DB. Otherwise a parent that
+ uses ``TriggerDagRunOperator`` to fire a sibling fails with a 404 from
+ the in-process Execution API because the sibling was never written.
+ """
+ parent_id = "test_dag_test_trigger_parent"
+ target_id = "test_dag_test_trigger_target"
+
+ dagbag = DagBag(dag_folder=os.fspath(TEST_DAGS_FOLDER),
include_examples=False)
+ parent = dagbag.dags.get(parent_id)
+ assert parent is not None
+
+ # Pre-sync ONLY the parent: simulates the state where the parent was
+ # written previously (e.g. by an earlier dag.test() invocation or by
+ # the DAG processor) but the trigger target has never been synced.
+ sync_dag_to_db(parent, bundle_name="testing")
+ session.commit()
+
+ assert DBDagBag().get_latest_version_of_dag(parent_id,
session=session) is not None
+ assert DBDagBag().get_latest_version_of_dag(target_id,
session=session) is None
+
+ dr = parent.test()
+
+ assert dr.state == DagRunState.SUCCESS
+ assert DBDagBag().get_latest_version_of_dag(target_id,
session=session) is not None
+ assert session.scalar(select(DagRun).where(DagRun.dag_id ==
target_id)) is not None
+
+ @conf_vars({("core", "load_examples"): "false"})
+ def test_dag_test_syncs_sibling_for_dynamic_trigger_dagrun(self,
test_dags_bundle, session):
+ """
+ Regression test for the dynamic-DAG variant of #64884.
+
+ The bundle re-walk in ``DAG.test()`` must not run under
+ ``_airflow_parsing_context_manager(dag_id=self.dag_id)``: DAG files
that
+ use ``get_parsing_context().dag_id`` to early-return would otherwise
+ emit only the parent and the trigger target would be omitted from the
+ bag, leaving it missing from the metadata DB.
+ """
+ parent_id = "test_dag_test_dynamic_trigger_parent"
+ target_id = "test_dag_test_dynamic_trigger_target"
+
+ dagbag = DagBag(dag_folder=os.fspath(TEST_DAGS_FOLDER),
include_examples=False)
+ parent = dagbag.dags.get(parent_id)
+ assert parent is not None
+
+ sync_dag_to_db(parent, bundle_name="testing")
+ session.commit()
+
+ assert DBDagBag().get_latest_version_of_dag(target_id,
session=session) is None
+
+ dr = parent.test()
+
+ assert dr.state == DagRunState.SUCCESS
+ assert DBDagBag().get_latest_version_of_dag(target_id,
session=session) is not None
+ assert session.scalar(select(DagRun).where(DagRun.dag_id ==
target_id)) is not None
+
+ @conf_vars({("core", "load_examples"): "false"})
+ def test_dag_test_falls_back_when_recorded_bundle_no_longer_configured(
+ self, configure_dag_bundles, session
+ ):
+ """
+ If a parent DAG's recorded ``DagModel.bundle_name`` no longer appears
in
+ the current bundle config (renamed/removed), ``DAG.test()`` must walk
+ all configured bundles instead of silently skipping the sync.
+ """
+ parent_id = "test_dag_test_trigger_parent"
+ target_id = "test_dag_test_trigger_target"
+
+ dagbag = DagBag(dag_folder=os.fspath(TEST_DAGS_FOLDER),
include_examples=False)
+ parent = dagbag.dags.get(parent_id)
+ assert parent is not None
+
+ # Record the parent under a bundle name that we will NOT include in the
+ # configured bundle list below.
+ sync_dag_to_db(parent, bundle_name="ghost-bundle")
+ session.merge(DagBundleModel(name="ghost-bundle"))
+ session.commit()
+
+ with configure_dag_bundles({"current": TEST_DAGS_FOLDER}):
+ dr = parent.test()
+
+ assert dr.state == DagRunState.SUCCESS
+ assert DBDagBag().get_latest_version_of_dag(target_id,
session=session) is not None
+ assert session.scalar(select(DagRun).where(DagRun.dag_id ==
target_id)) is not None
+
+ @conf_vars({("core", "load_examples"): "false"})
+ def test_dag_test_only_syncs_owning_bundle_when_parent_already_serialized(
+ self, configure_dag_bundles, session
+ ):
+ """
+ When the parent DAG is already in the DB, ``DAG.test()`` should only
+ re-walk the bundle that owns it, not every configured bundle. Confirms
+ that the perf-sensitive optimization in the fix is in effect.
+ """
+ parent_id = "test_dag_test_trigger_parent"
+
+ dagbag = DagBag(dag_folder=os.fspath(TEST_DAGS_FOLDER),
include_examples=False)
+ parent = dagbag.dags.get(parent_id)
+ assert parent is not None
+
+ sync_dag_to_db(parent, bundle_name="testing")
+ # The owning bundle row needs to exist before dag.test() commits its
+ # bundle-sync; ``configure_dag_bundles`` only writes the unrelated
bundle.
+ session.merge(DagBundleModel(name="testing"))
+ session.commit()
+
+ with configure_dag_bundles(
+ {
+ "testing": TEST_DAGS_FOLDER,
+ "unrelated": TEST_DAGS_FOLDER,
+ }
+ ):
+ instantiated: list[str] = []
+ real_init = BundleDagBag.__init__
+
+ def _spy(self, *args, **kwargs):
+ instantiated.append(kwargs.get("bundle_name", ""))
+ real_init(self, *args, **kwargs)
+
+ with mock.patch.object(BundleDagBag, "__init__", _spy):
+ dr = parent.test()
+
+ assert dr.state == DagRunState.SUCCESS
+ # Only the owning bundle should have been parsed.
+ assert "testing" in instantiated
+ assert "unrelated" not in instantiated
+
def teardown_method(self) -> None:
clear_db_runs()
clear_db_dags()
diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py
b/task-sdk/src/airflow/sdk/definitions/dag.py
index ab85396335e..18200bd6714 100644
--- a/task-sdk/src/airflow/sdk/definitions/dag.py
+++ b/task-sdk/src/airflow/sdk/definitions/dag.py
@@ -1289,34 +1289,54 @@ class DAG:
else:
timetable = coerce_to_core_timetable(self.timetable)
data_interval =
timetable.infer_manual_data_interval(run_after=logical_date)
+ # These imports are intentionally lazy: this Task SDK module must
not
+ # pull in airflow-core at import time (worker isolation).
+ from airflow.dag_processing.bundles.manager import
DagBundlesManager
+ from airflow.dag_processing.dagbag import BundleDagBag,
sync_bag_to_db
+ from airflow.models.dag import DagModel
from airflow.models.dag_version import DagVersion
- version = DagVersion.get_version(self.dag_id)
- if not version:
- from airflow.dag_processing.bundles.manager import
DagBundlesManager
- from airflow.dag_processing.dagbag import BundleDagBag,
sync_bag_to_db
- from airflow.sdk.definitions._internal.dag_parsing_context
import (
- _airflow_parsing_context_manager,
+ manager = DagBundlesManager()
+ manager.sync_bundles_to_db(session=session)
+ session.commit()
+
+ # Re-sync the bundle that owns ``self`` so sibling DAGs (e.g.
targets of
+ # TriggerDagRunOperator) are written to the metadata DB on every
call,
+ # not just the first one (apache/airflow#64884). When we can
identify
+ # ``self``'s bundle from a prior sync we walk only that bundle;
otherwise
+ # we walk every configured bundle until we find it.
``sync_bag_to_db``
+ # is idempotent at the per-DAG hash level.
+ #
+ # Note: we deliberately do NOT use
``_airflow_parsing_context_manager``
+ # here. Setting ``_AIRFLOW_PARSING_CONTEXT_DAG_ID`` to
``self.dag_id``
+ # would cause DAG files that follow the documented dynamic-DAG
+ # optimization (early-return when the parsing context dag_id
doesn't
+ # match) to omit sibling DAGs from the bag, defeating the whole
point
+ # of this re-sync.
+ all_bundles = list(manager.get_all_dag_bundles())
+ existing_dm = DagModel.get_current(self.dag_id, session=session)
+ existing_bundle_name = existing_dm.bundle_name if existing_dm else
None
+ owning = (
+ [b for b in all_bundles if b.name == existing_bundle_name]
+ if existing_bundle_name is not None
+ else []
+ )
+ # If the recorded bundle is no longer in the configured bundle list
+ # (renamed / removed), fall back to walking all bundles so the sync
+ # doesn't silently skip.
+ bundles_to_sync = owning if owning else all_bundles
+
+ for bundle in bundles_to_sync:
+ if not bundle.is_initialized:
+ bundle.initialize()
+ dagbag = BundleDagBag(
+ dag_folder=bundle.path,
+ bundle_path=bundle.path,
+ bundle_name=bundle.name,
)
-
- manager = DagBundlesManager()
- manager.sync_bundles_to_db(session=session)
- session.commit()
- # sync all bundles? or use the dags-folder bundle?
- # What if the test dag is in a different bundle?
- for bundle in manager.get_all_dag_bundles():
- if not bundle.is_initialized:
- bundle.initialize()
- with _airflow_parsing_context_manager(dag_id=self.dag_id):
- dagbag = BundleDagBag(
- dag_folder=bundle.path,
- bundle_path=bundle.path,
- bundle_name=bundle.name,
- )
- sync_bag_to_db(dagbag, bundle.name, bundle.version)
- version = DagVersion.get_version(self.dag_id)
- if version:
- break
+ sync_bag_to_db(dagbag, bundle.name, bundle.version)
+ if DagVersion.get_version(self.dag_id):
+ break
# Preserve callback functions from original Dag since they're lost
during serialization
# and yes it is a hack for now! It is a tradeoff for code
simplicity.