Astronomer Cosmos Version
1.15.1
dbt Core or Fusion version
1.11.13
Versions of dbt adapters
dbt-clickhouse=1.10.2
LoadMode
DBT_LS_MANIFEST
ExecutionMode
WATCHER
InvocationMode
SUBPROCESS
airflow version
2.10.5
Operating System
apache/airflow:2.10.5-python3.12
If a you think it's an UI issue, what browsers are you seeing the problem on?
No response
Deployment
Official Apache Airflow Helm Chart
Deployment details
Running Airflow and Cosmos on on-prem K8s cluster but the infrastructure is not the issue.
What happened?
When combining a select and an exclude that both use dbt graph operators (e.g. +tag:x), select_nodes() computes the exclude traversal against the node set that has already been narrowed by select, instead of the full project graph. If none of the exclude selector's root nodes (e.g. nodes tagged x) survive the select narrowing, the graph traversal has nothing to start from — so ancestors that should be excluded are silently left in, even though they genuinely are ancestors of an excluded tag in the real dbt graph.
In cosmos/dbt/selector.py, select_nodes():
subset_ids = apply_select_filter(nodes, project_dir, select)
if select:
nodes = get_nodes_from_subset(nodes, subset_ids) # <-- nodes narrowed here
exclude_ids = apply_exclude_filter(nodes, project_dir, exclude) # <-- exclude computed against narrowed nodes
subset_ids = set(nodes.keys()) - exclude_ids
apply_exclude_filter → NodeSelector.select_nodes_ids_by_intersection → GraphSelector.filter_nodes looks for "root nodes" matching the selector (e.g. nodes tagged x) within the already-narrowed nodes dict. If a root node for the exclude selector isn't part of the select result, it can never be found, so its upstream ancestors are never added to exclude_ids.
Any project using select=["+tag:X"] combined with exclude=["+tag:Y", ...] (or any other graph-operator exclude such as tag:Y+, +model_name, etc.) where Y-tagged models aren't part of the X select result will silently fail to exclude shared upstream/downstream dependencies. This is a common pattern for avoiding race conditions between DAGs that partition a shared dbt project by tag (e.g. "run everything upstream of tag A, except things also owned by/upstream of tag B, which runs on its own schedule").
The task graph in Airflow shows the leaked models as present, but there's no error or warning — it's a silent correctness bug, not a crash.
Relevant log output
How to reproduce
Given this dbt project graph:
model_a (no tags)
└── model_b (tag: team_b) # depends_on model_a
└── model_c (tag: team_a) # depends_on model_a
model_a is a shared upstream dependency of both model_b (tagged team_b) and model_c (tagged team_a).
Selector config:
select=["+tag:team_a"]
exclude=["+tag:team_b"]
Expected (matching real dbt ls/dbt build semantics, and intuitive user intent — "select team_a and its ancestors, but exclude team_b and its ancestors"):
model_c only. model_a should be excluded because it's an ancestor of model_b (tag team_b), which is in the exclude set.
Actual:
model_a and model_c are both selected. model_b correctly doesn't appear (it's not selected by +tag:team_a in the first place), but because model_b itself is absent from the select-narrowed node set, +tag:team_b in exclude has no root node to traverse upstream from — so model_a is never identified as an excluded ancestor and leaks through.
Reproduction script (against installed Cosmos)
from pathlib import Path
from cosmos.dbt.selector import select_nodes
from cosmos.dbt.graph import DbtNode
from cosmos.constants import DbtResourceType
def make_node(unique_id, tags, depends_on):
return DbtNode(
unique_id=unique_id,
resource_type=DbtResourceType.MODEL,
depends_on=depends_on,
path_base=Path("fake"),
original_file_path=Path("fake/path.sql"),
package_name="fake_pkg",
tags=tags,
config={},
)
nodes = {
"model.p.model_a": make_node("model.p.model_a", [], []),
"model.p.model_b": make_node("model.p.model_b", ["team_b"], ["model.p.model_a"]),
"model.p.model_c": make_node("model.p.model_c", ["team_a"], ["model.p.model_a"]),
}
result = select_nodes(
project_dir=None,
nodes=nodes,
select=["+tag:team_a"],
exclude=["+tag:team_b"],
)
print(sorted(result.keys()))
# Actual: ['model.p.model_a', 'model.p.model_c']
# Expected: ['model.p.model_c']
Anything else :)?
Suggested fix
Compute exclude_ids in apply_exclude_filter against the original, full nodes dict rather than the select-narrowed one, then intersect the final result with the select-narrowed set:
subset_ids = apply_select_filter(nodes, project_dir, select)
exclude_ids = apply_exclude_filter(nodes, project_dir, exclude) # full nodes, not narrowed
if select:
subset_ids = subset_ids # already computed against full nodes
final_ids = subset_ids - exclude_ids if select else set(nodes.keys()) - exclude_ids
return get_nodes_from_subset(nodes, final_ids)
(This preserves dbt's own selection semantics, where select and exclude are each evaluated independently against the full manifest graph, then combined via set difference — see apply_select_filter/apply_exclude_filter docstring references to docs.getdbt.com/reference/node-selection/syntax.)
Current workaround
Add the exclude selector's tag to select as well (in addition to keeping it in exclude), so its root nodes survive the narrowing step and the traversal can find them:
select=["+tag:team_a", "+tag:team_b"]
exclude=["+tag:team_b"]
This works but is unintuitive and easy to forget, especially as new shared-ancestor cases appear over time.
Are you willing to submit PR?
Contact Details
jesper.bagge@axis.com
Astronomer Cosmos Version
1.15.1
dbt Core or Fusion version
1.11.13
Versions of dbt adapters
dbt-clickhouse=1.10.2
LoadMode
DBT_LS_MANIFEST
ExecutionMode
WATCHER
InvocationMode
SUBPROCESS
airflow version
2.10.5
Operating System
apache/airflow:2.10.5-python3.12
If a you think it's an UI issue, what browsers are you seeing the problem on?
No response
Deployment
Official Apache Airflow Helm Chart
Deployment details
Running Airflow and Cosmos on on-prem K8s cluster but the infrastructure is not the issue.
What happened?
When combining a
selectand anexcludethat both use dbt graph operators (e.g.+tag:x),select_nodes()computes theexcludetraversal against the node set that has already been narrowed byselect, instead of the full project graph. If none of theexcludeselector's root nodes (e.g. nodes taggedx) survive theselectnarrowing, the graph traversal has nothing to start from — so ancestors that should be excluded are silently left in, even though they genuinely are ancestors of an excluded tag in the real dbt graph.In
cosmos/dbt/selector.py,select_nodes():apply_exclude_filter→NodeSelector.select_nodes_ids_by_intersection→GraphSelector.filter_nodeslooks for "root nodes" matching the selector (e.g. nodes taggedx) within the already-narrowednodesdict. If a root node for the exclude selector isn't part of the select result, it can never be found, so its upstream ancestors are never added toexclude_ids.Any project using
select=["+tag:X"]combined withexclude=["+tag:Y", ...](or any other graph-operator exclude such astag:Y+,+model_name, etc.) whereY-tagged models aren't part of theXselect result will silently fail to exclude shared upstream/downstream dependencies. This is a common pattern for avoiding race conditions between DAGs that partition a shared dbt project by tag (e.g. "run everything upstream of tag A, except things also owned by/upstream of tag B, which runs on its own schedule").The task graph in Airflow shows the leaked models as present, but there's no error or warning — it's a silent correctness bug, not a crash.
Relevant log output
How to reproduce
Given this dbt project graph:
model_ais a shared upstream dependency of bothmodel_b(taggedteam_b) andmodel_c(taggedteam_a).Selector config:
Expected (matching real
dbt ls/dbt buildsemantics, and intuitive user intent — "select team_a and its ancestors, but exclude team_b and its ancestors"):model_conly.model_ashould be excluded because it's an ancestor ofmodel_b(tagteam_b), which is in theexcludeset.Actual:
model_aandmodel_care both selected.model_bcorrectly doesn't appear (it's not selected by+tag:team_ain the first place), but becausemodel_bitself is absent from the select-narrowed node set,+tag:team_binexcludehas no root node to traverse upstream from — somodel_ais never identified as an excluded ancestor and leaks through.Reproduction script (against installed Cosmos)
Anything else :)?
Suggested fix
Compute
exclude_idsinapply_exclude_filteragainst the original, fullnodesdict rather than the select-narrowed one, then intersect the final result with the select-narrowed set:(This preserves dbt's own selection semantics, where
selectandexcludeare each evaluated independently against the full manifest graph, then combined via set difference — seeapply_select_filter/apply_exclude_filterdocstring references todocs.getdbt.com/reference/node-selection/syntax.)Current workaround
Add the exclude selector's tag to
selectas well (in addition to keeping it inexclude), so its root nodes survive the narrowing step and the traversal can find them:This works but is unintuitive and easy to forget, especially as new shared-ancestor cases appear over time.
Are you willing to submit PR?
Contact Details
jesper.bagge@axis.com