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

bbovenzi 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 48c6bb413e0 Warn in UI when the same dag_id is registered from 
multiple files (#68791)
48c6bb413e0 is described below

commit 48c6bb413e0aaf7a3ad54e9f5332c56ab13e8af0
Author: Takayoshi Makabe <[email protected]>
AuthorDate: Fri Jul 10 01:12:13 2026 +0900

    Warn in UI when the same dag_id is registered from multiple files (#68791)
    
    * Add duplicate dag id warning
    
    * Modify related files
    
    * Add dag_processing.duplicate_dag_id to metrics registry
    
    * Change word (skipped → overwritten)
    
    * Simplified the message
    
    * Delete metrics
    
    * Remove unnecessary import
    
    ---------
    
    Co-authored-by: Takayoshi Makabe 
<[email protected]>
---
 airflow-core/docs/faq.rst                          | 17 +++++
 airflow-core/newsfragments/68791.improvement.rst   |  1 +
 .../core_api/openapi/v2-rest-api-generated.yaml    |  1 +
 .../src/airflow/dag_processing/collection.py       | 61 ++++++++++++++--
 airflow-core/src/airflow/models/dagwarning.py      |  1 +
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  2 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  2 +-
 .../core_api/routes/public/test_dag_warning.py     |  2 +-
 .../tests/unit/dag_processing/test_collection.py   | 82 ++++++++++++++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  1 +
 10 files changed, 162 insertions(+), 8 deletions(-)

diff --git a/airflow-core/docs/faq.rst b/airflow-core/docs/faq.rst
index 31e327a1c5a..6860fc72f91 100644
--- a/airflow-core/docs/faq.rst
+++ b/airflow-core/docs/faq.rst
@@ -420,6 +420,23 @@ Additionally, you can catch these issues earlier in your 
development workflow by
 dynamic values in Dag and Task constructors as part of static linting. See
 :ref:`best_practices/code_quality_and_linting` for how to set up ruff with 
Airflow-specific rules.
 
+.. _faq:duplicate-dag-id-warning:
+
+Why do I see a "duplicate dag id" warning?
+------------------------------------------
+
+The Dag processor recognizes each Dag by its ``dag_id``, not by its file path. 
If two files define a
+Dag with the same ``dag_id``, only one of them is shown in the UI at a time — 
whichever file was parsed
+most recently — and Airflow records a **Dag warning** naming the other, 
overwritten file.
+
+This is expected in one common case: you renamed or moved a Dag file. The old 
file's row is not removed
+from the database until it goes unobserved for
+:ref:`stale_dag_threshold <config:dag_processor__stale_dag_threshold>`, so the 
warning briefly
+appears for both the old and new paths and then clears on its own once the old 
file stops being parsed.
+
+If the warning does not clear, two files with the same ``dag_id`` genuinely 
coexist. Which one "wins" is
+not guaranteed to stay stable across parses, so rename one of them to give 
each Dag a unique ``dag_id``.
+
 
 Dag construction
 ^^^^^^^^^^^^^^^^
diff --git a/airflow-core/newsfragments/68791.improvement.rst 
b/airflow-core/newsfragments/68791.improvement.rst
new file mode 100644
index 00000000000..2c98be3c8ac
--- /dev/null
+++ b/airflow-core/newsfragments/68791.improvement.rst
@@ -0,0 +1 @@
+Warn in the UI when the same dag_id is registered from multiple files.
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 8173fd5fe9c..d3a1d124943 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -14570,6 +14570,7 @@ components:
       type: string
       enum:
       - asset conflict
+      - duplicate dag id
       - non-existent pool
       - runtime varying value
       title: DagWarningType
diff --git a/airflow-core/src/airflow/dag_processing/collection.py 
b/airflow-core/src/airflow/dag_processing/collection.py
index 9ea66e60ed3..8fa5209ff15 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -31,7 +31,7 @@ import traceback
 from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
 
 import structlog
-from sqlalchemy import delete, func, insert, select, tuple_, update
+from sqlalchemy import delete, false, func, insert, select, tuple_, update
 from sqlalchemy.exc import OperationalError
 from sqlalchemy.orm import joinedload, load_only
 
@@ -51,7 +51,7 @@ from airflow.models.asset import (
 )
 from airflow.models.dag import DagModel, DagOwnerAttributes, DagTag
 from airflow.models.dagrun import DagRun
-from airflow.models.dagwarning import DagWarningType
+from airflow.models.dagwarning import DagWarning, DagWarningType
 from airflow.models.errors import ParseImportError
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.trigger import Trigger
@@ -75,7 +75,6 @@ if TYPE_CHECKING:
     from sqlalchemy.orm import Session
     from sqlalchemy.sql import Select
 
-    from airflow.models.dagwarning import DagWarning
     from airflow.models.serialized_dag import DagWriteMetadata
     from airflow.typing_compat import Self, Unpack
 
@@ -325,14 +324,58 @@ def _sync_dag_perms(dag: LazyDeserializedDAG, session: 
Session):
     security_manager.sync_perm_for_dag(dag_id, dag.access_control)
 
 
+def _build_duplicate_dag_id_warnings(
+    dags: Collection[LazyDeserializedDAG],
+    bundle_name: str,
+    session: Session,
+) -> set[DagWarning]:
+    """
+    Detect dag_ids that are defined in multiple files and return DagWarning 
objects for each.
+
+    A warning is emitted whenever the incoming file differs from the file 
already recorded in
+    DagModel. This covers both accidental duplicates and file renames while 
leaving the final
+    interpretation to the user.
+    """
+    dag_by_id = {dag.dag_id: dag for dag in dags}
+    if not dag_by_id:
+        return set()
+
+    existing_rows = session.execute(
+        select(DagModel.dag_id, DagModel.bundle_name, 
DagModel.relative_fileloc).where(
+            DagModel.dag_id.in_(dag_by_id),
+            DagModel.is_stale == false(),
+        )
+    )
+
+    warnings: set[DagWarning] = set()
+    for existing in existing_rows:
+        dag = dag_by_id[existing.dag_id]
+        if bundle_name == existing.bundle_name and dag.relative_fileloc == 
existing.relative_fileloc:
+            continue
+
+        message = (
+            f"Also registered from '{existing.relative_fileloc}' (bundle: 
'{existing.bundle_name}'), "
+            "which is now overwritten by this file. See the FAQ on duplicate 
dag_id warnings for details."
+        )
+        log.warning(
+            "Duplicate dag_id detected",
+            dag_id=dag.dag_id,
+            incoming_fileloc=dag.relative_fileloc,
+            incoming_bundle=bundle_name,
+            existing_fileloc=existing.relative_fileloc,
+            existing_bundle=existing.bundle_name,
+        )
+        warnings.add(DagWarning(dag.dag_id, DagWarningType.DUPLICATE_DAG_ID, 
message))
+
+    return warnings
+
+
 def _update_dag_warnings(
     dag_ids: list[str],
     warnings: set[DagWarning],
     warning_types: tuple[DagWarningType, ...],
     session: Session,
 ):
-    from airflow.models.dagwarning import DagWarning
-
     stored_warnings = set(
         session.scalars(
             select(DagWarning).where(
@@ -446,6 +489,7 @@ def update_dag_parsing_results_in_db(
     *,
     version_data: dict | None = None,
     warning_types: tuple[DagWarningType, ...] = (
+        DagWarningType.DUPLICATE_DAG_ID,
         DagWarningType.NONEXISTENT_POOL,
         DagWarningType.RUNTIME_VARYING_VALUE,
     ),
@@ -475,6 +519,13 @@ def update_dag_parsing_results_in_db(
     # Retry 'DAG.bulk_write_to_db' & 'SerializedDagModel.bulk_sync_to_db' in 
case
     # of any Operational Errors
     # In case of failures, provide_session handles rollback
+    try:
+        duplicate_warnings = _build_duplicate_dag_id_warnings(dags, 
bundle_name, session)
+    except Exception:
+        log.exception("Error building duplicate dag_id warnings.")
+    else:
+        warnings = set(warnings) | duplicate_warnings
+
     for attempt in run_with_db_retries(logger=log):
         with attempt:
             serialize_errors = []
diff --git a/airflow-core/src/airflow/models/dagwarning.py 
b/airflow-core/src/airflow/models/dagwarning.py
index d411a3b9386..b0a5a3c93e1 100644
--- a/airflow-core/src/airflow/models/dagwarning.py
+++ b/airflow-core/src/airflow/models/dagwarning.py
@@ -102,5 +102,6 @@ class DagWarningType(str, Enum):
     """
 
     ASSET_CONFLICT = "asset conflict"
+    DUPLICATE_DAG_ID = "duplicate dag id"
     NONEXISTENT_POOL = "non-existent pool"
     RUNTIME_VARYING_VALUE = "runtime varying value"
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 55b25b2c975..956f4f9c9ae 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -4572,7 +4572,7 @@ export const $DagVersionResponse = {
 
 export const $DagWarningType = {
     type: 'string',
-    enum: ['asset conflict', 'non-existent pool', 'runtime varying value'],
+    enum: ['asset conflict', 'duplicate dag id', 'non-existent pool', 'runtime 
varying value'],
     title: 'DagWarningType',
     description: `Enum for DAG warning types.
 
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index acec153fd1f..85c405ae215 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1216,7 +1216,7 @@ export type DagVersionResponse = {
  * This is the set of allowable values for the ``warning_type`` field
  * in the DagWarning model.
  */
-export type DagWarningType = 'asset conflict' | 'non-existent pool' | 'runtime 
varying value';
+export type DagWarningType = 'asset conflict' | 'duplicate dag id' | 
'non-existent pool' | 'runtime varying value';
 
 /**
  * Backfill collection serializer for responses in dry-run mode.
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
index 91efee48df7..ade87cccf60 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
@@ -117,5 +117,5 @@ class TestGetDagWarnings:
         assert response.status_code == 422
         assert (
             response_json["detail"][0]["msg"]
-            == "Input should be 'asset conflict', 'non-existent pool' or 
'runtime varying value'"
+            == "Input should be 'asset conflict', 'duplicate dag id', 
'non-existent pool' or 'runtime varying value'"
         )
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py 
b/airflow-core/tests/unit/dag_processing/test_collection.py
index ee8d7fa0fcd..ad21350be6b 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -51,6 +51,7 @@ from airflow.models.asset import (
 )
 from airflow.models.dag import DagTag
 from airflow.models.dagbundle import DagBundleModel
+from airflow.models.dagwarning import DagWarning, DagWarningType
 from airflow.models.errors import ParseImportError
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.trigger import Trigger
@@ -772,6 +773,87 @@ class TestUpdateDagParsingResults:
         new_serialized_dags_count = 
session.scalar(select(func.count(SerializedDagModel.dag_id)))
         assert new_serialized_dags_count == 1
 
+    @pytest.mark.usefixtures("clean_db")
+    def test_duplicate_dag_id_creates_dag_warning(self, testing_dag_bundle, 
session):
+        session.add(
+            DagModel(
+                dag_id="duplicated_dag",
+                bundle_name="testing",
+                fileloc="/opt/airflow/dags/existing.py",
+                relative_fileloc="existing.py",
+                is_stale=False,
+            )
+        )
+        session.flush()
+
+        dag = DAG(dag_id="duplicated_dag")
+        dag.fileloc = "/opt/airflow/dags/current.py"
+        dag.relative_fileloc = "current.py"
+
+        update_dag_parsing_results_in_db(
+            bundle_name="testing",
+            bundle_version=None,
+            dags=[LazyDeserializedDAG.from_dag(dag)],
+            import_errors={},
+            parse_duration=None,
+            warnings=set(),
+            session=session,
+        )
+
+        warning = session.scalar(
+            select(DagWarning).where(
+                DagWarning.dag_id == "duplicated_dag",
+                DagWarning.warning_type == DagWarningType.DUPLICATE_DAG_ID,
+            )
+        )
+
+        assert warning is not None
+        assert "existing.py" in warning.message
+        assert "overwritten" in warning.message
+
+    @pytest.mark.usefixtures("clean_db")
+    def test_duplicate_dag_id_warning_is_removed_when_dag_file_matches(self, 
testing_dag_bundle, session):
+        session.add(
+            DagModel(
+                dag_id="same_file_dag",
+                bundle_name="testing",
+                fileloc="/opt/airflow/dags/current.py",
+                relative_fileloc="current.py",
+                is_stale=False,
+            )
+        )
+        session.add(
+            DagWarning(
+                dag_id="same_file_dag",
+                warning_type=DagWarningType.DUPLICATE_DAG_ID,
+                message="Previous duplicate dag_id warning",
+            )
+        )
+        session.flush()
+
+        dag = DAG(dag_id="same_file_dag")
+        dag.fileloc = "/opt/airflow/dags/current.py"
+        dag.relative_fileloc = "current.py"
+
+        update_dag_parsing_results_in_db(
+            bundle_name="testing",
+            bundle_version=None,
+            dags=[LazyDeserializedDAG.from_dag(dag)],
+            import_errors={},
+            parse_duration=None,
+            warnings=set(),
+            session=session,
+        )
+
+        warning = session.scalar(
+            select(DagWarning).where(
+                DagWarning.dag_id == "same_file_dag",
+                DagWarning.warning_type == DagWarningType.DUPLICATE_DAG_ID,
+            )
+        )
+
+        assert warning is None
+
     def test_parse_time_written_to_db_on_sync(self, testing_dag_bundle, 
session):
         """Test that the parse time is correctly written to the DB after 
parsing"""
 
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 8ce734890ad..d4e39a8ad19 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -646,6 +646,7 @@ class DagWarningType(str, Enum):
     """
 
     ASSET_CONFLICT = "asset conflict"
+    DUPLICATE_DAG_ID = "duplicate dag id"
     NON_EXISTENT_POOL = "non-existent pool"
     RUNTIME_VARYING_VALUE = "runtime varying value"
 

Reply via email to