Skip to content

Add durable option to TriggerDagRunOperator to reconnect on retry - #68936

Open
1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:durable-trigger-dagrun
Open

1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:durable-trigger-dagrun

Conversation

@1fanwang

@1fanwang 1fanwang commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

A TriggerDagRunOperator that waits for its child Dag run (wait_for_completion=True, not deferrable) does not survive a worker crash during the wait. The retry has no record of the run it already started, so it triggers a second child run and the downstream work runs twice. With a fixed run_id the retry fails because that run already exists, even though it is still healthy.

This PR adds an opt-in durable flag, off by default. With durable=True, Airflow saves the id of the triggered run in the task's state store (Airflow 3.3 and later) before the wait starts. A retry reads that id first. If the run is still going, the retry waits on it instead of starting another. If it already finished in an allowed state, the task succeeds. If it failed, was deleted or can't be read, the retry triggers a new run. Deferrable mode is left for a follow-up.

Airflow already does this for operators that submit and poll an external job. Trigger and save:

def _handle_trigger_dag_run(
drte: DagRunTriggerException, context: Context, ti: RuntimeTaskInstance, log: Logger
) -> tuple[ToSupervisor, TaskInstanceState]:
"""Handle exception from TriggerDagRunOperator."""
# Crash-safety for the synchronous wait: persist the triggered run id before polling so a worker
# crash mid-wait reconnects to the in-flight run on retry instead of triggering a duplicate.
task_state_store = context.get("task_state_store")
durable = drte.durable and drte.wait_for_completion and not drte.deferrable
run_id = drte.dag_run_id
reconnecting = False
if durable and task_state_store is not None:
stored_run_id = task_state_store.get(_TRIGGERED_RUN_ID_KEY)
if stored_run_id is not None:
prior_run_id = str(stored_run_id)
decision = _evaluate_prior_triggered_run(prior_run_id, drte, log)
if decision == "succeeded":
ti.xcom_push(key="trigger_run_id", value=prior_run_id)
return _handle_current_task_success(context, ti)
if decision == "reconnect":
run_id = prior_run_id
reconnecting = True
if not reconnecting:
log.info("Triggering Dag Run.", trigger_dag_id=drte.trigger_dag_id)
comms_msg = SUPERVISOR_COMMS.send(
TriggerDagRun(
dag_id=drte.trigger_dag_id,
run_id=run_id,
logical_date=drte.logical_date,
run_after=drte.run_after,
conf=drte.conf,
reset_dag_run=drte.reset_dag_run,
note=drte.note,
),
)
if isinstance(comms_msg, ErrorResponse) and comms_msg.error == ErrorType.DAGRUN_ALREADY_EXISTS:
ti.end_date = datetime.now(tz=UTC)
if drte.skip_when_already_exists:
log.info(
"Dag Run already exists, skipping task as skip_when_already_exists is set to True.",
dag_id=drte.trigger_dag_id,
)
msg = TaskState(
state=TaskInstanceState.SKIPPED,
end_date=ti.end_date,
rendered_map_index=ti.rendered_map_index,
)
state = TaskInstanceState.SKIPPED
else:
log.error("Dag Run already exists, marking task as failed.", dag_id=drte.trigger_dag_id)
msg = TaskState(
state=TaskInstanceState.FAILED,
end_date=ti.end_date,
rendered_map_index=ti.rendered_map_index,
)
state = TaskInstanceState.FAILED
return msg, state
log.info("Dag Run triggered successfully.", trigger_dag_id=drte.trigger_dag_id)
if durable and task_state_store is not None:
# Persist before polling so a crash mid-wait reconnects on retry instead of resubmitting.
task_state_store.set(_TRIGGERED_RUN_ID_KEY, run_id)

A crash after the trigger but before the save still leaves a duplicate, the same gap those operators have. Open question: the flag defaults to False, while those operators default to durable.

Alternatives on the dev list (https://lists.apache.org/thread/q5b7qsjotop023smr1521o2wfmdyrlhr): #68952 with #68955 reuse that shared resume code here and work on 2.11 and 3.0 to 3.3; #69135 lets the operator run the trigger itself on 3.3 and later.

Testing done

Each run kills the parent task process with SIGKILL while TriggerDagRunOperator(wait_for_completion=True) polls a child Dag that sleeps, then lets the scheduler retry the task once.

Child runs from one parent task Parent task on retry
durable=False (before) 2, a duplicate child run triggers a fresh run
durable=True (this PR) 1 reconnects to the running child and succeeds

On 4f70903 with airflow standalone, from the root of the checkout, with the script below saved as e2e.sh:

uv run --project providers/standard ./e2e.sh
=== durable=false ===
kill -9 51967 (parent task process, polling the child run)
./e2e.sh: child setpgid (56635 to 56635): Operation not permitted
parent task (try_number|state): 1|failed
2|success
child runs (run_id|state):
manual__2026-11-06T07:46:08.357046+00:00|success
manual__2026-11-06T07:46:50.643320+00:00|success
attempt 2 log:
{"timestamp":"2026-11-06T07:46:52.708990Z","level":"info","event":"Triggering Dag Run.","ti_id":"01a1afd2-ad52-7530-9efe-ff159063b70e","trigger_dag_id":"child_durable_false","try_number":2,"task_id":"trigger_child","dag_id":"parent_durable_false","run_id":"manual__2026-11-06T07:45:54.055826+00:00","map_index":-1,"logger":"task","filename":"task_runner.py","lineno":2147}
=== durable=true ===
kill -9 60141 (parent task process, polling the child run)
parent task (try_number|state): 1|failed
2|success
child runs (run_id|state):
manual__2026-11-06T07:48:07.754108+00:00|success
attempt 2 log:
{"timestamp":"2026-11-06T07:48:36.907165Z","level":"info","event":"Reconnecting to run triggered on a prior attempt.","ti_id":"01a1afd4-7d57-753a-9d49-da05f8bab9a5","run_id":"manual__2026-11-06T07:48:07.754108+00:00","state":"running","map_index":-1,"dag_id":"parent_durable_true","try_number":2,"task_id":"trigger_child","logger":"task","filename":"task_runner.py","lineno":2119}
Reproducer source: e2e.sh
#!/usr/bin/env bash
# Run from the root of an Airflow checkout: uv run --project providers/standard ./e2e.sh
set -euo pipefail
export AIRFLOW_HOME=/tmp/af68936-e2e
export AIRFLOW__CORE__LOAD_EXAMPLES=False
export AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=False
export AIRFLOW__API__PORT=18093
export AIRFLOW__CORE__EXECUTION_API_SERVER_URL=http://localhost:18093/execution/
export AIRFLOW__LOGGING__WORKER_LOG_SERVER_PORT=18793
export AIRFLOW__LOGGING__TRIGGER_LOG_SERVER_PORT=18794
rm -rf "$AIRFLOW_HOME"
mkdir -p "$AIRFLOW_HOME/dags"
cat > "$AIRFLOW_HOME/dags/durable_e2e.py" <<'PY'
from datetime import datetime, timedelta

from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import DAG

for durable in ("false", "true"):
    with DAG(f"child_durable_{durable}", schedule=None, start_date=datetime(2026, 1, 1)):
        BashOperator(task_id="sleep", bash_command="sleep 60")
    with DAG(f"parent_durable_{durable}", schedule=None, start_date=datetime(2026, 1, 1)):
        TriggerDagRunOperator(
            task_id="trigger_child",
            trigger_dag_id=f"child_durable_{durable}",
            wait_for_completion=True,
            poke_interval=5,
            durable=durable == "true",
            retries=1,
            retry_delay=timedelta(seconds=5),
        )
PY

set -m  # own process group, so the trap stops every Airflow component
airflow standalone > "$AIRFLOW_HOME/standalone.log" 2>&1 &
STANDALONE=$!
trap 'kill -- -"$STANDALONE"' EXIT
db() { sqlite3 "$AIRFLOW_HOME/airflow.db" "$1" 2>/dev/null || true; }
until [ "$(db "select count(*) from dag where dag_id like 'parent_durable_%'")" = 2 ]; do sleep 2; done

for durable in false true; do
  parent=parent_durable_$durable
  child=child_durable_$durable
  echo "=== durable=$durable ==="
  airflow dags trigger "$parent" > /dev/null
  until [ "$(db "select count(*) from dag_run where dag_id='$child' and state='running'")" = 1 ]; do sleep 1; done
  sleep 10
  pid=$(db "select pid from task_instance where dag_id='$parent'")
  echo "kill -9 $pid (parent task process, polling the child run)"
  kill -9 "$pid"
  until [ -n "$(db "select state from dag_run where dag_id='$parent' and state in ('success','failed')")" ]; do sleep 2; done
  echo "parent task (try_number|state): $(db "select try_number || '|' || state from task_instance where dag_id='$parent'")"
  echo "child runs (run_id|state):"
  db "select run_id || '|' || state from dag_run where dag_id='$child' order by id"
  echo "attempt 2 log:"
  grep -hE 'prior attempt|Triggering Dag Run' \
    "$AIRFLOW_HOME"/logs/dag_id="$parent"/run_id=*/task_id=trigger_child/attempt=2.log || true
done

The same scenario on Breeze built from main:

BEFORE (durable=False) — worker killed mid-wait, scheduler retried (try_number=2):
  child dag runs:
    manual__2026-...T08:17:47Z  success   <- attempt 1
    manual__2026-...T08:19:46Z  success   <- attempt 2, DUPLICATE
  => 2 runs from one parent task

AFTER (durable=True) — same crash, try_number=2:
  attempt=2.log: "Reconnecting to run triggered on a prior attempt."
                 run_id=manual__2026-...T08:53:51Z
  parent run: success   (trigger_child succeeded on try_number=2)
  child dag runs:
    manual__2026-...T08:53:51Z  success   <- single run, reconnected
  => 1 run from one parent task

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: GitHub Copilot CLI (GPT-6 Astra) following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

@seanghaeli seanghaeli 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.

Looks good, definitely a follow-up to handle deferrable=True will be useful

@potiuk potiuk added the ready for maintainer review Set by triage when all criteria pass, or by the fork CI reconciler when fork CI is green. label Jun 25, 2026
@raphaelauv

raphaelauv commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

It looks like the run_id is not save before trggering the dag_run

so in the worst case :

  1. the dag_run is trigger
  2. the worker hard crash
  3. run_id is never save ( because it's only saved, if the code _handle_trigger_dag_run is run )

is this the case ? thanks

@amoghrajesh amoghrajesh 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.

Thanks for your efforts on this @1fanwang, requesting changes for the time being to avoid accidental merge till we arrive at a consensus on https://lists.apache.org/thread/q5b7qsjotop023smr1521o2wfmdyrlhr

With wait_for_completion the trigger-and-wait runs in the task runner. A worker
crash while polling makes the retry recompute a fresh run_id and trigger a
duplicate child run (or fail with DagRunAlreadyExists), even though the run the
first attempt started is healthy and still running. The opt-in durable flag
persists the triggered run_id to task_state_store before polling, so the retry
reconnects to the in-flight run instead of resubmitting.

Signed-off-by: Stefan Wang <[email protected]>
An earlier commit on this branch re-added the __init__-time
_validate_datetime_param call for logical_date, undoing apache#70490.
logical_date is a template field, so validating it in __init__ fails the
validate-operators-init check and rejects values before rendering.
execute() already validates the rendered value.

Signed-off-by: 1fanwang <[email protected]>
airflow-core newsfragments are now reserved for significant changes, and
none was requested in review for this opt-in flag.

Signed-off-by: 1fanwang <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providers area:task-sdk need workflow approval provider:standard ready for maintainer review Set by triage when all criteria pass, or by the fork CI reconciler when fork CI is green.

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

5 participants