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 fd0ec61c503 Consolidate UI Dependency Graphs (#69833)
fd0ec61c503 is described below
commit fd0ec61c503cbebc900dd3e053b7e035cd859f30
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Jul 29 11:19:04 2026 -0400
Consolidate UI Dependency Graphs (#69833)
* Consolidate dag dep graphs
* fix prettier
* Batch entry-point resolution for scheduled Dags in the data-dependencies
graph
Resolving each scheduled Dag's entry task previously deserialized the full
Dag once per Dag discovered while tracing an asset's dependencies -- for an
asset feeding many Dags, that's N full Dag deserializations plus N round
trips. Restructures the BFS to process a whole frontier of assets per round
and batch-resolve every scheduled Dag's entry point in a single query.
* Address review feedback on consolidated dependency graphs
Fix nested boolean asset conditions dropping sibling branches so
(a & b) | (c & d) renders both gates; read a scheduled Dag's entry task
from serialized JSON instead of deserializing every Dag while tracing an
asset's lineage; and recover alias-produced assets' source task from
AssetEvent so they still show a producer. Also harden a malformed
expression against a 500 and link a Dag to every triggering asset in the
degraded fallback.
* UI: Prune orphaned per-Dag dependency toggle keys on startup
The dependency-view toggle moved from a per-Dag localStorage key to a
single global one, leaving the old `dependencies-<dag_id>` entries stranded
in every user's browser storage. Clean them up once at load.
---
.../api_fastapi/core_api/datamodels/ui/common.py | 1 +
.../core_api/datamodels/ui/structure.py | 2 -
.../api_fastapi/core_api/openapi/_private_ui.yaml | 42 +-
.../api_fastapi/core_api/routes/ui/dependencies.py | 2 +-
.../api_fastapi/core_api/routes/ui/structure.py | 91 +---
.../core_api/services/ui/dependencies.py | 486 +++++++++++++++++---
.../api_fastapi/core_api/services/ui/structure.py | 185 --------
.../src/airflow/ui/openapi-gen/queries/common.ts | 5 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 6 +-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 6 +-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 6 +-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 6 +-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 47 +-
.../ui/openapi-gen/requests/services.gen.ts | 3 -
.../airflow/ui/openapi-gen/requests/types.gen.ts | 9 +-
.../src/airflow/ui/public/i18n/locales/en/dag.json | 8 +-
.../src/airflow/ui/src/components/Graph/Edge.tsx | 7 +-
.../ui/src/components/Graph/reactflowUtils.test.ts | 118 +++++
.../ui/src/components/Graph/reactflowUtils.ts | 105 +++++
.../airflow/ui/src/constants/localStorage.test.ts | 51 +++
.../src/airflow/ui/src/constants/localStorage.ts | 19 +-
.../src/airflow/ui/src/constants/searchParams.ts | 1 -
.../ui/src/context/groups/GroupsProvider.tsx | 4 +-
.../airflow/ui/src/layouts/Details/Graph/Graph.tsx | 34 +-
.../ui/src/layouts/Details/PanelButtons.tsx | 65 +--
airflow-core/src/airflow/ui/src/main.tsx | 3 +
.../src/airflow/ui/src/pages/Asset/AssetGraph.tsx | 15 +-
.../core_api/routes/ui/test_dependencies.py | 364 ++++++++++++++-
.../core_api/routes/ui/test_structure.py | 490 +--------------------
29 files changed, 1179 insertions(+), 1002 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
index 702f6997cdf..0cccdecc4d3 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
@@ -55,6 +55,7 @@ class BaseNodeResponse(BaseModel):
"trigger",
]
team: str | None = None
+ asset_condition_type: Literal["or-gate", "and-gate"] | None = None
E = TypeVar("E", bound=BaseEdgeResponse)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
index 5d89b08ee98..36f0553d634 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
@@ -30,7 +30,6 @@ class EdgeResponse(BaseEdgeResponse):
is_setup_teardown: bool | None = None
label: str | None = None
- is_source_asset: bool | None = None
class NodeResponse(BaseNodeResponse):
@@ -41,7 +40,6 @@ class NodeResponse(BaseNodeResponse):
tooltip: str | None = None
setup_teardown_type: Literal["setup", "teardown"] | None = None
operator: str | None = None
- asset_condition_type: Literal["or-gate", "and-gate"] | None = None
ui_color: str | None = None
ui_fgcolor: str | None = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 317eabd56ee..2237e622d75 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -1069,13 +1069,6 @@ paths:
- type: string
- type: 'null'
title: Root
- - name: external_dependencies
- in: query
- required: false
- schema:
- type: boolean
- default: false
- title: External Dependencies
- name: version_number
in: query
required: false
@@ -1091,12 +1084,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/StructureDataResponse'
- '400':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/HTTPExceptionResponse'
- description: Bad Request
'404':
content:
application/json:
@@ -2201,6 +2188,14 @@ components:
- type: string
- type: 'null'
title: Team
+ asset_condition_type:
+ anyOf:
+ - type: string
+ enum:
+ - or-gate
+ - and-gate
+ - type: 'null'
+ title: Asset Condition Type
type: object
required:
- id
@@ -3052,11 +3047,6 @@ components:
- type: string
- type: 'null'
title: Label
- is_source_asset:
- anyOf:
- - type: boolean
- - type: 'null'
- title: Is Source Asset
type: object
required:
- source_id
@@ -3705,6 +3695,14 @@ components:
- type: string
- type: 'null'
title: Team
+ asset_condition_type:
+ anyOf:
+ - type: string
+ enum:
+ - or-gate
+ - and-gate
+ - type: 'null'
+ title: Asset Condition Type
children:
anyOf:
- items:
@@ -3735,14 +3733,6 @@ components:
- type: string
- type: 'null'
title: Operator
- asset_condition_type:
- anyOf:
- - type: string
- enum:
- - or-gate
- - and-gate
- - type: 'null'
- title: Asset Condition Type
ui_color:
anyOf:
- type: string
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dependencies.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dependencies.py
index d09b8acf0e4..0286b0a126f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dependencies.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dependencies.py
@@ -69,7 +69,7 @@ def get_dependencies(
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Asset with id
{asset_id} was not found")
return BaseGraphResponse(**data)
- data = get_scheduling_dependencies(readable_dags_filter.value)
+ data = get_scheduling_dependencies(readable_dags_filter.value, session)
if node_id is not None:
try:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
index a5cb9d1d8d1..876b64dac19 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
@@ -26,13 +26,8 @@ from airflow.api_fastapi.common.parameters import
QueryIncludeDownstream, QueryI
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.datamodels.ui.structure import
StructureDataResponse
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
-from airflow.api_fastapi.core_api.security import ReadableDagsFilterDep,
requires_access_dag
-from airflow.api_fastapi.core_api.services.ui.structure import (
- bind_output_assets_to_tasks,
- get_upstream_assets,
-)
+from airflow.api_fastapi.core_api.security import requires_access_dag
from airflow.api_fastapi.core_api.services.ui.task_group import
task_group_to_dict
-from airflow.models.dag import DagModel
from airflow.models.dag_version import DagVersion
from airflow.models.serialized_dag import SerializedDagModel
from airflow.utils.dag_edges import dag_edges
@@ -44,25 +39,21 @@ structure_router = AirflowRouter(tags=["Structure"],
prefix="/structure")
"/structure_data",
responses=create_openapi_http_exception_doc(
[
- status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
]
),
dependencies=[
Depends(requires_access_dag("GET")),
- Depends(requires_access_dag("GET", DagAccessEntity.DEPENDENCIES)),
Depends(requires_access_dag("GET", DagAccessEntity.TASK_INSTANCE)),
],
)
def structure_data(
session: SessionDep,
dag_id: str,
- readable_dags_filter: ReadableDagsFilterDep,
include_upstream: QueryIncludeUpstream = False,
include_downstream: QueryIncludeDownstream = False,
depth: int | None = None,
root: str | None = None,
- external_dependencies: bool = False,
version_number: int | None = None,
) -> StructureDataResponse:
"""Get Structure Data."""
@@ -79,7 +70,7 @@ def structure_data(
select(SerializedDagModel)
.join(DagVersion)
.where(SerializedDagModel.dag_id == dag_id, DagVersion.version_number
== version_number)
-
.options(joinedload(SerializedDagModel.dag_model).joinedload(DagModel.task_outlet_asset_references)),
+ .options(joinedload(SerializedDagModel.dag_model)),
)
if serialized_dag is None:
raise HTTPException(
@@ -108,82 +99,4 @@ def structure_data(
"edges": edges,
}
- if external_dependencies:
- entry_node_ref = nodes[0] if nodes else None
- exit_node_ref = nodes[-1] if nodes else None
-
- start_edges: list[dict] = []
- end_edges: list[dict] = []
-
- readable_dag_ids = readable_dags_filter.value
- for dependency_dag_id, dependencies in
sorted(SerializedDagModel.get_dag_dependencies().items()):
- if readable_dag_ids is not None and dependency_dag_id not in
readable_dag_ids:
- continue
- for dependency in dependencies:
- # Dependencies not related to `dag_id` are ignored
- if dependency_dag_id != dag_id and dependency.target != dag_id:
- continue
- # When target is a real Dag ID (not a type label), hide it
- # if the caller cannot read that Dag.
- if (
- readable_dag_ids is not None
- and dependency.target != dependency.dependency_type
- and dependency.target not in readable_dag_ids
- ):
- continue
-
- # upstream assets are handled by the `get_upstream_assets`
function.
- if dependency.target != dependency.dependency_type and
dependency.dependency_type in [
- "asset-alias",
- "asset",
- ]:
- continue
-
- # Add edges
- # start dependency
- if (
- dependency.source == dependency.dependency_type or
dependency.target == dag_id
- ) and entry_node_ref:
- start_edges.append({"source_id": dependency.node_id,
"target_id": entry_node_ref["id"]})
-
- # end dependency
- elif (
- dependency.target == dependency.dependency_type or
dependency.source == dag_id
- ) and exit_node_ref:
- end_edges.append(
- {
- "source_id": exit_node_ref["id"],
- "target_id": dependency.node_id,
- "resolved_from_alias":
dependency.source.replace("asset-alias:", "", 1)
- if dependency.source.startswith("asset-alias:")
- else None,
- }
- )
-
- # Add nodes
- nodes.append(
- {
- "id": dependency.node_id,
- "label": dependency.label,
- "type": dependency.dependency_type,
- }
- )
-
- if (asset_expression := serialized_dag.dag_model.asset_expression) and
entry_node_ref:
- try:
- upstream_asset_nodes, upstream_asset_edges =
get_upstream_assets(
- asset_expression, entry_node_ref["id"]
- )
- except TypeError as e:
- raise HTTPException(
- status.HTTP_400_BAD_REQUEST,
- f"Malformed asset_expression in Dag {dag_id!r} version
{version_number}: {e}",
- ) from e
- data["nodes"] += upstream_asset_nodes
- data["edges"] += upstream_asset_edges
-
- data["edges"] += start_edges + end_edges
-
- bind_output_assets_to_tasks(data["edges"], serialized_dag, version_number,
session)
-
return StructureDataResponse(**data)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
index 20ffc78008a..b519a61def3 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
@@ -17,15 +17,119 @@
from __future__ import annotations
-from collections import defaultdict, deque
+from collections import defaultdict
from typing import TYPE_CHECKING
+import structlog
+from sqlalchemy import select
+
from airflow.models.asset import AssetModel
from airflow.models.dag import DagModel
if TYPE_CHECKING:
from sqlalchemy.orm import Session
+log = structlog.get_logger(logger_name=__name__)
+
+ASSET_UPSTREAM_DEPENDENCY_TYPES = ("asset", "asset-alias", "asset-name-ref",
"asset-uri-ref")
+
+
+def _asset_node_id_and_label(asset: dict) -> tuple[str, str]:
+ """Get the graph node id and label for a single asset/alias/ref entry in
an asset_expression."""
+ asset_type = asset["type"]
+
+ if asset_type == "asset":
+ return f"asset:{asset['id']}", asset["name"]
+ if asset_type in ("asset-alias", "asset-name-ref"):
+ return f"{asset_type}:{asset['name']}", asset["name"]
+ if asset_type == "asset-uri-ref":
+ return f"{asset_type}:{asset['uri']}", asset["uri"]
+ raise TypeError(f"Unsupported type: {asset_type}")
+
+
+def get_upstream_assets(
+ asset_expression: dict, entry_node_ref: str, level: int = 0
+) -> tuple[list[dict], list[dict]]:
+ """Expand a Dag's asset trigger condition into asset-condition (AND/OR
gate) nodes and edges."""
+ edges: list[dict] = []
+ nodes: list[dict] = []
+
+ asset_expression_type: str | None = None
+ expr_key = ""
+ if asset_expression.keys() == {"any"}:
+ asset_expression_type = "or-gate"
+ expr_key = "any"
+ elif asset_expression.keys() == {"all"}:
+ asset_expression_type = "and-gate"
+ expr_key = "all"
+
+ if not asset_expression_type:
+ return nodes, edges
+
+ # include assets, asset-alias, asset-name-refs, asset-uri-refs
+ assets_info: list[dict] = []
+ # nested boolean sub-conditions, each of which becomes its own gate
feeding this one
+ nested_expressions: list[dict] = []
+
+ for expr in asset_expression[expr_key]:
+ nested_expr_key = next(iter(expr), None)
+ if nested_expr_key in ("any", "all"):
+ nested_expressions.append(expr)
+ elif nested_expr_key in ("asset", "alias", "asset-name-ref",
"asset-uri-ref"):
+ asset_info = expr[nested_expr_key]
+ asset_info["type"] = nested_expr_key if nested_expr_key != "alias"
else "asset-alias"
+ assets_info.append(asset_info)
+ elif nested_expr_key == "asset_ref":
+ # Asset.ref(...) that hasn't been resolved to a concrete asset yet
serializes as
+ # {"asset_ref": {"name": ...}} or {"asset_ref": {"uri": ...}} --
disambiguate on
+ # the inner field since, unlike the other branches, the key itself
doesn't say which.
+ ref_info = expr[nested_expr_key]
+ ref_info["type"] = "asset-name-ref" if "name" in ref_info else
"asset-uri-ref"
+ assets_info.append(ref_info)
+ else:
+ raise TypeError(f"Unsupported type: {expr.keys()}")
+
+ branch_count = len(assets_info) + len(nested_expressions)
+ if branch_count == 0:
+ return nodes, edges
+
+ # A condition combining exactly one branch isn't a real AND/OR -- connect
it directly (or
+ # recurse straight through the lone nested wrapper) instead of rendering a
single-input gate.
+ if branch_count == 1:
+ if assets_info:
+ source_id, label = _asset_node_id_and_label(assets_info[0])
+ edges.append({"source_id": source_id, "target_id": entry_node_ref})
+ nodes.append({"id": source_id, "label": label, "type":
assets_info[0]["type"]})
+ return nodes, edges
+ return get_upstream_assets(nested_expressions[0], entry_node_ref,
level=level)
+
+ # Scoped by entry_node_ref (unique per Dag) so gates from different Dags
don't collide
+ # when merged into a single multi-dag graph.
+ asset_condition_id = f"{entry_node_ref}-{asset_expression_type}-{level}"
+ edges.append({"source_id": asset_condition_id, "target_id":
entry_node_ref})
+ nodes.append(
+ {
+ "id": asset_condition_id,
+ "label": asset_condition_id,
+ "type": "asset-condition",
+ "asset_condition_type": asset_expression_type,
+ }
+ )
+
+ for asset in assets_info:
+ source_id, label = _asset_node_id_and_label(asset)
+ edges.append({"source_id": source_id, "target_id": asset_condition_id})
+ nodes.append({"id": source_id, "label": label, "type": asset["type"]})
+
+ # Each nested sub-condition feeds this gate through its own gate. The
branch index keeps
+ # sibling gates of the same type from colliding on their scoped id.
+ for index, nested in enumerate(nested_expressions):
+ nested_nodes, nested_edges = get_upstream_assets(nested,
asset_condition_id, level=index)
+ nodes += nested_nodes
+ edges += nested_edges
+
+ return nodes, edges
+
def _dfs_connected_components(
temp: list[str], node_id: str, visited: dict[str, bool], adjacency_matrix:
dict[str, list[str]]
@@ -85,7 +189,7 @@ def extract_single_connected_component(
return {"nodes": nodes, "edges": edges}
-def get_scheduling_dependencies(readable_dag_ids: set[str] | None = None) ->
dict[str, list[dict]]:
+def get_scheduling_dependencies(readable_dag_ids: set[str] | None, session:
Session) -> dict[str, list[dict]]:
"""Get scheduling dependencies between Dags."""
from airflow.models.serialized_dag import SerializedDagModel
@@ -93,6 +197,40 @@ def get_scheduling_dependencies(readable_dag_ids: set[str]
| None = None) -> dic
edge_tuples: set[tuple[str, str]] = set()
dag_dependencies = SerializedDagModel.get_dag_dependencies()
+
+ relevant_dag_ids = [
+ dag for dag in dag_dependencies if readable_dag_ids is None or dag in
readable_dag_ids
+ ]
+
+ # A Dag's asset trigger condition (AND/OR of assets) is expanded into
asset-condition
+ # gate nodes so it renders the same way here as it would per-Dag. A
single-asset
+ # schedule serializes as a bare `{"asset": {...}}`, not `{"any": [...]}`,
so
+ # `get_upstream_assets` returns nothing for it — only Dags with a genuine
boolean
+ # condition are gate-expanded; other Dags keep their flat asset edge
untouched.
+ expression_nodes: dict[str, dict] = {}
+ expression_edges: set[tuple[str, str]] = set()
+ gated_dag_ids: set[str] = set()
+ if relevant_dag_ids:
+ asset_expressions = session.execute(
+ select(DagModel.dag_id,
DagModel.asset_expression).where(DagModel.dag_id.in_(relevant_dag_ids))
+ )
+ for dag_id, asset_expression in asset_expressions:
+ if not asset_expression:
+ continue
+ try:
+ upstream_nodes, upstream_edges =
get_upstream_assets(asset_expression, f"dag:{dag_id}")
+ except TypeError:
+ # A malformed/unrecognized asset_expression for one Dag must
not break this
+ # endpoint for every Dag -- fall back to that Dag's flat asset
edge instead.
+ log.warning("Could not expand asset_expression into gate
nodes", dag_id=dag_id, exc_info=True)
+ continue
+ if upstream_nodes:
+ gated_dag_ids.add(dag_id)
+ for node in upstream_nodes:
+ expression_nodes[node["id"]] = node
+ for edge in upstream_edges:
+ expression_edges.add((edge["source_id"],
edge["target_id"]))
+
for dag, dependencies in sorted(dag_dependencies.items()):
if readable_dag_ids is not None and dag not in readable_dag_ids:
continue
@@ -113,6 +251,15 @@ def get_scheduling_dependencies(readable_dag_ids: set[str]
| None = None) -> dic
if not referenced_dag_ids.issubset(readable_dag_ids):
continue
+ # This Dag's upstream asset condition is represented by the
gate nodes
+ # built above instead of a flat edge.
+ if (
+ dag in gated_dag_ids
+ and dep.target == dag
+ and dep.dependency_type in ASSET_UPSTREAM_DEPENDENCY_TYPES
+ ):
+ continue
+
# Add nodes
nodes_dict[dag_node_id] = {"id": dag_node_id, "label": dag,
"type": "dag"}
if dep.node_id not in nodes_dict:
@@ -135,6 +282,9 @@ def get_scheduling_dependencies(readable_dag_ids: set[str]
| None = None) -> dic
target = dep.target if ":" in dep.target else
f"dag:{dep.target}"
edge_tuples.add((source, target))
+ nodes_dict.update(expression_nodes)
+ edge_tuples |= expression_edges
+
# Create missing ``dag:`` nodes which may have been skipped by the loop
above.
# A DAG referenced only as a trigger target or a sensor source may have no
# scheduling dependencies of its own. Without this loop, these DAGs will
not be
@@ -163,6 +313,83 @@ def get_scheduling_dependencies(readable_dag_ids: set[str]
| None = None) -> dic
}
+def _entry_task_id_from_serialized_data(data: dict | None) -> str | None:
+ """
+ Pick a Dag's entry-point task id from its serialized JSON, without
deserializing the Dag.
+
+ A root task (one nothing else lists as downstream) is where an asset
schedule enters the
+ graph. Task ids and their downstream links are read straight from the
serialized payload --
+ ``downstream_task_ids`` is stored as a plain list of strings -- so this
avoids instantiating
+ every operator just to read one id. Ties are broken by sorted order for
determinism.
+ """
+ tasks = (data or {}).get("dag", {}).get("tasks", [])
+ task_ids: set[str] = set()
+ downstream_ids: set[str] = set()
+ for task in tasks:
+ var = task.get("__var", {})
+ task_id = var.get("task_id")
+ if task_id is None:
+ continue
+ task_ids.add(task_id)
+ downstream_ids.update(var.get("downstream_task_ids") or [])
+
+ if not task_ids:
+ return None
+ roots = task_ids - downstream_ids
+ return min(roots) if roots else min(task_ids)
+
+
+def _get_dag_entry_points(dag_ids: set[str], session: Session) -> dict[str,
tuple[str, dict | None]]:
+ """
+ Batch-resolve each Dag's entry-point task id and its asset_expression.
+
+ Fetches the latest SerializedDagModel row for every dag_id in one query
(mirroring the
+ latest-per-group window-function pattern in
+ ``SerializedDagModel._prefetch_dag_write_metadata``) instead of one query
per Dag, and reads
+ the entry task from the serialized JSON rather than deserializing the
whole Dag -- both matter
+ when a single asset schedules many Dags across a connected component.
+ """
+ from sqlalchemy import func, select
+ from sqlalchemy.orm import joinedload
+
+ from airflow.models.dag_version import DagVersion
+ from airflow.models.serialized_dag import SerializedDagModel
+
+ if not dag_ids:
+ return {}
+
+ latest_per_dag = (
+ select(
+ SerializedDagModel.id,
+ func.row_number()
+ .over(partition_by=SerializedDagModel.dag_id,
order_by=DagVersion.version_number.desc())
+ .label("rn"),
+ )
+ .join(DagVersion, SerializedDagModel.dag_version_id == DagVersion.id)
+ .where(SerializedDagModel.dag_id.in_(dag_ids))
+ .subquery()
+ )
+
+ serialized_dags = session.scalars(
+ select(SerializedDagModel)
+ .join(latest_per_dag, SerializedDagModel.id == latest_per_dag.c.id)
+ .where(latest_per_dag.c.rn == 1)
+ .options(joinedload(SerializedDagModel.dag_model))
+ ).all()
+
+ entry_points: dict[str, tuple[str, dict | None]] = {}
+ for serialized_dag in serialized_dags:
+ entry_task_id =
_entry_task_id_from_serialized_data(serialized_dag.data)
+ if entry_task_id is None:
+ continue
+ entry_points[serialized_dag.dag_id] = (
+ entry_task_id,
+ serialized_dag.dag_model.asset_expression,
+ )
+
+ return entry_points
+
+
def get_data_dependencies(
asset_id: int, session: Session, readable_dag_ids: set[str] | None = None
) -> dict[str, list[dict]]:
@@ -197,98 +424,225 @@ def get_data_dependencies(
nodes_dict: dict[str, dict] = {}
edge_set: set[tuple[str, str]] = set()
- # BFS to trace full dependencies
- assets_to_process: deque[int] = deque([asset_id])
processed_assets: set[int] = set()
processed_tasks: set[tuple[str, str]] = set() # (dag_id, task_id)
-
- while assets_to_process:
- current_asset_id = assets_to_process.popleft()
- if current_asset_id in processed_assets:
- continue
- processed_assets.add(current_asset_id)
-
- # Eagerload producing_tasks and consuming_tasks to avoid lazy queries
- asset = session.scalar(
+ processed_scheduled_dags: set[str] = set()
+ # Assets linked to an AssetAlias -- these may be produced via the alias
with no static
+ # TaskOutletAssetReference, so their producing task is recovered from
AssetEvent afterwards.
+ alias_linked_asset_ids: set[int] = set()
+
+ # BFS by rounds (a whole frontier of assets at once) rather than one asset
at a time, so
+ # every Dag scheduled by assets in the same round has its entry point
resolved through one
+ # batched query (see _get_dag_entry_points) instead of one query -- and
one full Dag
+ # deserialization -- per Dag.
+ frontier: set[int] = {asset_id}
+
+ while frontier:
+ round_asset_ids = frontier - processed_assets
+ processed_assets |= round_asset_ids
+ if not round_asset_ids:
+ break
+
+ # Eagerload producing_tasks, consuming_tasks, and scheduled_dags to
avoid lazy queries
+ assets = session.scalars(
select(AssetModel)
- .where(AssetModel.id == current_asset_id)
+ .where(AssetModel.id.in_(round_asset_ids))
.options(
selectinload(AssetModel.producing_tasks),
selectinload(AssetModel.consuming_tasks),
+ selectinload(AssetModel.scheduled_dags),
+ selectinload(AssetModel.aliases),
)
- )
- if not asset:
- continue
-
- asset_node_id = f"asset:{current_asset_id}"
+ ).all()
+
+ next_frontier: set[int] = set()
+ pending_dag_ids: set[str] = set()
+ triggering_asset_node_ids_by_dag: dict[str, set[str]] =
defaultdict(set)
+
+ for asset in assets:
+ asset_node_id = f"asset:{asset.id}"
+
+ # Add asset node
+ if asset_node_id not in nodes_dict:
+ nodes_dict[asset_node_id] = {"id": asset_node_id, "label":
asset.name, "type": "asset"}
+
+ if asset.aliases:
+ alias_linked_asset_ids.add(asset.id)
+
+ # Process producing tasks (tasks that output this asset)
+ for ref in asset.producing_tasks:
+ # Filter out tasks from Dags the user doesn't have access to
+ if readable_dag_ids is not None and ref.dag_id not in
readable_dag_ids:
+ continue
+ task_key = (ref.dag_id, ref.task_id)
+ task_node_id = f"task:{ref.dag_id}{SEPARATOR}{ref.task_id}"
+
+ # Add task node with dag_id.task_id label for disambiguation
+ if task_node_id not in nodes_dict:
+ nodes_dict[task_node_id] = {
+ "id": task_node_id,
+ "label": f"{ref.dag_id}.{ref.task_id}",
+ "type": "task",
+ }
- # Add asset node
- if asset_node_id not in nodes_dict:
- nodes_dict[asset_node_id] = {"id": asset_node_id, "label":
asset.name, "type": "asset"}
+ # Add edge: task → asset
+ edge_set.add((task_node_id, asset_node_id))
+
+ # Find other assets this task consumes (inlets) to trace
upstream
+ if task_key not in processed_tasks:
+ processed_tasks.add(task_key)
+ inlet_refs = session.scalars(
+ select(TaskInletAssetReference).where(
+ TaskInletAssetReference.dag_id == ref.dag_id,
+ TaskInletAssetReference.task_id == ref.task_id,
+ )
+ ).all()
+ for inlet_ref in inlet_refs:
+ if inlet_ref.asset_id not in processed_assets:
+ next_frontier.add(inlet_ref.asset_id)
+
+ # Process consuming tasks (tasks that input this asset)
+ for ref in asset.consuming_tasks:
+ # Filter out tasks from Dags the user doesn't have access to
+ if readable_dag_ids is not None and ref.dag_id not in
readable_dag_ids:
+ continue
+ task_key = (ref.dag_id, ref.task_id)
+ task_node_id = f"task:{ref.dag_id}{SEPARATOR}{ref.task_id}"
+
+ # Add task node with dag_id.task_id label for disambiguation
+ if task_node_id not in nodes_dict:
+ nodes_dict[task_node_id] = {
+ "id": task_node_id,
+ "label": f"{ref.dag_id}.{ref.task_id}",
+ "type": "task",
+ }
- # Process producing tasks (tasks that output this asset)
- for ref in asset.producing_tasks:
- # Filter out tasks from Dags the user doesn't have access to
- if readable_dag_ids is not None and ref.dag_id not in
readable_dag_ids:
+ # Add edge: asset → task
+ edge_set.add((asset_node_id, task_node_id))
+
+ # Find other assets this task produces (outlets) to trace
downstream
+ if task_key not in processed_tasks:
+ processed_tasks.add(task_key)
+ outlet_refs = session.scalars(
+ select(TaskOutletAssetReference).where(
+ TaskOutletAssetReference.dag_id == ref.dag_id,
+ TaskOutletAssetReference.task_id == ref.task_id,
+ )
+ ).all()
+ for outlet_ref in outlet_refs:
+ if outlet_ref.asset_id not in processed_assets:
+ next_frontier.add(outlet_ref.asset_id)
+
+ # Process Dags scheduled by this asset at the Dag level
(`schedule=...`). These have
+ # no task-level inlet reference and would otherwise be a dead end
in this graph --
+ # collect them here and resolve every entry point for the round
together, below.
+ for ref in asset.scheduled_dags:
+ if readable_dag_ids is not None and ref.dag_id not in
readable_dag_ids:
+ continue
+ if ref.dag_id in processed_scheduled_dags:
+ continue
+ pending_dag_ids.add(ref.dag_id)
+ # Record every asset in this round that schedules the Dag --
marking it processed
+ # is deferred until after the round so a second triggering
asset isn't skipped.
+ triggering_asset_node_ids_by_dag[ref.dag_id].add(asset_node_id)
+
+ processed_scheduled_dags |= pending_dag_ids
+
+ # Route through each Dag's asset_expression (skipping straight to a
flat edge for a
+ # single-asset schedule) into the Dag's topologically-first task, same
as the scheduling
+ # graph does for its `dag:` nodes.
+ entry_points = _get_dag_entry_points(pending_dag_ids, session)
+ for dag_id in pending_dag_ids:
+ entry_point = entry_points.get(dag_id)
+ if entry_point is None:
continue
- task_key = (ref.dag_id, ref.task_id)
- task_node_id = f"task:{ref.dag_id}{SEPARATOR}{ref.task_id}"
+ entry_task_id, asset_expression = entry_point
+ task_key = (dag_id, entry_task_id)
+ task_node_id = f"task:{dag_id}{SEPARATOR}{entry_task_id}"
- # Add task node with dag_id.task_id label for disambiguation
if task_node_id not in nodes_dict:
nodes_dict[task_node_id] = {
"id": task_node_id,
- "label": f"{ref.dag_id}.{ref.task_id}",
+ "label": f"{dag_id}.{entry_task_id}",
"type": "task",
}
- # Add edge: task → asset
- edge_set.add((task_node_id, asset_node_id))
+ upstream_nodes: list[dict] = []
+ upstream_edges: list[dict] = []
+ if asset_expression:
+ try:
+ upstream_nodes, upstream_edges =
get_upstream_assets(asset_expression, task_node_id)
+ except TypeError:
+ log.warning(
+ "Could not expand asset_expression into gate nodes",
+ dag_id=dag_id,
+ exc_info=True,
+ )
- # Find other assets this task consumes (inlets) to trace upstream
+ if upstream_nodes:
+ for node in upstream_nodes:
+ nodes_dict.setdefault(node["id"], node)
+ # Continue the BFS through any other concrete assets this
gate combines
+ # with, so their own producers are traced too.
+ if node["id"].startswith("asset:"):
+ sibling_asset_id =
int(node["id"].removeprefix("asset:"))
+ if sibling_asset_id not in processed_assets:
+ next_frontier.add(sibling_asset_id)
+ for edge in upstream_edges:
+ edge_set.add((edge["source_id"], edge["target_id"]))
+ else:
+ # No gate (bare single-asset schedule, or an expression that
couldn't be
+ # expanded) -- link every triggering asset directly to the
entry task.
+ for triggering_asset_node_id in
triggering_asset_node_ids_by_dag[dag_id]:
+ edge_set.add((triggering_asset_node_id, task_node_id))
+
+ # Find other assets this entry task produces (outlets) to trace
downstream.
if task_key not in processed_tasks:
processed_tasks.add(task_key)
- inlet_refs = session.scalars(
- select(TaskInletAssetReference).where(
- TaskInletAssetReference.dag_id == ref.dag_id,
- TaskInletAssetReference.task_id == ref.task_id,
+ outlet_refs = session.scalars(
+ select(TaskOutletAssetReference).where(
+ TaskOutletAssetReference.dag_id == dag_id,
+ TaskOutletAssetReference.task_id == entry_task_id,
)
).all()
- for inlet_ref in inlet_refs:
- if inlet_ref.asset_id not in processed_assets:
- assets_to_process.append(inlet_ref.asset_id)
-
- # Process consuming tasks (tasks that input this asset)
- for ref in asset.consuming_tasks:
- # Filter out tasks from Dags the user doesn't have access to
- if readable_dag_ids is not None and ref.dag_id not in
readable_dag_ids:
+ for outlet_ref in outlet_refs:
+ if outlet_ref.asset_id not in processed_assets:
+ next_frontier.add(outlet_ref.asset_id)
+
+ frontier = next_frontier
+
+ # Assets produced only through an AssetAlias have no static
TaskOutletAssetReference, so the
+ # producer loop above can't attribute them. Only when the graph actually
contains alias-linked
+ # assets do we consult AssetEvent -- one batched query -- to recover the
task that produced each
+ # via its alias, so alias-produced assets still show their source task.
+ if alias_linked_asset_ids:
+ from airflow.models.asset import AssetEvent
+
+ alias_events = session.execute(
+ select(AssetEvent.asset_id, AssetEvent.source_dag_id,
AssetEvent.source_task_id)
+ .join(AssetEvent.source_aliases)
+ .where(
+ AssetEvent.asset_id.in_(alias_linked_asset_ids),
+ AssetEvent.source_dag_id.is_not(None),
+ AssetEvent.source_task_id.is_not(None),
+ )
+ .distinct()
+ )
+ for event_asset_id, source_dag_id, source_task_id in alias_events:
+ if readable_dag_ids is not None and source_dag_id not in
readable_dag_ids:
continue
- task_key = (ref.dag_id, ref.task_id)
- task_node_id = f"task:{ref.dag_id}{SEPARATOR}{ref.task_id}"
-
- # Add task node with dag_id.task_id label for disambiguation
+ asset_node_id = f"asset:{event_asset_id}"
+ if asset_node_id not in nodes_dict:
+ continue
+ task_node_id = f"task:{source_dag_id}{SEPARATOR}{source_task_id}"
if task_node_id not in nodes_dict:
nodes_dict[task_node_id] = {
"id": task_node_id,
- "label": f"{ref.dag_id}.{ref.task_id}",
+ "label": f"{source_dag_id}.{source_task_id}",
"type": "task",
}
-
- # Add edge: asset → task
- edge_set.add((asset_node_id, task_node_id))
-
- # Find other assets this task produces (outlets) to trace
downstream
- if task_key not in processed_tasks:
- processed_tasks.add(task_key)
- outlet_refs = session.scalars(
- select(TaskOutletAssetReference).where(
- TaskOutletAssetReference.dag_id == ref.dag_id,
- TaskOutletAssetReference.task_id == ref.task_id,
- )
- ).all()
- for outlet_ref in outlet_refs:
- if outlet_ref.asset_id not in processed_assets:
- assets_to_process.append(outlet_ref.asset_id)
+ edge_set.add((task_node_id, asset_node_id))
+ processed_tasks.add((source_dag_id, source_task_id))
all_dag_ids = list({dag_id for dag_id, _ in processed_tasks})
if all_dag_ids:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/structure.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/structure.py
deleted file mode 100644
index db3d1ba6dea..00000000000
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/structure.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# 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.
-
-"""
-Private service for dag structure.
-
-:meta private:
-"""
-
-from __future__ import annotations
-
-from collections import defaultdict
-
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from airflow.models.asset import AssetAliasModel, AssetEvent
-from airflow.models.dag_version import DagVersion
-from airflow.models.dagrun import DagRun
-from airflow.models.serialized_dag import SerializedDagModel
-
-
-def get_upstream_assets(
- asset_expression: dict, entry_node_ref: str, level: int = 0
-) -> tuple[list[dict], list[dict]]:
- edges: list[dict] = []
- nodes: list[dict] = []
- asset_expression_type: str | None = None
-
- # include assets, asset-alias, asset-name-refs, asset-uri-refs
- assets_info: list[dict] = []
-
- nested_expression: dict = {}
-
- expr_key = ""
- if asset_expression.keys() == {"any"}:
- asset_expression_type = "or-gate"
- expr_key = "any"
- elif asset_expression.keys() == {"all"}:
- asset_expression_type = "and-gate"
- expr_key = "all"
-
- if expr_key in asset_expression:
- asset_exprs: list[dict] = asset_expression[expr_key]
- for expr in asset_exprs:
- nested_expr_key = next(iter(expr.keys()))
- if nested_expr_key in ("any", "all"):
- nested_expression = expr
- elif nested_expr_key in ("asset", "alias", "asset-name-ref",
"asset-uri-ref"):
- asset_info = expr[nested_expr_key]
- asset_info["type"] = nested_expr_key if nested_expr_key !=
"alias" else "asset-alias"
-
- assets_info.append(asset_info)
- else:
- raise TypeError(f"Unsupported type: {expr.keys()}")
-
- if asset_expression_type and assets_info:
- asset_condition_id = f"{asset_expression_type}-{level}"
- edges.append(
- {
- "source_id": asset_condition_id,
- "target_id": entry_node_ref,
- "is_source_asset": level == 0,
- }
- )
- nodes.append(
- {
- "id": asset_condition_id,
- "label": asset_condition_id,
- "type": "asset-condition",
- "asset_condition_type": asset_expression_type,
- }
- )
-
- for asset in assets_info:
- asset_type = asset["type"]
-
- if asset_type == "asset":
- source_id = str(asset["id"])
- label = asset["name"]
- elif asset_type == "asset-alias" or asset_type == "asset-name-ref":
- source_id = asset["name"]
- label = asset["name"]
- elif asset_type == "asset-uri-ref":
- source_id = asset["uri"]
- label = asset["uri"]
- else:
- raise TypeError(f"Unsupported type: {asset_type}")
-
- edges.append(
- {
- "source_id": source_id,
- "target_id": asset_condition_id,
- }
- )
- nodes.append(
- {
- "id": source_id,
- "label": label,
- "type": asset_type,
- }
- )
-
- if nested_expression is not None:
- n, e = get_upstream_assets(nested_expression, asset_condition_id,
level=level + 1)
-
- nodes = nodes + n
- edges = edges + e
-
- return nodes, edges
-
-
-def bind_output_assets_to_tasks(
- edges: list[dict], serialized_dag: SerializedDagModel, version_number:
int, session: Session
-) -> None:
- """
- Try to bind the downstream assets to the relevant task that produces them.
-
- This function will mutate the `edges` in place.
- """
- # bind normal assets present in the `task_outlet_asset_references`
- outlet_asset_references =
serialized_dag.dag_model.task_outlet_asset_references
-
- downstream_asset_edges = [
- edge
- for edge in edges
- if edge["target_id"].startswith("asset:") and not
edge.get("resolved_from_alias")
- ]
-
- for edge in downstream_asset_edges:
- # Try to attach the outlet assets to the relevant tasks
- asset_id = int(edge["target_id"].replace("asset:", "", 1))
- outlet_asset_reference = next(
- outlet_asset_reference
- for outlet_asset_reference in outlet_asset_references
- if outlet_asset_reference.asset_id == asset_id
- )
- edge["source_id"] = outlet_asset_reference.task_id
-
- # bind assets resolved from aliases, they do not populate the
`outlet_asset_references`
- downstream_alias_resolved_edges = [
- edge for edge in edges if edge["target_id"].startswith("asset:") and
edge.get("resolved_from_alias")
- ]
-
- aliases_names = {edges["resolved_from_alias"] for edges in
downstream_alias_resolved_edges}
-
- result = session.scalars(
- select(AssetEvent)
- .join(AssetEvent.source_aliases)
- .join(AssetEvent.source_dag_run)
- # That's a simplification, instead doing `version_number` in
`DagRun.dag_versions`.
- .join(DagRun.created_dag_version)
-
.where(AssetEvent.source_aliases.any(AssetAliasModel.name.in_(aliases_names)))
- .where(AssetEvent.source_dag_run.has(DagRun.dag_id ==
serialized_dag.dag_model.dag_id))
- .where(DagVersion.version_number == version_number)
- ).unique()
-
- asset_id_to_task_ids = defaultdict(set)
- for asset_event in result:
-
asset_id_to_task_ids[asset_event.asset_id].add(asset_event.source_task_id)
-
- for edge in downstream_alias_resolved_edges:
- asset_id = int(edge["target_id"].replace("asset:", "", 1))
- task_ids = asset_id_to_task_ids.get(asset_id, set())
-
- for index, task_id in enumerate(task_ids):
- if index == 0:
- edge["source_id"] = task_id
- continue
- edge_copy = {**edge, "source_id": task_id}
- edges.append(edge_copy)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index ec9da9f0535..64e87bd2a1d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -990,15 +990,14 @@ export const UseDeadlinesServiceGetDagDeadlineAlertsKeyFn
= ({ dagId, limit, off
export type StructureServiceStructureDataDefaultResponse =
Awaited<ReturnType<typeof StructureService.structureData>>;
export type StructureServiceStructureDataQueryResult<TData =
StructureServiceStructureDataDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useStructureServiceStructureDataKey =
"StructureServiceStructureData";
-export const UseStructureServiceStructureDataKeyFn = ({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}: {
+export const UseStructureServiceStructureDataKeyFn = ({ dagId, depth,
includeDownstream, includeUpstream, root, versionNumber }: {
dagId: string;
depth?: number;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string;
versionNumber?: number;
-}, queryKey?: Array<unknown>) => [useStructureServiceStructureDataKey,
...(queryKey ?? [{ dagId, depth, externalDependencies, includeDownstream,
includeUpstream, root, versionNumber }])];
+}, queryKey?: Array<unknown>) => [useStructureServiceStructureDataKey,
...(queryKey ?? [{ dagId, depth, includeDownstream, includeUpstream, root,
versionNumber }])];
export type GridServiceGetDagStructureDefaultResponse =
Awaited<ReturnType<typeof GridService.getDagStructure>>;
export type GridServiceGetDagStructureQueryResult<TData =
GridServiceGetDagStructureDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useGridServiceGetDagStructureKey = "GridServiceGetDagStructure";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 9482e9ce6d0..c3c73d7d427 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1908,20 +1908,18 @@ export const
ensureUseDeadlinesServiceGetDagDeadlineAlertsData = (queryClient: Q
* @param data.includeDownstream
* @param data.depth
* @param data.root
-* @param data.externalDependencies
* @param data.versionNumber
* @returns StructureDataResponse Successful Response
* @throws ApiError
*/
-export const ensureUseStructureServiceStructureDataData = (queryClient:
QueryClient, { dagId, depth, externalDependencies, includeDownstream,
includeUpstream, root, versionNumber }: {
+export const ensureUseStructureServiceStructureDataData = (queryClient:
QueryClient, { dagId, depth, includeDownstream, includeUpstream, root,
versionNumber }: {
dagId: string;
depth?: number;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string;
versionNumber?: number;
-}) => queryClient.ensureQueryData({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}), queryFn: () => StructureService.structureData({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}) });
+}) => queryClient.ensureQueryData({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }), queryFn: () =>
StructureService.structureData({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }) });
/**
* Get Dag Structure
* Return dag structure for grid view.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 1eaac7e3395..53e93745026 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1908,20 +1908,18 @@ export const
prefetchUseDeadlinesServiceGetDagDeadlineAlerts = (queryClient: Que
* @param data.includeDownstream
* @param data.depth
* @param data.root
-* @param data.externalDependencies
* @param data.versionNumber
* @returns StructureDataResponse Successful Response
* @throws ApiError
*/
-export const prefetchUseStructureServiceStructureData = (queryClient:
QueryClient, { dagId, depth, externalDependencies, includeDownstream,
includeUpstream, root, versionNumber }: {
+export const prefetchUseStructureServiceStructureData = (queryClient:
QueryClient, { dagId, depth, includeDownstream, includeUpstream, root,
versionNumber }: {
dagId: string;
depth?: number;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string;
versionNumber?: number;
-}) => queryClient.prefetchQuery({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}), queryFn: () => StructureService.structureData({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}) });
+}) => queryClient.prefetchQuery({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }), queryFn: () =>
StructureService.structureData({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }) });
/**
* Get Dag Structure
* Return dag structure for grid view.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index 386cd4aca74..49f1fa6d734 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -1908,20 +1908,18 @@ export const useDeadlinesServiceGetDagDeadlineAlerts =
<TData = Common.Deadlines
* @param data.includeDownstream
* @param data.depth
* @param data.root
-* @param data.externalDependencies
* @param data.versionNumber
* @returns StructureDataResponse Successful Response
* @throws ApiError
*/
-export const useStructureServiceStructureData = <TData =
Common.StructureServiceStructureDataDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}: {
+export const useStructureServiceStructureData = <TData =
Common.StructureServiceStructureDataDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, depth,
includeDownstream, includeUpstream, root, versionNumber }: {
dagId: string;
depth?: number;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string;
versionNumber?: number;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}, queryKey), queryFn: () => StructureService.structureData({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}) as TData, ...options });
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }, queryKey), queryFn: () =>
StructureService.structureData({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }) as TData, ...options });
/**
* Get Dag Structure
* Return dag structure for grid view.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index e4834f3ac0a..a67ccaa19ee 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1908,20 +1908,18 @@ export const
useDeadlinesServiceGetDagDeadlineAlertsSuspense = <TData = Common.D
* @param data.includeDownstream
* @param data.depth
* @param data.root
-* @param data.externalDependencies
* @param data.versionNumber
* @returns StructureDataResponse Successful Response
* @throws ApiError
*/
-export const useStructureServiceStructureDataSuspense = <TData =
Common.StructureServiceStructureDataDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}: {
+export const useStructureServiceStructureDataSuspense = <TData =
Common.StructureServiceStructureDataDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, depth,
includeDownstream, includeUpstream, root, versionNumber }: {
dagId: string;
depth?: number;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string;
versionNumber?: number;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}, queryKey), queryFn: () => StructureService.structureData({ dagId, depth,
externalDependencies, includeDownstream, includeUpstream, root, versionNumber
}) as TData, ...options });
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseStructureServiceStructureDataKeyFn({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }, queryKey), queryFn: () =>
StructureService.structureData({ dagId, depth, includeDownstream,
includeUpstream, root, versionNumber }) as TData, ...options });
/**
* Get Dag Structure
* Return dag structure for grid view.
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 fedb68983f6..d14b118651c 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
@@ -8743,6 +8743,18 @@ export const $BaseNodeResponse = {
}
],
title: 'Team'
+ },
+ asset_condition_type: {
+ anyOf: [
+ {
+ type: 'string',
+ enum: ['or-gate', 'and-gate']
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Asset Condition Type'
}
},
type: 'object',
@@ -9794,17 +9806,6 @@ export const $EdgeResponse = {
}
],
title: 'Label'
- },
- is_source_asset: {
- anyOf: [
- {
- type: 'boolean'
- },
- {
- type: 'null'
- }
- ],
- title: 'Is Source Asset'
}
},
type: 'object',
@@ -10445,6 +10446,18 @@ export const $NodeResponse = {
],
title: 'Team'
},
+ asset_condition_type: {
+ anyOf: [
+ {
+ type: 'string',
+ enum: ['or-gate', 'and-gate']
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Asset Condition Type'
+ },
children: {
anyOf: [
{
@@ -10504,18 +10517,6 @@ export const $NodeResponse = {
],
title: 'Operator'
},
- asset_condition_type: {
- anyOf: [
- {
- type: 'string',
- enum: ['or-gate', 'and-gate']
- },
- {
- type: 'null'
- }
- ],
- title: 'Asset Condition Type'
- },
ui_color: {
anyOf: [
{
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 1c7365f7ea8..d34910b8276 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -4901,7 +4901,6 @@ export class StructureService {
* @param data.includeDownstream
* @param data.depth
* @param data.root
- * @param data.externalDependencies
* @param data.versionNumber
* @returns StructureDataResponse Successful Response
* @throws ApiError
@@ -4916,11 +4915,9 @@ export class StructureService {
include_downstream: data.includeDownstream,
depth: data.depth,
root: data.root,
- external_dependencies: data.externalDependencies,
version_number: data.versionNumber
},
errors: {
- 400: 'Bad Request',
404: 'Not Found',
422: 'Validation Error'
}
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 f0b9e09aa46..932326c9d6f 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
@@ -2207,6 +2207,7 @@ export type BaseNodeResponse = {
label: string;
type: 'join' | 'task' | 'asset-condition' | 'asset' | 'asset-alias' |
'asset-name-ref' | 'asset-uri-ref' | 'dag' | 'sensor' | 'trigger';
team?: string | null;
+ asset_condition_type?: 'or-gate' | 'and-gate' | null;
};
export type type = 'join' | 'task' | 'asset-condition' | 'asset' |
'asset-alias' | 'asset-name-ref' | 'asset-uri-ref' | 'dag' | 'sensor' |
'trigger';
@@ -2489,7 +2490,6 @@ export type EdgeResponse = {
target_id: string;
is_setup_teardown?: boolean | null;
label?: string | null;
- is_source_asset?: boolean | null;
};
/**
@@ -2652,12 +2652,12 @@ export type NodeResponse = {
label: string;
type: 'join' | 'task' | 'asset-condition' | 'asset' | 'asset-alias' |
'asset-name-ref' | 'asset-uri-ref' | 'dag' | 'sensor' | 'trigger';
team?: string | null;
+ asset_condition_type?: 'or-gate' | 'and-gate' | null;
children?: Array<NodeResponse> | null;
is_mapped?: boolean | null;
tooltip?: string | null;
setup_teardown_type?: 'setup' | 'teardown' | null;
operator?: string | null;
- asset_condition_type?: 'or-gate' | 'and-gate' | null;
ui_color?: string | null;
ui_fgcolor?: string | null;
};
@@ -4634,7 +4634,6 @@ export type GetDagDeadlineAlertsResponse =
DeadlineAlertCollectionResponse;
export type StructureDataData = {
dagId: string;
depth?: number | null;
- externalDependencies?: boolean;
includeDownstream?: boolean;
includeUpstream?: boolean;
root?: string | null;
@@ -8541,10 +8540,6 @@ export type $OpenApiTs = {
* Successful Response
*/
200: StructureDataResponse;
- /**
- * Bad Request
- */
- 400: HTTPExceptionResponse;
/**
* Not Found
*/
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index f203c21595f..6712444e12e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -144,13 +144,7 @@
"label": "Number of Dag Runs"
},
"dependencies": {
- "label": "Dependencies",
- "options": {
- "allDagDependencies": "All Dag Dependencies",
- "externalConditions": "External conditions",
- "onlyTasks": "Only tasks"
- },
- "placeholder": "Dependencies"
+ "allDagDependencies": "All Dag Dependencies"
},
"graphDirection": {
"label": "Graph Direction"
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
index a4afa95c168..c2e19448143 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
@@ -39,13 +39,18 @@ const CustomEdge = ({ data, source, target }: Props) => {
// don't require the parent to rebuild and pass down a new edges array.
// useNodesData subscribes to data changes for these specific node IDs only.
const nodesData = useNodesData([source, target]);
- const isSelected = nodesData.some((node) => Boolean(node.data.isSelected));
if (data === undefined) {
return undefined;
}
const { rest } = data;
+ // rest.isSelected covers what node-level selection can't express on its
own: a gate
+ // (asset-condition) node is shared by every asset in its AND/OR condition,
so marking the gate
+ // itself selected would highlight every edge touching it, not just the path
relevant to the
+ // currently selected asset/Dag. See getGatePathEdgeIdsForSelection.
+ const isSelected = Boolean(rest.isSelected) || nodesData.some((node) =>
Boolean(node.data.isSelected));
+
const edgeStrokeColor = isSelected ? (rest.edgeType === "data" ?
dataEdgeColor : blueColor) : strokeColor;
return (
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.test.ts
b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.test.ts
new file mode 100644
index 00000000000..10bc5d81f3c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.test.ts
@@ -0,0 +1,118 @@
+/*!
+ * 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.
+ */
+import { describe, expect, it } from "vitest";
+
+import { getGatePathEdgeIdsForSelection } from "./reactflowUtils";
+
+const node = (id: string, type: string) => ({ id, type });
+const edge = (id: string, source: string, target: string) => ({ id, source,
target });
+
+describe("getGatePathEdgeIdsForSelection", () => {
+ it("returns the edges on the path from a selected asset through a gate to
the dag", () => {
+ const nodes = [node("asset:1", "asset"), node("gate-0",
"asset-condition"), node("dag:my_dag", "dag")];
+ const edges = [edge("e1", "asset:1", "gate-0"), edge("e2", "gate-0",
"dag:my_dag")];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, (id) => id ===
"asset:1");
+
+ expect(result).toEqual(new Set(["e1", "e2"]));
+ });
+
+ it("does not highlight a sibling asset's edge in the same AND/OR condition",
() => {
+ // asset:1 and asset:2 both feed and-gate-0, which schedules dag:my_dag.
Selecting asset:1
+ // must only highlight its own edge into the gate and the gate's single
output -- not
+ // asset:2's edge, even though asset:2 shares the same gate.
+ const nodes = [
+ node("asset:1", "asset"),
+ node("asset:2", "asset"),
+ node("and-gate-0", "asset-condition"),
+ node("dag:my_dag", "dag"),
+ ];
+ const edges = [
+ edge("e1", "asset:1", "and-gate-0"),
+ edge("e2", "asset:2", "and-gate-0"),
+ edge("e3", "and-gate-0", "dag:my_dag"),
+ ];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, (id) => id ===
"asset:1");
+
+ expect(result).toEqual(new Set(["e1", "e3"]));
+ expect(result.has("e2")).toBe(false);
+ });
+
+ it("fans out to every input when the Dag (output side) is selected", () => {
+ // From the Dag's perspective, all of the gate's inputs are genuinely
relevant.
+ const nodes = [
+ node("asset:1", "asset"),
+ node("asset:2", "asset"),
+ node("and-gate-0", "asset-condition"),
+ node("dag:my_dag", "dag"),
+ ];
+ const edges = [
+ edge("e1", "asset:1", "and-gate-0"),
+ edge("e2", "asset:2", "and-gate-0"),
+ edge("e3", "and-gate-0", "dag:my_dag"),
+ ];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, (id) => id ===
"dag:my_dag");
+
+ expect(result).toEqual(new Set(["e1", "e2", "e3"]));
+ });
+
+ it("walks forward through nested gates without fanning into a sibling gate's
inputs", () => {
+ // asset:1 feeds or-gate-1, which is one input (among others) to
and-gate-0, which schedules
+ // the Dag. Selecting asset:1 should light up its single path all the way
to the Dag, but not
+ // and-gate-0's other inputs.
+ const nodes = [
+ node("asset:1", "asset"),
+ node("asset:2", "asset"),
+ node("or-gate-1", "asset-condition"),
+ node("and-gate-0", "asset-condition"),
+ node("dag:my_dag", "dag"),
+ ];
+ const edges = [
+ edge("e1", "asset:1", "or-gate-1"),
+ edge("e2", "or-gate-1", "and-gate-0"),
+ edge("e3", "asset:2", "and-gate-0"),
+ edge("e4", "and-gate-0", "dag:my_dag"),
+ ];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, (id) => id ===
"asset:1");
+
+ expect(result).toEqual(new Set(["e1", "e2", "e4"]));
+ expect(result.has("e3")).toBe(false);
+ });
+
+ it("returns an empty set when the graph has no gate nodes", () => {
+ const nodes = [node("asset:1", "asset"), node("dag:my_dag", "dag")];
+ const edges = [edge("e1", "asset:1", "dag:my_dag")];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, (id) => id ===
"asset:1");
+
+ expect(result).toEqual(new Set());
+ });
+
+ it("returns an empty set when nothing is selected", () => {
+ const nodes = [node("asset:1", "asset"), node("gate-0",
"asset-condition"), node("dag:my_dag", "dag")];
+ const edges = [edge("e1", "asset:1", "gate-0"), edge("e2", "gate-0",
"dag:my_dag")];
+
+ const result = getGatePathEdgeIdsForSelection(nodes, edges, () => false);
+
+ expect(result).toEqual(new Set());
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
index 7a2667a7abc..93a12e550a0 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
@@ -141,3 +141,108 @@ export const formatFlowEdges = ({ edges }: { edges:
Array<Edge> }): Array<FlowEd
target: edge.targets[0] ?? "",
type: "custom",
}));
+
+type SelectionGraphEdge = { id: string; source: string; target: string };
+type SelectionGraphNode = { id: string; type?: string };
+
+/**
+ * Edges only highlight when one of their two endpoints has node-level
`isSelected` set, or (for
+ * gate-adjacent edges) when this function marks that specific edge id. Gate
edges are directional
+ * -- `source` is always the thing feeding the condition, `target` is what it
schedules -- so:
+ *
+ * - Entering a gate from one of its inputs (an asset, or a nested gate below
it) continues
+ * through the gate's single output edge only. It does NOT fan out to the
gate's other inputs,
+ * which is exactly the over-highlighting bug this replaces: selecting one
asset in an AND/OR
+ * condition must not also light up sibling assets that happen to share the
same gate.
+ * - Entering a gate from its output (e.g. selecting the Dag it schedules)
fans out to every one
+ * of its inputs, since all of them are genuinely part of what makes that
Dag's condition true.
+ *
+ * Returns the set of edge ids to mark selected -- callers OR this into their
own per-edge
+ * `isSelected` computation (see Graph.tsx / AssetGraph.tsx).
+ */
+export const getGatePathEdgeIdsForSelection = (
+ nodes: Array<SelectionGraphNode>,
+ edges: Array<SelectionGraphEdge>,
+ isNodeSelected: (nodeId: string) => boolean,
+): Set<string> => {
+ const gateNodeIds = new Set(nodes.filter((node) => node.type ===
"asset-condition").map((node) => node.id));
+
+ if (gateNodeIds.size === 0) {
+ return new Set();
+ }
+
+ const outgoingByNode = new Map<string, Array<SelectionGraphEdge>>();
+ const incomingByNode = new Map<string, Array<SelectionGraphEdge>>();
+ const addTo = (map: Map<string, Array<SelectionGraphEdge>>, key: string,
edge: SelectionGraphEdge) => {
+ const existing = map.get(key);
+
+ if (existing) {
+ existing.push(edge);
+ } else {
+ map.set(key, [edge]);
+ }
+ };
+
+ edges.forEach((edge) => {
+ addTo(outgoingByNode, edge.source, edge);
+ addTo(incomingByNode, edge.target, edge);
+ });
+
+ const selectedEdgeIds = new Set<string>();
+ const visitedForward = new Set<string>();
+ const visitedBackward = new Set<string>();
+
+ const walkForward = (gateId: string) => {
+ if (visitedForward.has(gateId)) {
+ return;
+ }
+ visitedForward.add(gateId);
+
+ (outgoingByNode.get(gateId) ?? []).forEach((edge) => {
+ selectedEdgeIds.add(edge.id);
+
+ if (gateNodeIds.has(edge.target)) {
+ walkForward(edge.target);
+ }
+ });
+ };
+
+ const walkBackward = (gateId: string) => {
+ if (visitedBackward.has(gateId)) {
+ return;
+ }
+ visitedBackward.add(gateId);
+
+ (incomingByNode.get(gateId) ?? []).forEach((edge) => {
+ selectedEdgeIds.add(edge.id);
+
+ if (gateNodeIds.has(edge.source)) {
+ walkBackward(edge.source);
+ }
+ });
+ };
+
+ nodes.forEach((node) => {
+ if (!isNodeSelected(node.id)) {
+ return;
+ }
+
+ (outgoingByNode.get(node.id) ?? []).forEach((edge) => {
+ selectedEdgeIds.add(edge.id);
+
+ if (gateNodeIds.has(edge.target)) {
+ walkForward(edge.target);
+ }
+ });
+
+ (incomingByNode.get(node.id) ?? []).forEach((edge) => {
+ selectedEdgeIds.add(edge.id);
+
+ if (gateNodeIds.has(edge.source)) {
+ walkBackward(edge.source);
+ }
+ });
+ });
+
+ return selectedEdgeIds;
+};
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
new file mode 100644
index 00000000000..b485f9420c8
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
@@ -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.
+ */
+import { afterEach, describe, expect, it } from "vitest";
+
+import { pruneLegacyDependencyKeys, SHOW_ALL_DEPENDENCIES_KEY } from
"./localStorage";
+
+describe("pruneLegacyDependencyKeys", () => {
+ afterEach(() => {
+ globalThis.localStorage.clear();
+ });
+
+ it("removes only the legacy per-Dag dependency keys, leaving everything else
intact", () => {
+ globalThis.localStorage.setItem("dependencies-my_dag", "all");
+ globalThis.localStorage.setItem("dependencies-other_dag", "tasks");
+ globalThis.localStorage.setItem(SHOW_ALL_DEPENDENCIES_KEY, "true");
+ globalThis.localStorage.setItem("direction-my_dag", "RIGHT");
+
+ pruneLegacyDependencyKeys();
+
+ expect(globalThis.localStorage.getItem("dependencies-my_dag")).toBeNull();
+
expect(globalThis.localStorage.getItem("dependencies-other_dag")).toBeNull();
+ // The global toggle and unrelated Dag-scoped keys are untouched.
+
expect(globalThis.localStorage.getItem(SHOW_ALL_DEPENDENCIES_KEY)).toBe("true");
+ expect(globalThis.localStorage.getItem("direction-my_dag")).toBe("RIGHT");
+ });
+
+ it("is a no-op when there are no legacy keys", () => {
+ globalThis.localStorage.setItem(SHOW_ALL_DEPENDENCIES_KEY, "false");
+
+ pruneLegacyDependencyKeys();
+
+
expect(globalThis.localStorage.getItem(SHOW_ALL_DEPENDENCIES_KEY)).toBe("false");
+ expect(globalThis.localStorage.length).toBe(1);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 72ff5b8327b..9e366796fd6 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -28,10 +28,10 @@ export const LOG_SHOW_TIMESTAMP_KEY = "log_show_timestamp";
export const LOG_SHOW_SOURCE_KEY = "log_show_source";
export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mode";
export const COLLAPSED_UI_ALERTS_KEY = "collapsed_ui_alerts";
+export const SHOW_ALL_DEPENDENCIES_KEY = "show_all_dependencies";
// Dag-scoped keys
export const dagRunsLimitKey = (dagId: string) => `dag_runs_limit-${dagId}`;
-export const dependenciesKey = (dagId: string) => `dependencies-${dagId}`;
export const directionKey = (dagId: string) => `direction-${dagId}`;
export const openGroupsKey = (dagId: string) => `${dagId}/open-groups`;
export const allGroupsKey = (dagId: string) => `${dagId}/all-groups`;
@@ -45,3 +45,20 @@ export const presetFiltersDefaultKey = (pageName: string) =>
// SearchBar advanced (substring) toggle, scoped per searchbar via a
caller-provided id.
export const advancedSearchKey = (id: string) => `advanced_search-${id}`;
+
+// One-time cleanup of the pre-consolidation per-Dag dependency toggle keys
+// (`dependencies-<dag_id>`), now superseded by the global
SHOW_ALL_DEPENDENCIES_KEY. Without this
+// they linger in every user's localStorage forever. Safe because no current
key shares the prefix.
+export const pruneLegacyDependencyKeys = (storage: Storage =
globalThis.localStorage): void => {
+ const staleKeys: Array<string> = [];
+
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index);
+
+ if (key?.startsWith("dependencies-")) {
+ staleKeys.push(key);
+ }
+ }
+
+ staleKeys.forEach((key) => storage.removeItem(key));
+};
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index cf1174b881e..71f2b763e8a 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -37,7 +37,6 @@ export enum SearchParamsKeys {
DEADLINE_TIME_GTE = "deadline_time_gte",
DEADLINE_TIME_LTE = "deadline_time_lte",
DEADLINE_TIME_RANGE = "deadline_time_range",
- DEPENDENCIES = "dependencies",
DURATION_GTE = "duration_gte",
DURATION_LTE = "duration_lte",
END_DATE = "end_date",
diff --git a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
index 2f486f15a2c..f467009f05c 100644
--- a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
@@ -21,7 +21,7 @@ import { useDebouncedCallback } from "use-debounce";
import { useLocalStorage } from "usehooks-ts";
import { useStructureServiceStructureData } from "openapi/queries";
-import { allGroupsKey, dependenciesKey, openGroupsKey } from
"src/constants/localStorage";
+import { allGroupsKey, openGroupsKey } from "src/constants/localStorage";
import useSelectedVersion from "src/hooks/useSelectedVersion";
import { flattenGraphNodes } from "src/layouts/Details/Grid/utils";
@@ -42,12 +42,10 @@ export const GroupsProvider = ({ children, dagId }: Props)
=> {
}, [allGroupIds]);
const selectedVersion = useSelectedVersion();
- const [dependencies] = useLocalStorage<"all" | "immediate" |
"tasks">(dependenciesKey(dagId), "tasks");
const { data: structure = { edges: [], nodes: [] } } =
useStructureServiceStructureData(
{
dagId,
- externalDependencies: dependencies === "immediate",
versionNumber: selectedVersion,
},
undefined,
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index 8c2ff657453..73d92d89bfc 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -27,9 +27,9 @@ import { useDagRunServiceGetDagRun,
useStructureServiceStructureData } from "ope
import type { Direction } from "src/components/Graph/DirectionDropdown";
import { DownloadButton } from "src/components/Graph/DownloadButton";
import { edgeTypes, nodeTypes } from "src/components/Graph/graphTypes";
-import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
+import { getGatePathEdgeIdsForSelection, type CustomNodeProps } from
"src/components/Graph/reactflowUtils";
import { useGraphLayout } from "src/components/Graph/useGraphLayout";
-import { dependenciesKey, directionKey } from "src/constants/localStorage";
+import { SHOW_ALL_DEPENDENCIES_KEY, directionKey } from
"src/constants/localStorage";
import { useColorMode } from "src/context/colorMode";
import { useGroups } from "src/context/groups";
import useSelectedVersion from "src/hooks/useSelectedVersion";
@@ -70,7 +70,7 @@ export const Graph = () => {
const { allGroupIds, openGroupIds, setAllGroupIds } = useGroups();
- const [dependencies] = useLocalStorage<"all" | "immediate" |
"tasks">(dependenciesKey(dagId), "tasks");
+ const [showAllDependencies] =
useLocalStorage<boolean>(SHOW_ALL_DEPENDENCIES_KEY, false);
const [direction] = useLocalStorage<Direction>(directionKey(dagId), "RIGHT");
const selectedColor = colorMode === "dark" ? selectedDarkColor :
selectedLightColor;
@@ -78,7 +78,6 @@ export const Graph = () => {
{
dagId,
depth,
- externalDependencies: dependencies === "immediate",
includeDownstream,
includeUpstream,
root: hasActiveFilter && filterRoot !== undefined ? filterRoot :
undefined,
@@ -100,17 +99,17 @@ export const Graph = () => {
}, [allGroupIds, graphData.nodes, setAllGroupIds]);
const { data: dagDependencies = { edges: [], nodes: [] } } =
useDependencyGraph(`dag:${dagId}`, {
- enabled: dependencies === "all",
+ enabled: showAllDependencies,
});
- const dagDepEdges = dependencies === "all" ? dagDependencies.edges : [];
- const dagDepNodes = dependencies === "all" ? dagDependencies.nodes : [];
+ const dagDepEdges = showAllDependencies ? dagDependencies.edges : [];
+ const dagDepNodes = showAllDependencies ? dagDependencies.nodes : [];
const layoutEdges = [...graphData.edges, ...dagDepEdges];
const layoutNodes = dagDepNodes.length
? dagDepNodes.map((node) => (node.id === `dag:${dagId}` ? { ...node,
children: graphData.nodes } : node))
: graphData.nodes;
- const layoutOpenGroupIds = [...openGroupIds, ...(dependencies === "all" ?
[`dag:${dagId}`] : [])];
+ const layoutOpenGroupIds = [...openGroupIds, ...(showAllDependencies ?
[`dag:${dagId}`] : [])];
const { data, isPending } = useGraphLayout({
direction,
@@ -131,6 +130,9 @@ export const Graph = () => {
});
const gridTISummaries = runId ? summariesByRunId.get(runId) : undefined;
+ const isNodeSelected = (nodeId: string) =>
+ nodeId === taskId || nodeId === groupId || nodeId === `dag:${dagId}`;
+
// Add task instances to the node data but without having to recalculate how
the graph is laid out
const nodesWithTI = data?.nodes.map((node) => {
const taskInstance = gridTISummaries?.task_instances.find((ti) =>
ti.task_id === node.id);
@@ -139,7 +141,7 @@ export const Graph = () => {
...node,
data: {
...node.data,
- isSelected: node.id === taskId || node.id === groupId || node.id ===
`dag:${dagId}`,
+ isSelected: isNodeSelected(node.id),
taskInstance,
},
};
@@ -147,7 +149,7 @@ export const Graph = () => {
const baseFilteredNodes = useGraphFilteredNodes(nodesWithTI, graphFilters);
- const { edges, nodes } = useFilteredNodesAndEdges({
+ const { edges: filteredEdges, nodes } = useFilteredNodesAndEdges({
baseFilteredNodes,
dagId,
groupId,
@@ -155,6 +157,18 @@ export const Graph = () => {
taskId,
});
+ const gatePathEdgeIds = data
+ ? getGatePathEdgeIdsForSelection(data.nodes, data.edges, isNodeSelected)
+ : new Set<string>();
+ const edges =
+ gatePathEdgeIds.size > 0
+ ? filteredEdges.map((edge) =>
+ gatePathEdgeIds.has(edge.id)
+ ? { ...edge, data: { ...edge.data, rest: { ...edge.data?.rest,
isSelected: true } } }
+ : edge,
+ )
+ : filteredEdges;
+
const selectedNodeId = taskId ?? groupId;
return (
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 6109d693190..8d8fa30757d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -39,10 +39,10 @@ import { useLocalStorage } from "usehooks-ts";
import { DagVersionSelect } from "src/components/DagVersionSelect";
import { DirectionDropdown } from "src/components/Graph/DirectionDropdown";
import { GraphTaskFilters } from "src/components/GraphTaskFilters";
-import { IconButton } from "src/components/ui";
+import { IconButton, Switch } from "src/components/ui";
import { type ButtonGroupOption, ButtonGroupToggle } from
"src/components/ui/ButtonGroupToggle";
import type { DagView } from "src/constants/dagView";
-import { dependenciesKey } from "src/constants/localStorage";
+import { SHOW_ALL_DEPENDENCIES_KEY } from "src/constants/localStorage";
import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { SHORTCUTS } from "src/context/keyboardShortcuts";
import { useShortcut } from "src/hooks/useShortcut";
@@ -65,15 +65,6 @@ type Props = {
readonly showVersionIndicatorMode: VersionIndicatorOptions;
};
-const getOptions = (translate: (key: string) => string) =>
- createListCollection({
- items: [
- { label: translate("dag:panel.dependencies.options.onlyTasks"), value:
"tasks" },
- { label: translate("dag:panel.dependencies.options.externalConditions"),
value: "immediate" },
- { label: translate("dag:panel.dependencies.options.allDagDependencies"),
value: "all" },
- ],
- });
-
const getWidthBasedConfig = (width: number, enableResponsiveOptions: boolean)
=> {
const breakpoints = enableResponsiveOptions
? [
@@ -94,10 +85,6 @@ const getWidthBasedConfig = (width: number,
enableResponsiveOptions: boolean) =>
};
};
-const deps = ["all", "immediate", "tasks"];
-
-type Dependency = (typeof deps)[number];
-
export const PanelButtons = ({
dagView,
limit,
@@ -111,9 +98,9 @@ export const PanelButtons = ({
const { dagId = "", runId } = useParams();
const { fitView } = useReactFlow();
const shouldShowToggleButtons = Boolean(runId);
- const [dependencies, setDependencies, removeDependencies] =
useLocalStorage<Dependency>(
- dependenciesKey(dagId),
- "tasks",
+ const [showAllDependencies, setShowAllDependencies] =
useLocalStorage<boolean>(
+ SHOW_ALL_DEPENDENCIES_KEY,
+ false,
);
const containerRef = useRef<HTMLDivElement>(null);
const containerWidth = useContainerWidth(containerRef);
@@ -136,14 +123,6 @@ export const PanelButtons = ({
}
}, [defaultLimit, enableResponsiveOptions, limit, setLimit]);
- const handleDepsChange = (event: SelectValueChangeDetails<{ label: string;
value: Array<string> }>) => {
- if (event.value[0] === undefined || event.value[0] === "tasks" ||
!deps.includes(event.value[0])) {
- removeDependencies();
- } else {
- setDependencies(event.value[0]);
- }
- };
-
const handleFocus = (view: string) => {
if (panelGroupRef.current) {
const newLayout = view === "graph" ? [70, 30] : [30, 70];
@@ -231,35 +210,13 @@ export const PanelButtons = ({
<DagVersionSelect />
<DagRunSelect limit={limit} />
- <Select.Root
- // @ts-expect-error The expected option type is
incorrect
- collection={getOptions(translate)}
- data-testid="dependencies"
- onValueChange={handleDepsChange}
- size="sm"
- value={[dependencies]}
+ <Switch
+ checked={showAllDependencies}
+ data-testid="show-all-dependencies"
+ onCheckedChange={(details) =>
setShowAllDependencies(details.checked)}
>
- <Select.Label fontSize="xs">
- {translate("dag:panel.dependencies.label")}
- </Select.Label>
- <Select.Control>
- <Select.Trigger>
- <Select.ValueText
placeholder={translate("dag:panel.dependencies.label")} />
- </Select.Trigger>
- <Select.IndicatorGroup>
- <Select.Indicator />
- </Select.IndicatorGroup>
- </Select.Control>
- <Select.Positioner>
- <Select.Content>
- {getOptions(translate).items.map((option) => (
- <Select.Item item={option} key={option.value}>
- {option.label}
- </Select.Item>
- ))}
- </Select.Content>
- </Select.Positioner>
- </Select.Root>
+
{translate("dag:panel.dependencies.allDagDependencies")}
+ </Switch>
<DirectionDropdown graphId={dagId} />
</>
diff --git a/airflow-core/src/airflow/ui/src/main.tsx
b/airflow-core/src/airflow/ui/src/main.tsx
index e1ef1be5257..f3f8f2b0cdb 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -29,6 +29,7 @@ import * as ReactRouterDOM from "react-router-dom";
import * as ReactJSXRuntime from "react/jsx-runtime";
import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
+import { pruneLegacyDependencyKeys } from "src/constants/localStorage";
import { ChakraCustomProvider } from "src/context/ChakraCustomProvider";
import { ColorModeProvider } from "src/context/colorMode";
import { ShortcutRegistryProvider } from "src/context/keyboardShortcuts";
@@ -95,6 +96,8 @@ axios.interceptors.response.use(
},
);
+pruneLegacyDependencyKeys();
+
createRoot(document.querySelector("#root") as HTMLDivElement).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
index c0f5218e5c6..e49c4a5cea2 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
@@ -26,7 +26,7 @@ import type { AssetResponse } from
"openapi/requests/types.gen";
import type { Direction } from "src/components/Graph/DirectionDropdown";
import { DownloadButton } from "src/components/Graph/DownloadButton";
import { edgeTypes, nodeTypes } from "src/components/Graph/graphTypes";
-import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
+import { getGatePathEdgeIdsForSelection, type CustomNodeProps } from
"src/components/Graph/reactflowUtils";
import { useGraphLayout } from "src/components/Graph/useGraphLayout";
import { directionKey } from "src/constants/localStorage";
import { useColorMode } from "src/context/colorMode";
@@ -60,10 +60,16 @@ export const AssetGraph = ({
const selectedColor = colorMode === "dark" ? selectedDarkColor :
selectedLightColor;
+ const isNodeSelected = (nodeId: string) => nodeId === `asset:${assetId}`;
+
const nodes = layoutData?.nodes.map((node) =>
- node.id === `asset:${assetId}` ? { ...node, data: { ...node.data,
isSelected: true } } : node,
+ isNodeSelected(node.id) ? { ...node, data: { ...node.data, isSelected:
true } } : node,
);
+ const gatePathEdgeIds = layoutData
+ ? getGatePathEdgeIdsForSelection(layoutData.nodes, layoutData.edges,
isNodeSelected)
+ : new Set<string>();
+
const edges = (layoutData?.edges ?? []).map((edge) => ({
...edge,
data: {
@@ -71,7 +77,10 @@ export const AssetGraph = ({
rest: {
...edge.data?.rest,
edgeType: dependencyType,
- isSelected: `asset:${asset?.id}` === edge.source ||
`asset:${asset?.id}` === edge.target,
+ isSelected:
+ `asset:${asset?.id}` === edge.source ||
+ `asset:${asset?.id}` === edge.target ||
+ gatePathEdgeIds.has(edge.id),
},
},
}));
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dependencies.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dependencies.py
index eca2fa00875..3c266f856d9 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dependencies.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dependencies.py
@@ -23,6 +23,7 @@ import pytest
from sqlalchemy import select
from airflow.models.asset import AssetModel
+from airflow.models.dag import DagModel
from airflow.models.dagbundle import DagBundleModel
from airflow.models.team import Team
from airflow.providers.standard.operators.empty import EmptyOperator
@@ -116,42 +117,49 @@ def expected_primary_component_response(asset1_id):
"label": "downstream",
"type": "dag",
"team": None,
- },
- {
- "id": f"asset:{asset1_id}",
- "label": "asset1",
- "type": "asset",
- "team": None,
+ "asset_condition_type": None,
},
{
"id": "sensor:other_dag:downstream:external_task_sensor",
"label": "external_task_sensor",
"type": "sensor",
"team": None,
+ "asset_condition_type": None,
},
{
"id": "dag:external_trigger_dag_id",
"label": "external_trigger_dag_id",
"type": "dag",
"team": None,
+ "asset_condition_type": None,
},
{
"id":
"trigger:external_trigger_dag_id:downstream:trigger_dag_run_operator",
"label": "trigger_dag_run_operator",
"type": "trigger",
"team": None,
+ "asset_condition_type": None,
},
{
"id": "dag:upstream",
"label": "upstream",
"type": "dag",
"team": None,
+ "asset_condition_type": None,
+ },
+ {
+ "id": f"asset:{asset1_id}",
+ "label": "asset1",
+ "type": "asset",
+ "team": None,
+ "asset_condition_type": None,
},
{
"id": "dag:other_dag",
"label": "other_dag",
"type": "dag",
"team": None,
+ "asset_condition_type": None,
},
],
}
@@ -199,22 +207,25 @@ def expected_secondary_component_response(asset2_id):
],
"nodes": [
{
- "id": "dag:downstream_secondary",
- "label": "downstream_secondary",
+ "id": "dag:upstream_secondary",
+ "label": "upstream_secondary",
"type": "dag",
"team": None,
+ "asset_condition_type": None,
},
{
"id": f"asset:{asset2_id}",
"label": "asset2",
"type": "asset",
"team": None,
+ "asset_condition_type": None,
},
{
- "id": "dag:upstream_secondary",
- "label": "upstream_secondary",
+ "id": "dag:downstream_secondary",
+ "label": "downstream_secondary",
"type": "dag",
"team": None,
+ "asset_condition_type": None,
},
],
}
@@ -223,7 +234,7 @@ def expected_secondary_component_response(asset2_id):
class TestGetDependencies:
@pytest.mark.usefixtures("make_primary_connected_component")
def test_should_response_200(self, test_client,
expected_primary_component_response):
- with assert_queries_count(7):
+ with assert_queries_count(8):
response = test_client.get("/dependencies")
assert response.status_code == 200
@@ -259,7 +270,7 @@ class TestGetDependencies:
@pytest.mark.usefixtures("make_primary_connected_component",
"make_secondary_connected_component")
def test_with_node_id_filter(self, test_client, node_id,
expected_response_fixture, request):
expected_response = request.getfixturevalue(expected_response_fixture)
- with assert_queries_count(7):
+ with assert_queries_count(8):
response = test_client.get("/dependencies", params={"node_id":
node_id})
assert response.status_code == 200
@@ -277,7 +288,7 @@ class TestGetDependencies:
(asset1_id, expected_primary_component_response),
(asset2_id, expected_secondary_component_response),
):
- with assert_queries_count(7):
+ with assert_queries_count(8):
response = test_client.get("/dependencies", params={"node_id":
f"asset:{asset_id}"})
assert response.status_code == 200
@@ -581,3 +592,330 @@ class TestGetDependencies:
)
actual_edges = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
assert expected_edge in actual_edges
+
+ def test_scheduling_dependencies_expands_asset_and_gate(self, dag_maker,
test_client, session):
+ """A Dag scheduled by multiple ANDed assets renders an and-gate node
instead of flat asset edges."""
+ asset_a = Asset(uri="s3://gate-bucket/a", name="gate_asset_a")
+ asset_b = Asset(uri="s3://gate-bucket/b", name="gate_asset_b")
+
+ with dag_maker(dag_id="gate_upstream", serialized=True,
session=session):
+ EmptyOperator(task_id="produce", outlets=[asset_a, asset_b])
+
+ with dag_maker(
+ dag_id="gate_downstream",
+ schedule=(asset_a & asset_b),
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="consume")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name == "gate_asset_a"))
+ asset_b_id =
session.scalar(select(AssetModel.id).where(AssetModel.name == "gate_asset_b"))
+
+ response = test_client.get("/dependencies", params={"node_id":
"dag:gate_downstream"})
+ assert response.status_code == 200
+
+ result = response.json()
+ gate_node = next(node for node in result["nodes"] if node["type"] ==
"asset-condition")
+ assert gate_node["asset_condition_type"] == "and-gate"
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ assert (gate_node["id"], "dag:gate_downstream") in edge_tuples
+ assert (f"asset:{asset_a_id}", gate_node["id"]) in edge_tuples
+ assert (f"asset:{asset_b_id}", gate_node["id"]) in edge_tuples
+
+ # The flat asset -> dag edge is replaced by the gate, not duplicated
alongside it.
+ assert (f"asset:{asset_a_id}", "dag:gate_downstream") not in
edge_tuples
+ assert (f"asset:{asset_b_id}", "dag:gate_downstream") not in
edge_tuples
+
+ # The producer side (unrelated to the consumer's gate) is untouched.
+ assert ("dag:gate_upstream", f"asset:{asset_a_id}") in edge_tuples
+ assert ("dag:gate_upstream", f"asset:{asset_b_id}") in edge_tuples
+
+ def test_scheduling_dependencies_expands_nested_boolean_groups(self,
dag_maker, test_client, session):
+ """`(a & b) | (c & d)` renders both and-gates under the or-gate -- no
sibling branch dropped."""
+ asset_a = Asset(uri="s3://nested-bucket/a", name="nested_asset_a")
+ asset_b = Asset(uri="s3://nested-bucket/b", name="nested_asset_b")
+ asset_c = Asset(uri="s3://nested-bucket/c", name="nested_asset_c")
+ asset_d = Asset(uri="s3://nested-bucket/d", name="nested_asset_d")
+
+ with dag_maker(
+ dag_id="nested_downstream",
+ schedule=((asset_a & asset_b) | (asset_c & asset_d)),
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="consume")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_ids = {
+ name: session.scalar(select(AssetModel.id).where(AssetModel.name
== name))
+ for name in ("nested_asset_a", "nested_asset_b", "nested_asset_c",
"nested_asset_d")
+ }
+
+ response = test_client.get("/dependencies", params={"node_id":
"dag:nested_downstream"})
+ assert response.status_code == 200
+
+ result = response.json()
+ gates = [node for node in result["nodes"] if node["type"] ==
"asset-condition"]
+ or_gates = [g for g in gates if g["asset_condition_type"] == "or-gate"]
+ and_gates = [g for g in gates if g["asset_condition_type"] ==
"and-gate"]
+ # Both nested and-groups survive: one or-gate, two distinct and-gates.
+ assert len(or_gates) == 1
+ assert len(and_gates) == 2
+ assert and_gates[0]["id"] != and_gates[1]["id"]
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ or_gate_id = or_gates[0]["id"]
+ assert (or_gate_id, "dag:nested_downstream") in edge_tuples
+ # Each and-gate feeds the or-gate, and each is fed by exactly its two
assets.
+ for and_gate in and_gates:
+ assert (and_gate["id"], or_gate_id) in edge_tuples
+ feeders = {src for src, tgt in edge_tuples if tgt ==
and_gate["id"]}
+ assert feeders in (
+ {f"asset:{asset_ids['nested_asset_a']}",
f"asset:{asset_ids['nested_asset_b']}"},
+ {f"asset:{asset_ids['nested_asset_c']}",
f"asset:{asset_ids['nested_asset_d']}"},
+ )
+ # All four assets are represented, none dropped.
+ all_feeders = {src for src, tgt in edge_tuples if tgt in {g["id"] for
g in and_gates}}
+ assert all_feeders == {f"asset:{aid}" for aid in asset_ids.values()}
+
+ def test_scheduling_dependencies_expands_gate_with_unresolved_asset_ref(
+ self, dag_maker, test_client, session
+ ):
+ """An AND/OR condition mixing a resolved asset with an unresolved
Asset.ref(...) must not 500.
+
+ Asset.ref(...) serializes its half of the expression as {"asset_ref":
{"name": ...}},
+ distinct from the {"asset-name-ref": ...} shape the rest of
get_upstream_assets expects.
+ """
+ asset_a = Asset(uri="s3://gate-bucket/ref-a", name="gate_asset_ref_a")
+
+ with dag_maker(
+ dag_id="gate_downstream_with_ref",
+ schedule=(asset_a & Asset.ref(name="not_yet_registered_asset")),
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="consume")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"gate_asset_ref_a"))
+
+ response = test_client.get("/dependencies", params={"node_id":
"dag:gate_downstream_with_ref"})
+ assert response.status_code == 200
+
+ result = response.json()
+ nodes_by_id = {node["id"]: node for node in result["nodes"]}
+ assert nodes_by_id["asset-name-ref:not_yet_registered_asset"]["type"]
== "asset-name-ref"
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ gate_node_id = next(node["id"] for node in result["nodes"] if
node["type"] == "asset-condition")
+ assert (f"asset:{asset_a_id}", gate_node_id) in edge_tuples
+ assert ("asset-name-ref:not_yet_registered_asset", gate_node_id) in
edge_tuples
+
+ def test_scheduling_dependencies_survives_unrecognized_asset_expression(
+ self, dag_maker, test_client, session
+ ):
+ """A Dag whose asset_expression get_upstream_assets can't parse must
not break the whole endpoint.
+
+ The gate expansion falls back to that one Dag's flat asset edge
instead of 500ing for
+ every caller.
+ """
+ asset_a = Asset(uri="s3://gate-bucket/broken",
name="gate_asset_broken")
+
+ with dag_maker(
+ dag_id="gate_downstream_broken",
+ schedule=[asset_a],
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="consume")
+
+ dag_maker.sync_dagbag_to_db()
+
+ dag_model = session.scalar(select(DagModel).where(DagModel.dag_id ==
"gate_downstream_broken"))
+ dag_model.asset_expression = {"any": [{"weird-op": {}}]}
+ session.commit()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"gate_asset_broken"))
+
+ response = test_client.get("/dependencies", params={"node_id":
"dag:gate_downstream_broken"})
+ assert response.status_code == 200
+
+ result = response.json()
+ assert not any(node["type"] == "asset-condition" for node in
result["nodes"])
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ assert (f"asset:{asset_a_id}", "dag:gate_downstream_broken") in
edge_tuples
+
+ def
test_data_dependencies_routes_through_dag_level_schedule_without_task_inlet(
+ self, dag_maker, test_client, session
+ ):
+ """An asset that schedules a Dag via `schedule=`, with no task
declaring it as an inlet,
+ must still connect to that Dag's entry task in the data-dependencies
(Task Dependencies) graph.
+
+ Uses the list form (`schedule=[asset_a]`), since that's the common way
a single-asset
+ schedule is written -- and, like the bare-asset form, it must not
render a redundant
+ single-input AND/OR gate.
+ """
+ asset_a = Asset(uri="s3://data-dep-bucket/a", name="data_dep_asset_a")
+
+ with dag_maker(
+ dag_id="data_dep_downstream",
+ schedule=[asset_a],
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="entry_task")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"data_dep_asset_a"))
+
+ response = test_client.get(
+ "/dependencies", params={"node_id": f"asset:{asset_a_id}",
"dependency_type": "data"}
+ )
+ assert response.status_code == 200
+
+ result = response.json()
+ entry_task_node_id = "task:data_dep_downstream__SEPARATOR__entry_task"
+ nodes_by_id = {node["id"]: node for node in result["nodes"]}
+ assert nodes_by_id[entry_task_node_id]["type"] == "task"
+ assert not any(node["type"] == "asset-condition" for node in
result["nodes"])
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ assert (f"asset:{asset_a_id}", entry_task_node_id) in edge_tuples
+
+ def test_data_dependencies_routes_through_asset_condition_gate(self,
dag_maker, test_client, session):
+ """When the scheduling Dag's condition combines multiple assets, the
data-dependencies graph
+ must show the gate (not a misleading direct edge from just one of the
assets).
+ """
+ asset_a = Asset(uri="s3://data-dep-bucket/gate-a",
name="data_dep_gate_asset_a")
+ asset_b = Asset(uri="s3://data-dep-bucket/gate-b",
name="data_dep_gate_asset_b")
+
+ with dag_maker(dag_id="data_dep_gate_upstream", serialized=True,
session=session):
+ EmptyOperator(task_id="produce_b", outlets=[asset_b])
+
+ with dag_maker(
+ dag_id="data_dep_gate_downstream",
+ schedule=(asset_a & asset_b),
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="entry_task")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"data_dep_gate_asset_a"))
+ asset_b_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"data_dep_gate_asset_b"))
+
+ response = test_client.get(
+ "/dependencies", params={"node_id": f"asset:{asset_a_id}",
"dependency_type": "data"}
+ )
+ assert response.status_code == 200
+
+ result = response.json()
+ entry_task_node_id =
"task:data_dep_gate_downstream__SEPARATOR__entry_task"
+ gate_node = next(node for node in result["nodes"] if node["type"] ==
"asset-condition")
+ assert gate_node["asset_condition_type"] == "and-gate"
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ assert (gate_node["id"], entry_task_node_id) in edge_tuples
+ assert (f"asset:{asset_a_id}", gate_node["id"]) in edge_tuples
+ assert (f"asset:{asset_b_id}", gate_node["id"]) in edge_tuples
+ # No misleading direct edge bypassing the gate.
+ assert (f"asset:{asset_a_id}", entry_task_node_id) not in edge_tuples
+
+ # BFS continues through the sibling asset in the gate to find its
producer.
+ producer_task_node_id =
"task:data_dep_gate_upstream__SEPARATOR__produce_b"
+ assert (producer_task_node_id, f"asset:{asset_b_id}") in edge_tuples
+
+ def
test_data_dependencies_batches_entry_point_resolution_across_scheduled_dags(
+ self, dag_maker, test_client, session
+ ):
+ """Resolving each scheduled Dag's entry task must not cost one query
(and one full Dag
+ deserialization) per Dag: the query count must stay flat as the number
of Dags an asset
+ schedules grows, not scale linearly with it.
+ """
+ asset_a = Asset(uri="s3://data-dep-bucket/batch-a",
name="data_dep_batch_asset_a")
+ dag_ids = [f"data_dep_batch_downstream_{i}" for i in range(3)]
+
+ for dag_id in dag_ids:
+ with dag_maker(
+ dag_id=dag_id,
+ schedule=[asset_a],
+ serialized=True,
+ session=session,
+ ):
+ EmptyOperator(task_id="entry_task")
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_a_id =
session.scalar(select(AssetModel.id).where(AssetModel.name ==
"data_dep_batch_asset_a"))
+
+ with assert_queries_count(12):
+ response = test_client.get(
+ "/dependencies", params={"node_id": f"asset:{asset_a_id}",
"dependency_type": "data"}
+ )
+ assert response.status_code == 200
+
+ result = response.json()
+ nodes_by_id = {node["id"]: node for node in result["nodes"]}
+ for dag_id in dag_ids:
+ entry_task_node_id = f"task:{dag_id}__SEPARATOR__entry_task"
+ assert nodes_by_id[entry_task_node_id]["type"] == "task"
+
+ def test_data_dependencies_attributes_alias_produced_asset_to_source_task(
+ self, dag_maker, test_client, session
+ ):
+ """An asset produced via an AssetAlias has no static outlet reference,
so its producing
+ task must be recovered from AssetEvent -- otherwise the consumer sees
the asset with no
+ producer. The asset is made visible through a consuming task (the
realistic case; an asset
+ with no reference at all is hidden by the readable-dags gate)."""
+ from airflow.models.asset import AssetAliasModel, AssetEvent
+ from airflow.sdk.definitions.asset import AssetAlias
+
+ alias_asset = Asset(uri="s3://alias/resolved",
name="alias_resolved_asset")
+
+ with dag_maker(dag_id="alias_producer", serialized=True,
session=session):
+ EmptyOperator(task_id="produce_via_alias",
outlets=[AssetAlias("resolving_alias")])
+ dr = dag_maker.create_dagrun()
+
+ with dag_maker(dag_id="alias_consumer", serialized=True,
session=session):
+ EmptyOperator(task_id="consume", inlets=[alias_asset])
+
+ dag_maker.sync_dagbag_to_db()
+
+ asset_id = session.scalar(select(AssetModel.id).where(AssetModel.name
== "alias_resolved_asset"))
+ asset_alias =
session.scalar(select(AssetAliasModel).where(AssetAliasModel.name ==
"resolving_alias"))
+ asset_alias.assets.append(session.get(AssetModel, asset_id))
+ asset_alias.asset_events.append(
+ AssetEvent(
+ timestamp=pendulum.datetime(2024, 1, 1, tz="UTC"),
+ asset_id=asset_id,
+ source_dag_id="alias_producer",
+ source_task_id="produce_via_alias",
+ source_run_id=dr.run_id,
+ source_map_index=-1,
+ )
+ )
+ session.commit()
+
+ response = test_client.get(
+ "/dependencies", params={"node_id": f"asset:{asset_id}",
"dependency_type": "data"}
+ )
+ assert response.status_code == 200
+
+ result = response.json()
+ producer_task_node_id =
"task:alias_producer__SEPARATOR__produce_via_alias"
+ consumer_task_node_id = "task:alias_consumer__SEPARATOR__consume"
+ nodes_by_id = {node["id"]: node for node in result["nodes"]}
+ assert nodes_by_id[producer_task_node_id]["type"] == "task"
+
+ edge_tuples = {(edge["source_id"], edge["target_id"]) for edge in
result["edges"]}
+ # Producer edge recovered from the alias event, plus the consumer edge
that made it visible.
+ assert (producer_task_node_id, f"asset:{asset_id}") in edge_tuples
+ assert (f"asset:{asset_id}", consumer_task_node_id) in edge_tuples
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
index 6aaff421ee3..61b2eed0b3d 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
@@ -18,21 +18,13 @@
from __future__ import annotations
import copy
-from unittest import mock
import pendulum
import pytest
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-from airflow._shared.timezones import timezone
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
from airflow.models.dagbag import DBDagBag
from airflow.providers.standard.operators.empty import EmptyOperator
-from airflow.providers.standard.operators.trigger_dagrun import
TriggerDagRunOperator
from airflow.providers.standard.sensors.external_task import ExternalTaskSensor
-from airflow.sdk import Metadata, task
-from airflow.sdk.definitions.asset import Asset, AssetAlias, Dataset
from airflow.sdk.definitions.taskgroup import TaskGroup
from tests_common.test_utils.asserts import assert_queries_count
@@ -41,8 +33,6 @@ from tests_common.test_utils.db import clear_db_assets,
clear_db_runs
pytestmark = pytest.mark.db_test
DAG_ID = "dag_with_multiple_versions"
-DAG_ID_EXTERNAL_TRIGGER = "external_trigger"
-DAG_ID_RESOLVED_ASSET_ALIAS = "dag_with_resolved_asset_alias"
DAG_ID_LINEAR_DEPTH = "linear_depth_dag"
DAG_ID_NONLINEAR_DEPTH = "nonlinear_depth_dag"
LATEST_VERSION_DAG_RESPONSE: dict = {
@@ -117,84 +107,20 @@ def clean():
@pytest.fixture
-def asset1() -> Asset:
- return Asset(uri="s3://bucket/next-run-asset/1", name="asset1")
-
-
[email protected]
-def asset2() -> Asset:
- return Asset(uri="s3://bucket/next-run-asset/2", name="asset2")
-
-
[email protected]
-def asset3() -> Dataset:
- return Dataset(uri="s3://dataset-bucket/example.csv")
-
-
[email protected]
-def make_dags(dag_maker, session, time_machine, asset1: Asset, asset2: Asset,
asset3: Dataset) -> None:
- with dag_maker(
- dag_id=DAG_ID_EXTERNAL_TRIGGER,
- serialized=True,
- session=session,
- start_date=pendulum.DateTime(2023, 2, 1, 0, 0, 0, tzinfo=pendulum.UTC),
- ):
- TriggerDagRunOperator(task_id="trigger_dag_run_operator",
trigger_dag_id=DAG_ID)
- dag_maker.sync_dagbag_to_db()
-
+def make_dags(dag_maker, session, time_machine) -> None:
with dag_maker(
dag_id=DAG_ID,
serialized=True,
session=session,
start_date=pendulum.DateTime(2023, 2, 1, 0, 0, 0, tzinfo=pendulum.UTC),
- schedule=(asset1 & asset2 & AssetAlias("example-alias")),
):
(
- EmptyOperator(task_id="task_1", outlets=[asset3])
+ EmptyOperator(task_id="task_1")
>> ExternalTaskSensor(task_id="external_task_sensor",
external_dag_id=DAG_ID)
>> EmptyOperator(task_id="task_2")
)
dag_maker.sync_dagbag_to_db()
- with dag_maker(
- dag_id=DAG_ID_RESOLVED_ASSET_ALIAS,
- serialized=True,
- session=session,
- start_date=pendulum.DateTime(2023, 2, 1, 0, 0, 0, tzinfo=pendulum.UTC),
- ):
-
- @task(outlets=[AssetAlias("example-alias-resolved")])
- def task_1(**context):
- yield Metadata(
- asset=Asset("resolved_example_asset_alias"),
- extra={"k": "v"}, # extra has to be provided, can be {}
- alias=AssetAlias("example-alias-resolved"),
- )
-
- task_1() >> EmptyOperator(task_id="task_2")
-
- dr = dag_maker.create_dagrun()
- asset_alias = session.scalar(
- select(AssetAliasModel).where(AssetAliasModel.name ==
"example-alias-resolved")
- )
- asset_model = AssetModel(name="resolved_example_asset_alias")
- session.add(asset_model)
- session.flush()
- asset_alias.assets.append(asset_model)
- asset_alias.asset_events.append(
- AssetEvent(
- id=1,
- timestamp=timezone.parse("2021-01-01T00:00:00"),
- asset_id=asset_model.id,
- source_dag_id=DAG_ID_RESOLVED_ASSET_ALIAS,
- source_task_id="task_1",
- source_run_id=dr.run_id,
- source_map_index=-1,
- )
- )
- session.commit()
- dag_maker.sync_dagbag_to_db()
-
# Linear DAG with 5 tasks for depth testing
with dag_maker(
dag_id=DAG_ID_LINEAR_DEPTH,
@@ -232,29 +158,6 @@ def make_dags(dag_maker, session, time_machine, asset1:
Asset, asset2: Asset, as
dag_maker.sync_dagbag_to_db()
-def _fetch_asset_id(asset: Asset, session: Session) -> str:
- return str(
- session.scalar(
- select(AssetModel.id).where(AssetModel.name == asset.name,
AssetModel.uri == asset.uri)
- )
- )
-
-
[email protected]
-def asset1_id(make_dags, asset1, session: Session) -> str:
- return _fetch_asset_id(asset1, session)
-
-
[email protected]
-def asset2_id(make_dags, asset2, session) -> str:
- return _fetch_asset_id(asset2, session)
-
-
[email protected]
-def asset3_id(make_dags, asset3, session) -> str:
- return _fetch_asset_id(asset3, session)
-
-
class TestStructureDataEndpoint:
@pytest.mark.parametrize(
("params", "expected", "expected_queries_count"),
@@ -265,14 +168,12 @@ class TestStructureDataEndpoint:
"edges": [
{
"is_setup_teardown": None,
- "is_source_asset": None,
"label": None,
"source_id": "external_task_sensor",
"target_id": "task_2",
},
{
"is_setup_teardown": None,
- "is_source_asset": None,
"label": None,
"source_id": "task_1",
"target_id": "external_task_sensor",
@@ -361,51 +262,6 @@ class TestStructureDataEndpoint:
},
7,
),
- (
- {"dag_id": DAG_ID_EXTERNAL_TRIGGER, "external_dependencies":
True},
- {
- "edges": [
- {
- "is_source_asset": None,
- "is_setup_teardown": None,
- "label": None,
- "source_id": "trigger_dag_run_operator",
- "target_id":
"trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator",
- }
- ],
- "nodes": [
- {
- "asset_condition_type": None,
- "ui_color": "#ffefeb",
- "ui_fgcolor": "#000",
- "children": None,
- "id": "trigger_dag_run_operator",
- "is_mapped": None,
- "label": "trigger_dag_run_operator",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "task",
- "team": None,
- "operator": "TriggerDagRunOperator",
- },
- {
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- "children": None,
- "id":
"trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator",
- "is_mapped": None,
- "label": "trigger_dag_run_operator",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "trigger",
- "team": None,
- "operator": None,
- },
- ],
- },
- 14,
- ),
],
)
@pytest.mark.usefixtures("make_dags")
@@ -415,305 +271,6 @@ class TestStructureDataEndpoint:
assert response.status_code == 200
assert response.json() == expected
- @pytest.mark.usefixtures("make_dags")
- def test_should_return_200_with_asset(self, test_client, asset1_id,
asset2_id, asset3_id):
- params = {
- "dag_id": DAG_ID,
- "external_dependencies": True,
- }
- expected = {
- "edges": [
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": "external_task_sensor",
- "target_id": "task_2",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": "task_1",
- "target_id": "external_task_sensor",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": "and-gate-0",
- "target_id": "task_1",
- "is_source_asset": True,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": asset1_id,
- "target_id": "and-gate-0",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": asset2_id,
- "target_id": "and-gate-0",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": "example-alias",
- "target_id": "and-gate-0",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id":
"sensor:dag_with_multiple_versions:dag_with_multiple_versions:external_task_sensor",
- "target_id": "task_1",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id":
"trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator",
- "target_id": "task_1",
- "is_source_asset": None,
- },
- {
- "is_setup_teardown": None,
- "label": None,
- "source_id": "task_1",
- "target_id": f"asset:{asset3_id}",
- "is_source_asset": None,
- },
- ],
- "nodes": [
- {
- "children": None,
- "id": "task_1",
- "is_mapped": None,
- "label": "task_1",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "task",
- "team": None,
- "operator": "EmptyOperator",
- "asset_condition_type": None,
- "ui_color": "#e8f7e4",
- "ui_fgcolor": "#000",
- },
- {
- "children": None,
- "id": "external_task_sensor",
- "is_mapped": None,
- "label": "external_task_sensor",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "task",
- "team": None,
- "operator": "ExternalTaskSensor",
- "asset_condition_type": None,
- "ui_color": "#4db7db",
- "ui_fgcolor": "#000",
- },
- {
- "children": None,
- "id": "task_2",
- "is_mapped": None,
- "label": "task_2",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "task",
- "team": None,
- "operator": "EmptyOperator",
- "asset_condition_type": None,
- "ui_color": "#e8f7e4",
- "ui_fgcolor": "#000",
- },
- {
- "children": None,
- "id": f"asset:{asset3_id}",
- "is_mapped": None,
- "label": "s3://dataset-bucket/example.csv",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "asset",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id":
"sensor:dag_with_multiple_versions:dag_with_multiple_versions:external_task_sensor",
- "is_mapped": None,
- "label": "external_task_sensor",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "sensor",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id":
"trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator",
- "is_mapped": None,
- "label": "trigger_dag_run_operator",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "trigger",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id": "and-gate-0",
- "is_mapped": None,
- "label": "and-gate-0",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "asset-condition",
- "team": None,
- "operator": None,
- "asset_condition_type": "and-gate",
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id": asset1_id,
- "is_mapped": None,
- "label": "asset1",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "asset",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id": asset2_id,
- "is_mapped": None,
- "label": "asset2",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "asset",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- {
- "children": None,
- "id": "example-alias",
- "is_mapped": None,
- "label": "example-alias",
- "tooltip": None,
- "setup_teardown_type": None,
- "type": "asset-alias",
- "team": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- ],
- }
-
- with assert_queries_count(14):
- response = test_client.get("/structure/structure_data",
params=params)
- assert response.status_code == 200
- assert response.json() == expected
-
- @pytest.mark.usefixtures("make_dags")
- def
test_should_return_200_with_resolved_asset_alias_attached_to_the_corrrect_producing_task(
- self, test_client, session
- ):
- resolved_asset = session.scalar(
- select(AssetModel).where(AssetModel.name ==
"resolved_example_asset_alias")
- )
- params = {
- "dag_id": DAG_ID_RESOLVED_ASSET_ALIAS,
- "external_dependencies": True,
- }
- expected = {
- "edges": [
- {
- "source_id": "task_1",
- "target_id": "task_2",
- "is_setup_teardown": None,
- "label": None,
- "is_source_asset": None,
- },
- {
- "source_id": "task_1",
- "target_id": f"asset:{resolved_asset.id}",
- "is_setup_teardown": None,
- "label": None,
- "is_source_asset": None,
- },
- ],
- "nodes": [
- {
- "id": "task_1",
- "label": "task_1",
- "type": "task",
- "team": None,
- "children": None,
- "is_mapped": None,
- "tooltip": None,
- "setup_teardown_type": None,
- "operator": "@task",
- "asset_condition_type": None,
- "ui_color": "#ffefeb",
- "ui_fgcolor": "#000",
- },
- {
- "id": "task_2",
- "label": "task_2",
- "type": "task",
- "team": None,
- "children": None,
- "is_mapped": None,
- "tooltip": None,
- "setup_teardown_type": None,
- "operator": "EmptyOperator",
- "asset_condition_type": None,
- "ui_color": "#e8f7e4",
- "ui_fgcolor": "#000",
- },
- {
- "id": f"asset:{resolved_asset.id}",
- "label": "resolved_example_asset_alias",
- "type": "asset",
- "team": None,
- "children": None,
- "is_mapped": None,
- "tooltip": None,
- "setup_teardown_type": None,
- "operator": None,
- "asset_condition_type": None,
- "ui_color": None,
- "ui_fgcolor": None,
- },
- ],
- }
-
- response = test_client.get("/structure/structure_data", params=params)
- assert response.status_code == 200
- assert response.json() == expected
-
@pytest.mark.parametrize(
("params", "expected"),
[
@@ -753,54 +310,11 @@ class TestStructureDataEndpoint:
response = unauthorized_test_client.get("/structure/structure_data",
params={"dag_id": DAG_ID})
assert response.status_code == 403
- @mock.patch(
-
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids",
- return_value={DAG_ID_EXTERNAL_TRIGGER},
- )
- @pytest.mark.usefixtures("make_dags")
- def test_external_deps_filters_unreadable_dags(self, _, test_client):
- response = test_client.get(
- "/structure/structure_data",
- params={"dag_id": DAG_ID_EXTERNAL_TRIGGER,
"external_dependencies": True},
- )
- assert response.status_code == 200
- result = response.json()
- node_ids = {node["id"] for node in result["nodes"]}
- assert "trigger_dag_run_operator" in node_ids
- assert not any(DAG_ID in nid for nid in node_ids if nid !=
"trigger_dag_run_operator")
- edge_targets = {edge["target_id"] for edge in result["edges"]}
- assert not any(DAG_ID in tid for tid in edge_targets)
-
def test_should_return_404(self, test_client):
response = test_client.get("/structure/structure_data",
params={"dag_id": "not_existing"})
assert response.status_code == 404
assert response.json()["detail"] == "Dag with id not_existing was not
found"
- @pytest.mark.usefixtures("make_dags")
- def test_should_return_400_on_malformed_asset_expression(self,
test_client):
- """A TypeError from get_upstream_assets surfaces as a 400 with a clear
message.
-
- The asset_expression ultimately comes from user-authored Dag code (via
the Task SDK),
- so a malformed expression is bad input that ended up persisted -- not
a server fault.
- Without the try/except wrap, the TypeError propagates uncaught and
FastAPI returns a
- generic ``{"detail": "Internal Server Error"}`` 500 body with no
context about which
- Dag triggered it. With the wrap, the response identifies the Dag and
version, which
- is what a caller needs to fix the upstream Dag definition.
- """
- with mock.patch(
-
"airflow.api_fastapi.core_api.routes.ui.structure.get_upstream_assets",
- side_effect=TypeError("Unsupported type: dict_keys(['weird-op'])"),
- ):
- response = test_client.get(
- "/structure/structure_data",
- params={"dag_id": DAG_ID, "external_dependencies": True},
- )
- assert response.status_code == 400
- detail = response.json()["detail"]
- assert "Malformed asset_expression" in detail
- assert DAG_ID in detail
- assert "Unsupported type" in detail
-
def test_should_return_404_when_dag_version_not_found(self, test_client):
response = test_client.get(
"/structure/structure_data", params={"dag_id": DAG_ID,
"version_number": 999}