Skip to content

Restoring include_downstream_dags logic to clear downstream Dags - #65314

Open
jroachgolf84 wants to merge 15 commits into
apache:mainfrom
jroachgolf84:issue-61451
Open

Restoring include_downstream_dags logic to clear downstream Dags#65314
jroachgolf84 wants to merge 15 commits into
apache:mainfrom
jroachgolf84:issue-61451

Conversation

@jroachgolf84

@jroachgolf84 jroachgolf84 commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Restoring behavior to clear downstream DAGs when a Task state is cleared (if applicable).

closes: #61451

Testing

Unit-tests were added/updated to test these changes. The changes were also tested E2E.

Unit Tests

The unit-tests added/updated as part of this effort can be found in these files:

  • airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
  • airflow-core/tests/unit/serialization/definitions/test_dag.py

These tests can be executed using the commands below:

breeze testing core-tests airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py

breeze testing core-tests airflow-core/tests/unit/serialization/definitions/test_dag.py

E2E Testing

The DAG below was used to test the changes int his PR. This DAG is made up of a parent and child DAG. The ExternalTaskMarker Operator is cleared in the parent DAG, the downstream Tasks in the child DAG are also cleared. That's showed in the video below.

from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.sensors.external_task import ExternalTaskMarker, ExternalTaskSensor
from airflow.sdk import DAG

from datetime import datetime


with DAG(
    dag_id="parent_dag_61451",
    start_date=datetime(2026, 1, 18),
    schedule="@daily",
    catchup=False,
    tags=["issue-61451"],
) as parent_dag:
    parent_echo = BashOperator(
        task_id="parent_echo",
        bash_command="echo parent_dag_61451",
    )

    trigger_child = ExternalTaskMarker(
        task_id="trigger_child",
        external_dag_id="child_dag_61451",
        external_task_id="receive_call_from_parent",
    )

    parent_echo >> trigger_child

with DAG(
    dag_id="child_dag_61451",
    start_date=datetime(2026, 1, 18),
    schedule="@daily",
    catchup=False,
    tags=["issue-61451"],
) as child_dag:
    receive_call_from_parent = ExternalTaskSensor(
        task_id="receive_call_from_parent",
        external_dag_id="parent_dag_61451",
        external_task_id="trigger_child",
        poke_interval=5,
    )

    child_echo = BashOperator(
        task_id="child_echo",
        bash_command="echo DONE",
    )

    receive_call_from_parent >> child_echo

Video

issue-61451-include-downstream-tasks.mov

@LinasData

Copy link
Copy Markdown

Hey, any updates on this?

@jroachgolf84

Copy link
Copy Markdown
Collaborator Author

@jason810496 - can you take a look at this one for me?

@jroachgolf84

Copy link
Copy Markdown
Collaborator Author

@bugraoz93 - do you mind taking a look at this one?

Comment thread airflow-core/src/airflow/serialization/definitions/dag.py
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
@kaxil kaxil changed the title issue-61451: Restoring include_downstream_dags logic Restoring include_downstream_dags logic to clear downstream Dags Jun 26, 2026
@jroachgolf84
jroachgolf84 requested a review from henry3260 as a code owner July 6, 2026 13:29
@jroachgolf84
jroachgolf84 requested a review from kaxil July 6, 2026 13:29
@jroachgolf84

Copy link
Copy Markdown
Collaborator Author

@kaxil - do you mind taking another look?

@eladkal eladkal added this to the Airflow 3.4.0 milestone Jul 17, 2026
@eladkal eladkal added the type:improvement Changelog: Improvements label Jul 17, 2026

@eladkal eladkal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM
but needs 2nd eye from @kaxil

Comment thread airflow-core/src/airflow/serialization/definitions/dag.py
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

uv.lock on main just moved via #70791 ("Limit pandas to < 3 for DataFrame XComs"), commit 415cb70 and this PR currently conflicts.

Quickest fix:

git fetch upstream main && git rebase upstream/main
rm uv.lock && uv lock
git add uv.lock && git rebase --continue
git push --force-with-lease

Automated nudge — ignore if you're not ready to rebase. This comment is updated in place on future uv.lock bumps.

Comment thread airflow-core/newsfragments/65314.improvement.rst Outdated
@jroachgolf84
jroachgolf84 requested a review from XD-DENG as a code owner July 22, 2026 03:08
@jroachgolf84
jroachgolf84 requested a review from kaxil July 22, 2026 03:09
@jroachgolf84

Copy link
Copy Markdown
Collaborator Author

@kaxil - feedback implemented. Do you mind taking another look? Thanks!

@Lee-W Lee-W left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly nits, but overall this looks good. The CI failure is unrelated—rebasing on the main branch should fix it.

Comment thread airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py Outdated
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/tests/unit/serialization/definitions/test_dag.py Outdated
Comment thread airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py Outdated
Comment thread airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py Outdated
Comment thread airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py Outdated
Comment thread airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py Outdated
jroachgolf84 and others added 2 commits July 22, 2026 23:22
Co-authored-by: Wei Lee <hello@wei-lee.me>
Co-authored-by: Wei Lee <hello@wei-lee.me>
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py Outdated
Comment thread airflow-core/tests/unit/serialization/definitions/test_dag.py Outdated
@jroachgolf84

Copy link
Copy Markdown
Collaborator Author

@Lee-W - thanks for the review! I've implemented all your feedback.

@Lee-W Lee-W left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly good on my end. would appreciate a second pair of eyes

Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py Outdated
Comment thread airflow-core/src/airflow/serialization/definitions/dag.py
Comment thread airflow-core/tests/unit/serialization/definitions/test_dag.py Outdated
Comment thread airflow-core/tests/unit/serialization/definitions/test_dag.py Outdated
jroachgolf84 and others added 2 commits August 3, 2026 09:25
dag_bag = DBDagBag(load_op_links=False)

for tii in external_tis:
external_dag = dag_bag.get_latest_version_of_dag(tii.dag_id, session=session)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolves the child Dag from its latest version, but the code being restored used the run-pinned one: dag_bag.get_dag_for_run(tii.dag_run, session=session), at dag.py:1029 before #54383 removed it. This route already calls get_dag_for_run in two other spots (lines 335 and 882), so the helper is right there.

Why it matters: the partial_subset(include_downstream=True) just below becomes the task_id.in_(...) filter for the child's TI query. With bundle versioning on, that means we work out the downstream closure from a graph the historical child run may never have had. Say child v1 is wait >> transform >> load and v2 drops transform: clearing the parent reruns load and leaves transform sitting at SUCCESS in the v1 run.

get_dag_for_run(...) or get_latest_version_of_dag(...) keeps a fallback for a missing serialized row, and still routes through _get_dag, so the deserialize-once behaviour you added earlier holds.


# Follow ExternalTaskMarker connections when explicitly requested via include_downstream_dags, or
# automatically whenever downstream clearing is selected (restoring Airflow 2 behavior)
include_dependent_dags = body.include_downstream_dags or downstream

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming back to the flag overlap I asked about in #65314 (comment), now that I've read the code this restores.

include_downstream_dags: false can't actually turn the cascade off, because or downstream only ever reacts to true. And the comment doesn't quite line up with what was there before: the pre-removal clear() hard-coded include_dependent_dags=True (dag.py:1404), it wasn't keyed off the same-Dag downstream flag at all.

The UI side is what bothers me more. ClearTaskInstanceDialog seeds selectedOptions with ["downstream"] (line 80, and the group dialog does the same at 54), so a default single-task clear from the grid now walks into other Dags and the caller has no way to say no.

run_on_latest_version in this same body model is already bool | None. Same treatment here gets you the opt-out and keeps the current UI default:

include_downstream_dags: bool | None = None
...
downstream if body.include_downstream_dags is None else body.include_downstream_dags

}

# list of all TI's that can be cleared (TI's within the Dags from above)
task_instances = [ti for ti in task_instances if ti.dag_id in editable_dag_ids]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this list can span Dags, the single resolved_run_on_latest computed up at line 863 from the path dag_id gets applied to child Dag runs too. resolve_run_on_latest_version only takes one dag_id and reads the parent's Dag-level setting; clear_task_instances then rewrites each run's created_dag_version_id and calls verify_integrity(), which can add or drop TIs in that run.

Since the clear dialog always sends the field, the explicit_value is not None short-circuit means a child Dag with rerun_with_latest_version=False gets upgraded regardless of its own setting.

Grouping the TIs by dag_id and resolving per Dag when body.run_on_latest_version is None would respect each Dag's policy, with an explicit value from the caller still winning everywhere.


except MaxRecursionDepthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
except ParserError as e:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still ParserError-only, so this misses the inputs that raise ValueError directly. Same point as #65314 (comment), which got resolved without the change.

Two shapes reach it on the pinned pendulum 3.2.0. pendulum.parse("") raises ValueError: year 0 is out of range, which isn't a ParserError. And pendulum.parse("P1D") returns a Duration, which then goes into DagRun.logical_date == <Duration> and comes back as a StatementError. Both end up as a 500. A marker with logical_date="{{ dag_run.conf.get('x', '') }}" rendering empty gets you the first one.

Catching ValueError picks up the bare case plus the ParserError ones, since it's the parent. The Duration shape needs a check on what parse actually returned.

One thing to flag: test_invalid_external_task_marker_logical_date_returns_400 can't catch either of these. It sets side_effect=ParserError(...) on a fully mocked clear(), so it only proves the except clause catches what the test hands it, not what the real call site raises.

@kaxil
kaxil requested a review from uranusjr August 3, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:API Airflow's REST/HTTP API area:DAG-processing ready for maintainer review Set after triaging when all criteria pass. type:improvement Changelog: Improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

I am upgrading from Airflow 2.11.0 to Airflow 3.1.6 and noticed a change in behavior regarding the ExternalTaskMarker

5 participants