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 7e176c79bc7 Fix Dag bundle sync losing updates when a bundle reads a 
connection (#76188)
7e176c79bc7 is described below

commit 7e176c79bc7038564642b741d82139125a9d4666
Author: PoAn Yang <[email protected]>
AuthorDate: Mon Nov 9 17:16:12 2026 +0800

    Fix Dag bundle sync losing updates when a bundle reads a connection (#76188)
    
    * Fix Dag bundle sync losing updates when a bundle reads a connection
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Update function comment
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../src/airflow/dag_processing/bundles/manager.py  |  26 ++-
 .../bundles/test_dag_bundle_manager.py             | 213 ++++++++++++++++++++-
 2 files changed, 231 insertions(+), 8 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/bundles/manager.py 
b/airflow-core/src/airflow/dag_processing/bundles/manager.py
index 4086e8985a2..c32e289ef7c 100644
--- a/airflow-core/src/airflow/dag_processing/bundles/manager.py
+++ b/airflow-core/src/airflow/dag_processing/bundles/manager.py
@@ -382,7 +382,7 @@ class DagBundlesManager(LoggingMixin):
         self.log.debug("Syncing DAG bundles to the database")
 
         def _extract_and_sign_template(bundle_name: str) -> tuple[str | None, 
dict]:
-            bundle_instance = self.get_bundle(name)
+            bundle_instance = self.get_bundle(bundle_name)
             new_template_ = bundle_instance.view_url_template()
             new_params_ = self._extract_template_params(bundle_instance)
             if new_template_:
@@ -399,6 +399,16 @@ class DagBundlesManager(LoggingMixin):
                     self.log.debug("Signed URL template for bundle %s", 
bundle_name)
             return new_template_, new_params_
 
+        # Bundle initialization and template generation can close the shared 
session,
+        # detaching loaded rows. Complete them before loading rows to update.
+        templates: dict[str, tuple[str | None, dict | None]] = {}
+        for name in self._bundle_config:
+            try:
+                with self._bundle_context(name):
+                    templates[name] = _extract_and_sign_template(name)
+            except Exception as e:
+                self.log.exception("Error creating bundle '%s': %s", name, e)
+
         stored = {b.name: b for b in 
session.scalars(select(DagBundleModel)).all()}
         bundle_to_team = {
             bundle.name: bundle.teams[0].name if len(bundle.teams) == 1 else 
None
@@ -412,14 +422,16 @@ class DagBundlesManager(LoggingMixin):
                 if not team:
                     raise _bundle_item_exc(f"Team '{config.team_name}' does 
not exist")
 
-            try:
-                with self._bundle_context(name):
-                    new_template, new_params = _extract_and_sign_template(name)
-            except Exception as e:
-                self.log.exception("Error creating bundle '%s': %s", name, e)
+            bundle = stored.pop(name, None)
+            if name in templates:
+                new_template, new_params = templates[name]
+            elif bundle:
+                # Template failure does not mean the bundle was removed from 
configuration.
+                new_template, new_params = bundle.signed_url_template, 
bundle.template_params
+            else:
                 continue
 
-            if bundle := stored.pop(name, None):
+            if bundle:
                 bundle.active = True
                 if new_template != bundle.signed_url_template:
                     bundle.signed_url_template = new_template
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 6accd346553..ae9d7226aac 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
@@ -37,14 +37,22 @@ from airflow.dag_processing.bundles.manager import (
     get_configured_bundle_team_names,
 )
 from airflow.exceptions import AirflowConfigException
+from airflow.models.connection import Connection
 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.models.team import Team
 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, clear_db_dags
+from tests_common.test_utils.db import (
+    clear_db_connections,
+    clear_db_dag_bundles,
+    clear_db_dags,
+    clear_db_import_errors,
+    clear_db_teams,
+)
 
 
 @pytest.mark.parametrize(
@@ -657,6 +665,209 @@ def test_multiple_bundles_one_fails(clear_db, session):
         assert bundle_names == {"my-test-bundle"}
 
 
+class BundleReadingConnection(BasicBundle):
+    """Read a connection during construction or URL-template generation."""
+
+    def __init__(self, *, conn_id: str, subdir: str, read_in_constructor: bool 
= False, **kwargs):
+        super().__init__(**kwargs)
+        self.conn_id = conn_id
+        self.subdir = subdir
+        self.host = self._read_host() if read_in_constructor else None
+
+    def _read_host(self) -> str | None:
+        return Connection.get_connection_from_secrets(self.conn_id).host
+
+    def view_url_template(self) -> str | None:
+        return f"https://{self.host or 
self._read_host()}/tree/{{version}}/{{subdir}}"
+
+
+CONNECTION_BUNDLE_CONFIG = {
+    "name": "connection-bundle",
+    "classpath": 
"unit.dag_processing.bundles.test_dag_bundle_manager.BundleReadingConnection",
+    "kwargs": {"conn_id": "bundle_conn", "subdir": "connection_dags", 
"refresh_interval": 1},
+}
+
+
+def _clear_db_for_connection_bundles():
+    clear_db_dag_bundles()
+    clear_db_teams()
+    clear_db_connections()
+    clear_db_import_errors()
+
+
[email protected]
+def clean_db_with_connection():
+    _clear_db_for_connection_bundles()
+    with create_session() as session:
+        session.add(Connection(conn_id="bundle_conn", conn_type="generic", 
host="bundles.example.com"))
+    yield
+    _clear_db_for_connection_bundles()
+
+
[email protected]_test
[email protected](
+    "read_in_constructor", [True, False], ids=["read_in_constructor", 
"read_in_template"]
+)
+def test_sync_bundles_to_db_updates_stored_bundles_when_one_reads_connection(
+    clean_db_with_connection, session, read_in_constructor
+):
+    for name in ("connection-bundle", "template-bundle"):
+        stored = DagBundleModel(name=name)
+        stored.active = False
+        session.add(stored)
+    session.commit()
+
+    connection_config = {
+        **CONNECTION_BUNDLE_CONFIG,
+        "kwargs": {**CONNECTION_BUNDLE_CONFIG["kwargs"], 
"read_in_constructor": read_in_constructor},
+    }
+    with conf_vars(
+        {
+            ("core", "LOAD_EXAMPLES"): "False",
+            ("dag_processor", "dag_bundle_config_list"): json.dumps(
+                [connection_config, *TEMPLATE_BUNDLE_CONFIG]
+            ),
+        }
+    ):
+        DagBundlesManager().sync_bundles_to_db()
+
+    session.expire_all()
+    bundle = session.get(DagBundleModel, "connection-bundle")
+    assert bundle.active is True
+    assert bundle._unsign_url() == 
"https://bundles.example.com/tree/{version}/{subdir}";
+    assert bundle.template_params == {"subdir": "connection_dags"}
+    bundle = session.get(DagBundleModel, "template-bundle")
+    assert bundle.active is True
+    assert bundle._unsign_url() == 
"https://github.com/example/repo/tree/{version}/{subdir}";
+    assert bundle.template_params == {"subdir": "dags"}
+
+
[email protected]_test
[email protected](
+    ("stored_team", "configured_team"),
+    [
+        pytest.param(None, "team-a", id="assign_team"),
+        pytest.param("team-a", "team-b", id="change_team"),
+        pytest.param("team-a", None, id="remove_team"),
+    ],
+)
+def 
test_sync_bundles_to_db_updates_team_of_stored_bundle_that_reads_connection(
+    clean_db_with_connection, session, stored_team, configured_team
+):
+    teams = {name: Team(name=name) for name in ("team-a", "team-b")}
+    stored = DagBundleModel(name="connection-bundle")
+    if stored_team:
+        stored.teams = [teams[stored_team]]
+    session.add_all([*teams.values(), stored])
+    session.commit()
+
+    config = {**CONNECTION_BUNDLE_CONFIG, "team_name": configured_team}
+    with conf_vars(
+        {
+            ("core", "LOAD_EXAMPLES"): "False",
+            ("core", "multi_team"): "True",
+            ("dag_processor", "dag_bundle_config_list"): json.dumps([config]),
+        }
+    ):
+        DagBundlesManager().sync_bundles_to_db()
+
+    session.expire_all()
+    assert session.get(DagBundleModel, "connection-bundle").team_name == 
configured_team
+
+
[email protected]_test
+@conf_vars(
+    {
+        ("core", "LOAD_EXAMPLES"): "False",
+        ("dag_processor", "dag_bundle_config_list"): 
json.dumps([CONNECTION_BUNDLE_CONFIG]),
+    }
+)
+def 
test_sync_bundles_to_db_disables_bundle_left_out_of_config_when_another_reads_connection(
+    clean_db_with_connection, session
+):
+    removed = DagBundleModel(name="removed-bundle")
+    removed.teams = [Team(name="team-a")]
+    session.add_all(
+        [removed, ParseImportError(bundle_name="removed-bundle", 
filename="dag.py", stacktrace="error")]
+    )
+    session.commit()
+
+    DagBundlesManager().sync_bundles_to_db()
+
+    session.expire_all()
+    bundle = session.get(DagBundleModel, "removed-bundle")
+    assert bundle.active is False
+    assert bundle.teams == []
+    assert session.scalar(select(func.count(ParseImportError.id))) == 0
+
+
[email protected]_test
[email protected](
+    "failing_config",
+    [
+        pytest.param(FAILING_BUNDLE_CONFIG[0], 
id="fails_before_reading_the_db"),
+        pytest.param(
+            {
+                **CONNECTION_BUNDLE_CONFIG,
+                "name": "failing-bundle",
+                "kwargs": {"conn_id": "missing_conn", "subdir": "dags", 
"refresh_interval": 1},
+            },
+            id="fails_after_reading_the_db",
+        ),
+    ],
+)
[email protected](
+    ("stored_active", "stored_team", "configured_team"),
+    [
+        pytest.param(True, "team-a", "team-b", id="active_row_changes_team"),
+        pytest.param(False, None, None, id="inactive_row_without_team"),
+    ],
+)
+def test_sync_bundles_to_db_keeps_configured_bundle_active_when_template_fails(
+    clean_db_with_connection, session, failing_config, stored_active, 
stored_team, configured_team
+):
+    teams = {name: Team(name=name) for name in ("team-a", "team-b")}
+    failing = DagBundleModel(name="failing-bundle", version="v1")
+    failing.active = stored_active
+    failing.signed_url_template = "stored-url"
+    failing.template_params = {"subdir": "stored"}
+    if stored_team:
+        failing.teams = [teams[stored_team]]
+    other = DagBundleModel(name="template-bundle")
+    other.active = False
+    session.add_all(
+        [
+            *teams.values(),
+            failing,
+            other,
+            ParseImportError(bundle_name="failing-bundle", filename="dag.py", 
stacktrace="error"),
+        ]
+    )
+    session.commit()
+
+    config = {**failing_config, "team_name": configured_team}
+    with conf_vars(
+        {
+            ("core", "LOAD_EXAMPLES"): "False",
+            ("core", "multi_team"): "True",
+            ("dag_processor", "dag_bundle_config_list"): json.dumps([config, 
*TEMPLATE_BUNDLE_CONFIG]),
+        }
+    ):
+        DagBundlesManager().sync_bundles_to_db()
+
+    session.expire_all()
+    bundle = session.get(DagBundleModel, "failing-bundle")
+    assert bundle.active is True
+    assert bundle.team_name == configured_team
+    assert bundle.signed_url_template == "stored-url"
+    assert bundle.template_params == {"subdir": "stored"}
+    assert bundle.version == "v1"
+    assert session.scalar(select(func.count(ParseImportError.id))) == 1
+    bundle = session.get(DagBundleModel, "template-bundle")
+    assert bundle.active is True
+    assert bundle._unsign_url() == 
"https://github.com/example/repo/tree/{version}/{subdir}";
+
+
 def test_get_all_bundle_names():
     bundle_names = DagBundlesManager().get_all_bundle_names()
     # Built-in bundles are always present.

Reply via email to