This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new fd88296d318 [v3-3-test] Fix cleared tasks getting stuck when a Dag run
has no version (#71696) (#71773)
fd88296d318 is described below
commit fd88296d318f6706a62aae4a9a492ef74e3a463f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Aug 30 02:03:39 2026 +0200
[v3-3-test] Fix cleared tasks getting stuck when a Dag run has no version
(#71696) (#71773)
---
.../administration-and-deployment/dag-bundles.rst | 4 +
airflow-core/src/airflow/models/dagbag.py | 5 +-
airflow-core/src/airflow/models/taskinstance.py | 53 +++++-
.../airflow/ui/public/i18n/locales/en/dags.json | 1 +
.../ui/src/components/Clear/Run/ClearRunDialog.tsx | 30 ++--
.../TaskInstance/ClearGroupTaskInstanceDialog.tsx | 32 +++-
.../Clear/TaskInstance/ClearTaskInstanceDialog.tsx | 26 ++-
.../Clear/TaskInstance/runOnLatestVersion.test.ts | 14 ++
.../Clear/TaskInstance/runOnLatestVersion.ts | 21 ++-
.../core_api/routes/public/test_dag_run.py | 17 ++
airflow-core/tests/unit/models/test_cleartasks.py | 179 ++++++++++++++++++++-
11 files changed, 344 insertions(+), 38 deletions(-)
diff --git a/airflow-core/docs/administration-and-deployment/dag-bundles.rst
b/airflow-core/docs/administration-and-deployment/dag-bundles.rst
index 354e6ecda16..ef65b5eaa71 100644
--- a/airflow-core/docs/administration-and-deployment/dag-bundles.rst
+++ b/airflow-core/docs/administration-and-deployment/dag-bundles.rst
@@ -276,6 +276,10 @@ The setting is resolved using the following precedence
(highest to lowest):
4. **Per-call-site fallback**: ``False`` for clear/rerun, ``True`` for
backfills (preserving
the historical default for each path)
+One exception: a Dag run with no version of its own — carried over from
Airflow 2, or its version
+since removed by ``airflow db clean`` — has nothing to preserve, so clearing
it always uses the
+latest version and bundle version regardless of the resolved setting.
+
Global Configuration
~~~~~~~~~~~~~~~~~~~~
diff --git a/airflow-core/src/airflow/models/dagbag.py
b/airflow-core/src/airflow/models/dagbag.py
index 5489803caec..dfa336f2fe0 100644
--- a/airflow-core/src/airflow/models/dagbag.py
+++ b/airflow-core/src/airflow/models/dagbag.py
@@ -221,7 +221,10 @@ class DBDagBag:
@staticmethod
def _version_from_dag_run(dag_run: DagRun, *, session: Session) -> UUID |
None:
- if not dag_run.bundle_version:
+ # A run with no version of its own can only resolve to the latest.
Runs carried over from
+ # Airflow 2 are like this, as are runs whose version `airflow db
clean` has since deleted --
+ # the latter keep their bundle version, so they would otherwise
resolve to nothing at all.
+ if not dag_run.bundle_version or not dag_run.created_dag_version_id:
if dag_version :=
DagVersion.get_latest_version(dag_id=dag_run.dag_id, session=session):
return dag_version.id
diff --git a/airflow-core/src/airflow/models/taskinstance.py
b/airflow-core/src/airflow/models/taskinstance.py
index 7dae21baa2c..aca45da6391 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -333,6 +333,27 @@ def _update_dagrun_to_latest_version(
session.flush()
+def _pin_versionless_tis_to_run_version(dag_run: DagRun, dag_version_id: UUID,
session: Session) -> None:
+ """
+ Give the run's unfinished task instances a dag version if they have none.
+
+ Once the run is pinned the scheduler stops backfilling versions onto them,
and one
+ without a version is never enqueued.
+ """
+ session.execute(
+ update(TaskInstance)
+ .where(
+ TaskInstance.dag_id == dag_run.dag_id,
+ TaskInstance.run_id == dag_run.run_id,
+ TaskInstance.dag_version_id.is_(None),
+ # State.unfinished holds None, which SQL IN never matches.
+ or_(TaskInstance.state.is_(None),
TaskInstance.state.in_(State.unfinished)),
+ )
+ .values(dag_version_id=dag_version_id)
+ .execution_options(synchronize_session="evaluate")
+ )
+
+
def clear_task_instances(
tis: list[TaskInstance],
session: Session,
@@ -353,7 +374,9 @@ def clear_task_instances(
:param session: current session
:param dag_run_state: state to set finished DagRuns to.
If set to False, DagRuns state will not be changed.
- :param run_on_latest_version: whether to run on latest serialized DAG and
Bundle version
+ :param run_on_latest_version: whether to run on latest serialized DAG and
Bundle version.
+ A run with no version of its own uses the latest either way, since
there is nothing
+ else for it to run on; a task instance with no version joins its run's.
:meta private:
"""
@@ -377,7 +400,10 @@ def clear_task_instances(
# the task is terminated and becomes eligible for retry.
else:
dr = ti.dag_run
- if run_on_latest_version:
+ # A run with no version of its own has nothing to re-run on but
the latest, and the
+ # run loop below moves it there.
+ use_latest_version = run_on_latest_version or
dr.created_dag_version_id is None
+ if use_latest_version:
ti_dag = scheduler_dagbag.get_latest_version_of_dag(ti.dag_id,
session=session)
else:
ti_dag = scheduler_dagbag.get_dag_for_run(dag_run=dr,
session=session)
@@ -399,11 +425,15 @@ def clear_task_instances(
ti.state = None
ti.external_executor_id = None
ti.clear_next_method_args()
- # Match DagVersion to latest serialized DAG when
run_on_latest_version.
- if run_on_latest_version:
+ # Match DagVersion to latest serialized DAG when running on the
latest version.
+ if use_latest_version:
latest_dag_version = DagVersion.get_latest_version(ti.dag_id,
session=session)
if latest_dag_version is not None:
ti.dag_version_id = latest_dag_version.id
+ elif ti.dag_version_id is None:
+ # One without a version is never enqueued, and the run keeps
its own, so it can
+ # only go there.
+ ti.dag_version_id = dr.created_dag_version_id
session.merge(ti)
if dag_run_state is not False and tis:
@@ -435,10 +465,14 @@ def clear_task_instances(
_recalculate_dagrun_queued_at_deadlines(dr, dr.queued_at, session)
+ # A run with no version of its own has nothing to preserve, so the
latest is all
+ # it can be re-run on. Runs migrated from Airflow 2 are like this,
as are runs
+ # whose version `airflow db clean` has since deleted.
+ use_latest_version = run_on_latest_version or
dr.created_dag_version_id is None
if dr.state in State.finished_dr_states:
dr.state = dag_run_state
dr.start_date = timezone.utcnow()
- if run_on_latest_version:
+ if use_latest_version:
dr_dag =
scheduler_dagbag.get_latest_version_of_dag(dr.dag_id, session=session)
dag_version = DagVersion.get_latest_version(dr.dag_id,
session=session)
if dag_version:
@@ -452,14 +486,14 @@ def clear_task_instances(
dr_dag = scheduler_dagbag.get_dag_for_run(dag_run=dr,
session=session)
if not dr_dag:
log.warning("No serialized dag found for dag '%s'",
dr.dag_id)
- if dr_dag and not dr_dag.disable_bundle_versioning and
run_on_latest_version:
+ if dr_dag and not dr_dag.disable_bundle_versioning and
use_latest_version:
bundle_version = dr.dag_model.bundle_version
- if bundle_version is not None and run_on_latest_version:
+ if bundle_version is not None:
dr.bundle_version = bundle_version
if dag_run_state == DagRunState.QUEUED:
dr.last_scheduling_decision = None
dr.start_date = None
- elif run_on_latest_version:
+ elif use_latest_version:
# Queued/running DagRun: update DR to latest version/bundle
for workloads that use it.
dag_version = DagVersion.get_latest_version(dr.dag_id,
session=session)
if dag_version and dr.created_dag_version_id != dag_version.id:
@@ -473,6 +507,9 @@ def clear_task_instances(
bundle_version = dr.dag_model.bundle_version
if bundle_version is not None:
dr.bundle_version = bundle_version
+
+ if dr.created_dag_version_id:
+ _pin_versionless_tis_to_run_version(dr,
dr.created_dag_version_id, session)
for ti in tis:
ti.context_carrier = new_task_run_carrier(ti.dag_run.context_carrier)
session.flush()
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 472c949ce28..b8fbc5a0f96 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -75,6 +75,7 @@
"preventRunningTasks": "Prevent rerun if task is running",
"queueNew": "Queue up new tasks",
"runOnLatestVersion": "Run with latest bundle version",
+ "runOnLatestVersionForced": "Always uses the latest — there's no earlier
version to go back to",
"upstream": "Upstream"
}
},
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
index c864f0b047c..8c471eaad9e 100644
--- a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
@@ -63,6 +63,17 @@ const ClearRunDialog = ({ dagRun, onClose, open }: Props) =>
{
dagId,
});
+ // Offered only where it changes the outcome. A non-versioned bundle (e.g.
LocalDagBundle)
+ // leaves bundle_version null and resolves to the latest serialized Dag at
run time anyway,
+ // so unless the run has no version at all the option would be a no-op there.
+ const { runOnLatestVersionForced, shouldShowRunOnLatestOption } =
getRunOnLatestVersionState({
+ latestBundleVersion: dagDetails?.bundle_version,
+ latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
+ selectedBundleVersion: dagRun.bundle_version,
+ selectedDagVersionNumber: dagRun.dag_versions.at(-1)?.version_number,
+ selectedVersionMissing: dagRun.dag_versions.length === 0,
+ });
+
const { setValue: setRunOnLatestVersion, value: runOnLatestVersion } =
useRerunWithLatestVersion({
dagLevelConfig: dagDetails?.rerun_with_latest_version,
});
@@ -92,17 +103,6 @@ const ClearRunDialog = ({ dagRun, onClose, open }: Props)
=> {
onSuccessConfirm: handleClose,
});
- // Non-versioned bundles (e.g. LocalDagBundle) always leave bundle_version
null and
- // resolve to the latest serialized Dag at run time, so "run on latest" is a
no-op there.
- // Offer it only when re-running on the latest would actually change the
outcome:
- // the run's Dag version differs from the latest while the bundle is
versioned
- // (latest bundle_version present), or the run's bundle version differs from
the latest.
- const { shouldShowRunOnLatestOption } = getRunOnLatestVersionState({
- latestBundleVersion: dagDetails?.bundle_version,
- latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
- selectedBundleVersion: dagRun.bundle_version,
- selectedDagVersionNumber: dagRun.dag_versions.at(-1)?.version_number,
- });
const shouldShowBundleVersionOption = shouldShowRunOnLatestOption &&
!onlyNew;
return (
@@ -158,8 +158,14 @@ const ClearRunDialog = ({ dagRun, onClose, open }: Props)
=> {
>
{shouldShowBundleVersionOption ? (
<Checkbox
- checked={runOnLatestVersion}
+ checked={runOnLatestVersionForced || runOnLatestVersion}
+ disabled={runOnLatestVersionForced}
onCheckedChange={(event) =>
setRunOnLatestVersion(Boolean(event.checked))}
+ title={
+ runOnLatestVersionForced
+ ?
translate("dags:runAndTaskActions.options.runOnLatestVersionForced")
+ : undefined
+ }
>
{translate("dags:runAndTaskActions.options.runOnLatestVersion")}
</Checkbox>
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
index ab61c46e7ae..a215486ebe0 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
@@ -22,7 +22,11 @@ import { useTranslation } from "react-i18next";
import { CgRedo } from "react-icons/cg";
import { useParams } from "react-router-dom";
-import { useDagServiceGetDagDetails, useTaskInstanceServiceGetTaskInstances }
from "openapi/queries";
+import {
+ useDagRunServiceGetDagRun,
+ useDagServiceGetDagDetails,
+ useTaskInstanceServiceGetTaskInstances,
+} from "openapi/queries";
import type { LightGridTaskInstanceSummary, TaskInstanceResponse } from
"openapi/requests/types.gen";
import { ActionAccordion } from "src/components/ActionAccordion";
import { useRerunWithLatestVersion } from
"src/components/Clear/useRerunWithLatestVersion";
@@ -78,14 +82,20 @@ export const ClearGroupTaskInstanceDialog = ({ onClose,
open, taskInstance }: Pr
const groupTaskIds = groupTaskInstances?.task_instances.map((ti) =>
ti.task_id) ?? [];
- const { dagVersionsDiffer, shouldShowRunOnLatestOption } =
getRunOnLatestVersionState({
- latestBundleVersion: dagDetails?.bundle_version,
- latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
- selectedDagVersionNumber: taskInstance.dag_version_number,
- // Fall back to legacy heuristic when grid summary has no version (older
API).
- useLatestBundleVersionAsFallback: true,
+ const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId: runId
}, undefined, {
+ enabled: open,
});
+ const { dagVersionsDiffer, runOnLatestVersionForced,
shouldShowRunOnLatestOption } =
+ getRunOnLatestVersionState({
+ latestBundleVersion: dagDetails?.bundle_version,
+ latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
+ selectedDagVersionNumber: taskInstance.dag_version_number,
+ selectedVersionMissing: dagRun?.dag_versions.length === 0,
+ // Fall back to legacy heuristic when grid summary has no version (older
API).
+ useLatestBundleVersionAsFallback: true,
+ });
+
// dagVersionsDiffer becomes the fallback so the historical "auto-check when
versions
// differ" heuristic still applies when neither DAG-level nor global config
is set.
const { setValue: setRunOnLatestVersion, value: runOnLatestVersion } =
useRerunWithLatestVersion({
@@ -180,8 +190,14 @@ export const ClearGroupTaskInstanceDialog = ({ onClose,
open, taskInstance }: Pr
>
{shouldShowRunOnLatestOption ? (
<Checkbox
- checked={runOnLatestVersion}
+ checked={runOnLatestVersionForced || runOnLatestVersion}
+ disabled={runOnLatestVersionForced}
onCheckedChange={(event) =>
setRunOnLatestVersion(Boolean(event.checked))}
+ title={
+ runOnLatestVersionForced
+ ?
translate("dags:runAndTaskActions.options.runOnLatestVersionForced")
+ : undefined
+ }
>
{translate("dags:runAndTaskActions.options.runOnLatestVersion")}
</Checkbox>
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
index 0026da07395..57e8675704c 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
@@ -21,7 +21,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CgRedo } from "react-icons/cg";
-import { useDagServiceGetDagDetails } from "openapi/queries";
+import { useDagRunServiceGetDagRun, useDagServiceGetDagDetails } from
"openapi/queries";
import type { ClearTaskInstancesBody, TaskInstanceResponse } from
"openapi/requests/types.gen";
import { ActionAccordion } from "src/components/ActionAccordion";
import { taskInstanceKey } from "src/components/ActionAccordion/columns";
@@ -104,13 +104,19 @@ const ClearTaskInstanceDialog = (props: Props) => {
dagId,
});
- const { dagVersionsDiffer, shouldShowRunOnLatestOption } =
getRunOnLatestVersionState({
- latestBundleVersion: dagDetails?.bundle_version,
- latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
- selectedBundleVersion: taskInstance?.dag_version?.bundle_version,
- selectedDagVersionNumber: taskInstance?.dag_version?.version_number,
+ const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId },
undefined, {
+ enabled: openDialog,
});
+ const { dagVersionsDiffer, runOnLatestVersionForced,
shouldShowRunOnLatestOption } =
+ getRunOnLatestVersionState({
+ latestBundleVersion: dagDetails?.bundle_version,
+ latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
+ selectedBundleVersion: taskInstance?.dag_version?.bundle_version,
+ selectedDagVersionNumber: taskInstance?.dag_version?.version_number,
+ selectedVersionMissing: dagRun?.dag_versions.length === 0,
+ });
+
// dagVersionsDiffer becomes the fallback so the historical "auto-check when
versions
// differ" heuristic still applies when neither DAG-level nor global config
is set.
const { setValue: setRunOnLatestVersion, value: runOnLatestVersion } =
useRerunWithLatestVersion({
@@ -258,8 +264,14 @@ const ClearTaskInstanceDialog = (props: Props) => {
>
{shouldShowRunOnLatestOption ? (
<Checkbox
- checked={runOnLatestVersion}
+ checked={runOnLatestVersionForced || runOnLatestVersion}
+ disabled={runOnLatestVersionForced}
onCheckedChange={(event) =>
setRunOnLatestVersion(Boolean(event.checked))}
+ title={
+ runOnLatestVersionForced
+ ?
translate("dags:runAndTaskActions.options.runOnLatestVersionForced")
+ : undefined
+ }
>
{translate("dags:runAndTaskActions.options.runOnLatestVersion")}
</Checkbox>
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.test.ts
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.test.ts
index 1f3d6ba4d3c..cc09d604e65 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.test.ts
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.test.ts
@@ -137,15 +137,27 @@ describe("getRunOnLatestVersionState", () => {
name: "does not show for group fallback when latest bundle is missing",
useLatestBundleVersionAsFallback: true,
},
+ {
+ expectedDagVersionsDiffer: false,
+ expectedRunOnLatestVersionForced: true,
+ expectedShouldShowRunOnLatestOption: true,
+ // A null latest bundle version pins the case that matters: the option
is forced even
+ // on a non-versioned bundle, where it would otherwise never be offered.
+ latestBundleVersion: null,
+ name: "forces and shows the option when the selection has no Dag version
at all",
+ selectedVersionMissing: true,
+ },
])(
"$name",
({
expectedDagVersionsDiffer,
+ expectedRunOnLatestVersionForced = false,
expectedShouldShowRunOnLatestOption,
latestBundleVersion,
latestDagVersionNumber,
selectedBundleVersion,
selectedDagVersionNumber,
+ selectedVersionMissing,
useLatestBundleVersionAsFallback,
}) => {
expect(
@@ -154,10 +166,12 @@ describe("getRunOnLatestVersionState", () => {
latestDagVersionNumber,
selectedBundleVersion,
selectedDagVersionNumber,
+ selectedVersionMissing,
useLatestBundleVersionAsFallback,
}),
).toEqual({
dagVersionsDiffer: expectedDagVersionsDiffer,
+ runOnLatestVersionForced: expectedRunOnLatestVersionForced,
shouldShowRunOnLatestOption: expectedShouldShowRunOnLatestOption,
});
},
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.ts
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.ts
index fa183b37d0c..21d7591e84c 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.ts
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/runOnLatestVersion.ts
@@ -22,11 +22,26 @@ type RunOnLatestVersionParams = {
readonly latestDagVersionNumber?: number | null;
readonly selectedBundleVersion?: string | null;
readonly selectedDagVersionNumber?: number | null;
+ /**
+ * True when the *run* being cleared has no Dag version at all, which is the
case for
+ * anything carried over from Airflow 2. There is nothing to re-run it on
but the latest
+ * version, so the backend forces that regardless of the request. Keep this
keyed off the
+ * run: a task instance with no version of its own is given its run's
version, not the
+ * latest, so deriving this from the task instance would promise the wrong
thing.
+ */
+ readonly selectedVersionMissing?: boolean;
readonly useLatestBundleVersionAsFallback?: boolean;
};
type RunOnLatestVersionState = {
readonly dagVersionsDiffer: boolean;
+ /**
+ * Drives how the checkbox renders, not what is submitted. A clear can span
several runs
+ * (via past/future) while the request carries one flag for all of them, so
forcing it
+ * would pin runs the user never selected. The backend forces each
version-less run on
+ * its own instead.
+ */
+ readonly runOnLatestVersionForced: boolean;
readonly shouldShowRunOnLatestOption: boolean;
};
@@ -38,6 +53,7 @@ export const getRunOnLatestVersionState = ({
latestDagVersionNumber,
selectedBundleVersion,
selectedDagVersionNumber,
+ selectedVersionMissing = false,
useLatestBundleVersionAsFallback = false,
}: RunOnLatestVersionParams): RunOnLatestVersionState => {
const dagVersionsDiffer =
@@ -55,7 +71,10 @@ export const getRunOnLatestVersionState = ({
return {
dagVersionsDiffer,
+ runOnLatestVersionForced: selectedVersionMissing,
shouldShowRunOnLatestOption:
- (dagVersionsDiffer && hasBundleVersion(latestBundleVersion)) ||
shouldShowForBundleVersion,
+ selectedVersionMissing ||
+ (dagVersionsDiffer && hasBundleVersion(latestBundleVersion)) ||
+ shouldShowForBundleVersion,
};
};
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 6c633fe8a2a..947fb3cce8b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -2031,6 +2031,23 @@ class TestClearDagRun:
logical_date=None,
)
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_clear_dag_run_whose_dag_version_was_deleted(self, test_client,
session):
+ """A run that kept its bundle version after ``airflow db clean``
removed its Dag version."""
+ session.execute(
+ update(DagRun)
+ .where(DagRun.dag_id == DAG1_ID, DagRun.run_id == DAG1_RUN1_ID)
+ .values(created_dag_version_id=None,
bundle_version="deleted-version")
+ )
+ session.commit()
+
+ response = test_client.post(
+ f"/dags/{DAG1_ID}/dagRuns/{DAG1_RUN1_ID}/clear",
+ json={"dry_run": False},
+ )
+ assert response.status_code == 200
+ assert response.json()["state"] == "queued"
+
def test_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.post(
f"/dags/{DAG1_ID}/dagRuns/{DAG1_RUN1_ID}/clear",
diff --git a/airflow-core/tests/unit/models/test_cleartasks.py
b/airflow-core/tests/unit/models/test_cleartasks.py
index 9972191e1eb..407b51bbec2 100644
--- a/airflow-core/tests/unit/models/test_cleartasks.py
+++ b/airflow-core/tests/unit/models/test_cleartasks.py
@@ -21,7 +21,7 @@ import datetime
import random
import pytest
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
from airflow.models.dag_version import DagVersion
from airflow.models.dagrun import DagRun
@@ -739,6 +739,183 @@ class TestClearTasks:
for ti in dr.task_instances:
assert ti.dag_version_id == old_dag_version.id
+ def test_clear_task_instances_without_dag_version_forces_latest(self,
dag_maker, session):
+ """A Dag run carried over from Airflow 2 has no version, so clearing
must pin it to the latest."""
+ dag_id = "test_clear_no_dag_version"
+ dr = self._make_versionless_run(dag_maker, session, dag_id,
DagRunState.SUCCESS)
+
+ latest_dag_version = DagVersion.get_latest_version(dr.dag_id)
+ ti0 = session.scalar(select(TI).where(TI.dag_id == dag_id))
+ assert ti0.dag_version_id is None, "Pre-condition"
+ assert ti0.dag_run.created_dag_version_id is None, "Pre-condition"
+
+ clear_task_instances([ti0], session, run_on_latest_version=False)
+ session.commit()
+
+ dr_after = session.scalar(select(DagRun).where(DagRun.dag_id ==
dag_id))
+ assert dr_after.created_dag_version_id == latest_dag_version.id
+ assert dr_after.bundle_version == latest_dag_version.bundle_version
+ assert dr_after.task_instances[0].dag_version_id ==
latest_dag_version.id
+
+ def _make_versionless_run(self, dag_maker, session, dag_id, dr_state,
task_count=1, sibling_state=None):
+ """
+ Build a run shaped like Airflow 2 left it: no versions anywhere.
+
+ Task "0" is run for real; any further tasks are left in
``sibling_state``.
+ """
+ with dag_maker(dag_id, start_date=DEFAULT_DATE, catchup=True,
bundle_version="v1") as dag:
+ task0 = EmptyOperator(task_id="0")
+ for index in range(1, task_count):
+ EmptyOperator(task_id=str(index))
+ dr = dag_maker.create_dagrun(state=State.RUNNING,
run_type=DagRunType.SCHEDULED)
+
+ ti0, *siblings = sorted(dr.task_instances, key=lambda ti: ti.task_id)
+ ti0.refresh_from_task(dag.get_task("0"))
+ run_task_instance(ti0, task0)
+ for sibling in siblings:
+ sibling.state = sibling_state
+ dr.state = dr_state
+
+ # `airflow db migrate` from Airflow 2 leaves these columns NULL. Write
them directly so
+ # no ORM relationship syncs the old values back, then expire so the
objects are reloaded
+ # from the database like they are in a real deployment.
+ session.flush()
+ session.execute(
+ update(DagRun).where(DagRun.id ==
dr.id).values(created_dag_version_id=None, bundle_version=None)
+ )
+ session.execute(update(TI).where(TI.dag_id ==
dag.dag_id).values(dag_version_id=None))
+ session.commit()
+ session.expire_all()
+ return dr
+
+ def
test_clear_task_instances_pins_task_instance_restored_by_verify_integrity(self,
dag_maker, session):
+ """
+ A task instance revived by ``verify_integrity`` is given a version too.
+
+ It comes back unfinished but unversioned, and pinning the run stops
the scheduler
+ backfilling one, so it would never be enqueued.
+ """
+ dag_id = "test_clear_no_dag_version_restored"
+ # Task "1" was dropped from the Dag during the Airflow 2 era and later
re-added, so
+ # verify_integrity restores it when the finished run is cleared.
+ dr = self._make_versionless_run(
+ dag_maker,
+ session,
+ dag_id,
+ DagRunState.SUCCESS,
+ task_count=2,
+ sibling_state=TaskInstanceState.REMOVED,
+ )
+ latest_dag_version = DagVersion.get_latest_version(dr.dag_id)
+ ti0 = session.scalar(select(TI).where(TI.dag_id == dag_id, TI.task_id
== "0"))
+
+ clear_task_instances([ti0], session, run_on_latest_version=False)
+ session.commit()
+
+ restored = session.scalar(select(TI).where(TI.dag_id == dag_id,
TI.task_id == "1"))
+ assert restored.state is None, "verify_integrity should have restored
it"
+ assert restored.dag_version_id == latest_dag_version.id
+
+ def
test_clear_task_instances_pins_unfinished_siblings_on_running_run(self,
dag_maker, session):
+ """A queued/running run is pinned without verify_integrity, so its
siblings need one too."""
+ dag_id = "test_clear_no_dag_version_running"
+ dr = self._make_versionless_run(
+ dag_maker,
+ session,
+ dag_id,
+ DagRunState.RUNNING,
+ task_count=2,
+ sibling_state=TaskInstanceState.SCHEDULED,
+ )
+ latest_dag_version = DagVersion.get_latest_version(dr.dag_id)
+ ti0 = session.scalar(select(TI).where(TI.dag_id == dag_id, TI.task_id
== "0"))
+
+ clear_task_instances([ti0], session, run_on_latest_version=False)
+ session.commit()
+
+ dr_after = session.scalar(select(DagRun).where(DagRun.dag_id ==
dag_id))
+ assert dr_after.created_dag_version_id == latest_dag_version.id
+ sibling = session.scalar(select(TI).where(TI.dag_id == dag_id,
TI.task_id == "1"))
+ assert sibling.dag_version_id == latest_dag_version.id
+
+ def test_clear_task_instances_keeps_run_and_task_versions_together(self,
dag_maker, session):
+ """A run pinned to a version must not leave its cleared task instances
on another."""
+ dag_id = "test_clear_backfilled_ti_null_run"
+ with dag_maker(dag_id, start_date=DEFAULT_DATE, catchup=True,
bundle_version="v1") as dag:
+ task0 = EmptyOperator(task_id="0")
+ dr = dag_maker.create_dagrun(state=State.RUNNING,
run_type=DagRunType.SCHEDULED)
+ (ti0,) = dr.task_instances
+ ti0.refresh_from_task(dag.get_task("0"))
+ run_task_instance(ti0, task0)
+ dr.state = DagRunState.SUCCESS
+ session.flush()
+
+ # The task instance keeps a version while the run loses its own, so
the run counts as
+ # version-less and gets forced onto the latest.
+ old_dag_version = DagVersion.get_latest_version(dag_id)
+ session.execute(update(DagRun).where(DagRun.id ==
dr.id).values(created_dag_version_id=None))
+ session.commit()
+ session.expire_all()
+
+ with dag_maker(dag_id, start_date=DEFAULT_DATE, catchup=True,
bundle_version="v2"):
+ EmptyOperator(task_id="0")
+ new_dag_version = DagVersion.get_latest_version(dag_id)
+ assert old_dag_version.id != new_dag_version.id, "Pre-condition"
+
+ ti0 = session.scalar(select(TI).where(TI.dag_id == dag_id))
+ assert ti0.dag_version_id == old_dag_version.id, "Pre-condition"
+ assert ti0.dag_run.created_dag_version_id is None, "Pre-condition"
+
+ clear_task_instances([ti0], session, run_on_latest_version=False)
+ session.commit()
+
+ dr_after = session.scalar(select(DagRun).where(DagRun.dag_id ==
dag_id))
+ ti_after = session.scalar(select(TI).where(TI.dag_id == dag_id))
+ assert dr_after.created_dag_version_id == new_dag_version.id
+ assert ti_after.dag_version_id == dr_after.created_dag_version_id, (
+ "the run and its task instance must end up on the same version"
+ )
+
+ def
test_clear_task_instances_moves_versionless_task_to_its_run_version(self,
dag_maker, session):
+ """A version-less task instance on a pinned run joins the run, not the
latest version."""
+ dag_id = "test_clear_versionless_ti_pinned_run"
+ with dag_maker(dag_id, start_date=DEFAULT_DATE, catchup=True,
bundle_version="v1") as dag:
+ task0 = EmptyOperator(task_id="0")
+ EmptyOperator(task_id="1")
+ dr = dag_maker.create_dagrun(state=State.RUNNING,
run_type=DagRunType.SCHEDULED)
+ ti0, ti1 = sorted(dr.task_instances, key=lambda ti: ti.task_id)
+ ti0.refresh_from_task(dag.get_task("0"))
+ run_task_instance(ti0, task0)
+ ti1.state = TaskInstanceState.SUCCESS
+ dr.state = DagRunState.SUCCESS
+ session.flush()
+
+ # An Airflow 2 task instance that an earlier clear left behind: it was
already finished, so
+ # pinning the run did not give it a version.
+ run_dag_version = DagVersion.get_latest_version(dag_id)
+ session.execute(update(TI).where(TI.dag_id == dag_id, TI.task_id ==
"1").values(dag_version_id=None))
+ session.commit()
+ session.expire_all()
+
+ with dag_maker(dag_id, start_date=DEFAULT_DATE, catchup=True,
bundle_version="v2"):
+ EmptyOperator(task_id="0")
+ EmptyOperator(task_id="1")
+ assert DagVersion.get_latest_version(dag_id).id != run_dag_version.id,
"Pre-condition"
+
+ ti1 = session.scalar(select(TI).where(TI.dag_id == dag_id, TI.task_id
== "1"))
+ assert ti1.dag_version_id is None, "Pre-condition"
+ assert ti1.dag_run.created_dag_version_id == run_dag_version.id,
"Pre-condition"
+
+ clear_task_instances([ti1], session, run_on_latest_version=False)
+ session.commit()
+
+ dr_after = session.scalar(select(DagRun).where(DagRun.dag_id ==
dag_id))
+ ti1_after = session.scalar(select(TI).where(TI.dag_id == dag_id,
TI.task_id == "1"))
+ assert dr_after.created_dag_version_id == run_dag_version.id
+ assert ti1_after.dag_version_id == run_dag_version.id, (
+ "the run and its task instance must end up on the same version"
+ )
+
def test_clear_subset_run_on_latest_version_only_updates_cleared_tis(self,
dag_maker, session):
"""run_on_latest_version on a finished DR must not rewrite
dag_version_id on TIs that were not cleared."""
with dag_maker(