Skip to content

[Bug] exclude graph-operator selectors (+tag:x) silently miss ancestors #2964

Description

@jesperbagge

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_filterNodeSelector.select_nodes_ids_by_intersectionGraphSelector.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?

  • Yes I am willing to submit a PR!

Contact Details

jesper.bagge@axis.com

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:selectorRelated to selector, like DAG selector, DBT selector, etcbugSomething isn't workingparsing:dbt_manifestIssues, questions, or features related to dbt_manifest parsingpriority:mediumMedium priority issues are important issues that may have a workaround and medium impact

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions