This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 3af85e214f4 Avoid a supervisor round trip before poke for rescheduled
sensors (#68012)
3af85e214f4 is described below
commit 3af85e214f489748fd39dab9bbbfb99073ffc3b5
Author: Hemkumar Chheda <[email protected]>
AuthorDate: Mon Oct 12 18:12:56 2026 +0530
Avoid a supervisor round trip before poke for rescheduled sensors (#68012)
Rescheduled tasks fetched their first task reschedule start date from the
supervisor when RuntimeTaskInstance.get_first_reschedule_date() was called.
For sensors this happens before poke() starts, so a worker blocked on the
supervisor/API round trip before emitting the sensor's usual poke log line.
The ti_run endpoint already queries TaskReschedule for the reschedule count;
widen that to a single count + min(start_date) query and return the first
reschedule start date in the run context. The Task SDK uses the value when
present and keeps the supervisor request as a fallback for older servers.
closes: #68010
* Use min(start_date) for the task-reschedules start_date route
ti_run now returns the earliest reschedule start date via min(start_date).
Use the same definition in GET /task-reschedules/{ti_id}/start_date instead
of picking the first row by id, so the two cannot drift apart.
* Sync the Go SDK supervisor schema and models with the new run-context
field
The Task SDK supervisor schema gained first_task_reschedule_start_date, and
the Go SDK keeps a vendored copy of that schema plus models generated from it,
so the static checks failed until both were refreshed.
Generated-by: Claude Opus 5
* Add real-bundle downgrade test for the first reschedule start date field
AddFirstTaskRescheduleStartDateToSupervisorTIRunContext is didnt_exist-only,
so it is only visible through SchemaVersionMigrator.downgrade. Pin it the
same
way the arg_bindings and retry_reason changes are pinned.
---
.../execution_api/datamodels/taskinstance.py | 3 +
.../execution_api/routes/task_instances.py | 14 +-
.../execution_api/routes/task_reschedules.py | 11 +-
.../api_fastapi/execution_api/versions/__init__.py | 2 +
.../execution_api/versions/v2026_10_30.py | 10 ++
.../versions/head/test_task_instances.py | 80 +++++++++++-
.../versions/v2026_10_30/test_task_instances.py | 45 ++++++-
go-sdk/pkg/execution/genmodels/models.gen.go | 142 +++++++++++----------
go-sdk/schema/supervisor-schema.json | 13 ++
.../src/airflow/sdk/api/datamodels/_generated.py | 3 +
.../airflow/sdk/execution_time/schema/schema.json | 13 ++
.../sdk/execution_time/schema/versions/__init__.py | 2 +
.../execution_time/schema/versions/v2026_10_30.py | 16 +++
.../src/airflow/sdk/execution_time/task_runner.py | 5 +
task-sdk/tests/conftest.py | 6 +
.../execution_time/schema/test_migrator.py | 74 ++++++++++-
.../task_sdk/execution_time/test_task_runner.py | 18 ++-
ts-sdk/schema/supervisor-schema.json | 13 ++
ts-sdk/src/generated/supervisor.ts | 2 +
19 files changed, 384 insertions(+), 88 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
index 2d158476ada..81000c67d30 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
@@ -448,6 +448,9 @@ class TIRunContext(BaseModel):
task_reschedule_count: int = 0
"""How many times the task has been rescheduled."""
+ first_task_reschedule_start_date: UtcDateTime | None = None
+ """The first reschedule start date for the task instance, if it has been
rescheduled."""
+
max_tries: int
"""Maximum number of tries for the task instance (from DB)."""
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index cb42639c984..3f462882ed9 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -321,12 +321,12 @@ def ti_run(
read =
build_xcom_read_query(producer_ids=select(TI.id).where(TI.id ==
task_instance_id))
entity = xcom_entity(read)
xcom_keys =
list(session.scalars(read.with_only_columns(entity.key)))
- task_reschedule_count = (
- session.scalar(
-
select(func.count(TaskReschedule.id)).where(TaskReschedule.ti_id ==
task_instance_id)
- )
- or 0
- )
+ task_reschedule_count, first_task_reschedule_start_date =
session.execute(
+ select(
+ func.count(TaskReschedule.id),
+ func.min(TaskReschedule.start_date),
+ ).where(TaskReschedule.ti_id == task_instance_id)
+ ).one()
context = TIRunContext(
dag_run=dr,
@@ -352,6 +352,8 @@ def ti_run(
terminal_task_id=group.terminal_task_id,
terminal_is_mapped=terminal.get_needs_expansion(),
)
+ if first_task_reschedule_start_date is not None:
+ context.first_task_reschedule_start_date =
first_task_reschedule_start_date
# Only set for lang-SDK (foreign-runtime) tasks with a captured
TaskFlow arg
# spec; the route excludes unset fields, keeping regular responses
lean.
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_reschedules.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_reschedules.py
index c98d9417f50..02b7810681b 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_reschedules.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_reschedules.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Security, status
-from sqlalchemy import select
+from sqlalchemy import func, select
from airflow.api_fastapi.common.db.common import AsyncSessionDep
from airflow.api_fastapi.common.types import UtcDateTime
@@ -42,11 +42,6 @@ router = APIRouter(
@router.get("/{task_instance_id}/start_date")
async def get_start_date(task_instance_id: UUID, session: AsyncSessionDep) ->
UtcDateTime | None:
"""Get the first reschedule date if found, None if no records exist."""
- start_date = await session.scalar(
- select(TaskReschedule.start_date)
- .where(TaskReschedule.ti_id == task_instance_id)
- .order_by(TaskReschedule.id.asc())
- .limit(1)
+ return await session.scalar(
+ select(func.min(TaskReschedule.start_date)).where(TaskReschedule.ti_id
== task_instance_id)
)
-
- return start_date
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
index f23972db004..aa4d1028d2f 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
@@ -56,6 +56,7 @@ from airflow.api_fastapi.execution_api.versions.v2026_10_30
import (
AddArgBindingsToTIRunContext,
AddCallbackRunEndpoint,
AddDagRunNoteUpdateEndpoint,
+ AddFirstTaskRescheduleStartDateField,
AddLoopContext,
AddMultiTeamToTIRunContext,
AddStoppedTaskReport,
@@ -81,6 +82,7 @@ bundle = VersionBundle(
AddStoppedTaskReport,
AddTaskInstanceRegionCoordinates,
IdentifyArchivedTaskStateUpdates,
+ AddFirstTaskRescheduleStartDateField,
),
Version(
"2026-06-30",
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
index a75c7591982..ec4b6ddf125 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
@@ -133,3 +133,13 @@ class AddMultiTeamToTIRunContext(VersionChange):
def remove_multi_team_field(response: ResponseInfo) -> None: # type:
ignore[misc]
"""Strip ``multi_team`` from the run context for older clients."""
response.body.pop("multi_team", None)
+
+
+class AddFirstTaskRescheduleStartDateField(VersionChange):
+ """Add the ``first_task_reschedule_start_date`` field to TIRunContext."""
+
+ description = __doc__
+
+ instructions_to_migrate_to_previous_version = (
+
schema(TIRunContext).field("first_task_reschedule_start_date").didnt_exist,
+ )
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 584b112f213..40b7f029a50 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -21,7 +21,7 @@ import re
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import nullcontext
-from datetime import datetime
+from datetime import datetime, timedelta
from threading import Barrier, Event, local
from types import SimpleNamespace
from typing import TYPE_CHECKING
@@ -877,6 +877,60 @@ class TestTIRunState:
)
assert response.status_code == 409
+ def test_ti_run_state_includes_first_task_reschedule_start_date(
+ self,
+ client,
+ session,
+ create_task_instance,
+ ):
+ """Test that running a rescheduled Task Instance includes its first
reschedule start date."""
+ instant_str = "2024-09-30T12:00:00Z"
+ instant = timezone.parse(instant_str)
+ first_reschedule_start_date = timezone.datetime(2024, 9, 30, 10)
+ second_reschedule_start_date = timezone.datetime(2024, 9, 30, 11)
+
+ ti = create_task_instance(
+
task_id="test_ti_run_state_includes_first_task_reschedule_start_date",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=instant,
+ dag_id=str(uuid4()),
+ )
+ session.add_all(
+ [
+ TaskReschedule(
+ ti_id=ti.id,
+ start_date=second_reschedule_start_date,
+ end_date=timezone.datetime(2024, 9, 30, 11, 1),
+ reschedule_date=timezone.datetime(2024, 9, 30, 11, 2),
+ ),
+ TaskReschedule(
+ ti_id=ti.id,
+ start_date=first_reschedule_start_date,
+ end_date=timezone.datetime(2024, 9, 30, 10, 1),
+ reschedule_date=timezone.datetime(2024, 9, 30, 10, 2),
+ ),
+ ]
+ )
+ session.commit()
+
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": instant_str,
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["task_reschedule_count"] == 2
+ assert result["first_task_reschedule_start_date"] ==
"2024-09-30T10:00:00Z"
+
def test_ti_run_returns_execution_token(
self, client, exec_app, session, create_task_instance, time_machine
):
@@ -5254,6 +5308,30 @@ class TestGetRescheduleStartDate:
assert response.status_code == 200
assert response.json() == "2024-01-01T00:00:00Z"
+ def test_get_start_date_returns_earliest_not_first_inserted(self, client,
session, create_task_instance):
+ ti = create_task_instance(
+ task_id="test_get_start_date_returns_earliest_not_first_inserted",
+ state=State.RUNNING,
+ start_date=timezone.datetime(2024, 1, 1),
+ session=session,
+ )
+ # Insert the later reschedule first so that ordering by id and by
start_date disagree
+ for start_date in (timezone.datetime(2024, 1, 2),
timezone.datetime(2024, 1, 1)):
+ session.add(
+ TaskReschedule(
+ ti_id=ti.id,
+ start_date=start_date,
+ end_date=start_date + timedelta(hours=1),
+ reschedule_date=start_date + timedelta(hours=2),
+ )
+ )
+ session.flush()
+ session.commit()
+
+ response =
client.get(f"/execution/task-reschedules/{ti.id}/start_date")
+ assert response.status_code == 200
+ assert response.json() == "2024-01-01T00:00:00Z"
+
def test_get_start_date_not_found(self, client):
ti_id = "0182e924-0f1e-77e6-ab50-e977118bc139"
response =
client.get(f"/execution/task-reschedules/{ti_id}/start_date")
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py
index 55280830e14..3b4fcd392aa 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py
@@ -17,6 +17,8 @@
from __future__ import annotations
+from uuid import uuid4
+
import pytest
from sqlalchemy import select
@@ -28,7 +30,7 @@ from airflow.models.taskreschedule import TaskReschedule
from airflow.models.trigger import Trigger
from airflow.models.xcom import XComModelV2
from airflow.sdk import task
-from airflow.utils.state import State
+from airflow.utils.state import DagRunState, State
from tests_common.test_utils.db import clear_db_runs
@@ -224,7 +226,7 @@ RUN_PATCH_BODY = {
@pytest.fixture
def old_ver_client(client):
- """Execution API version immediately before ``arg_bindings`` was added."""
+ """Execution API version immediately before the ``2026-10-30`` fields were
added."""
client.headers["Airflow-API-Version"] = "2026-06-30"
return client
@@ -329,3 +331,42 @@ def test_mutations_after_retry_response_by_version(
stored = XComModelV2.get_for_attempt(ti.id, "extra_link", session=session)
assert (stored is not None) is xcom_kept
assert XComModelV2.get_for_attempt(successor.id, "extra_link",
session=session) is None
+
+
+class TestFirstTaskRescheduleStartDateBackwardCompat:
+ def setup_method(self):
+ clear_db_runs()
+
+ def teardown_method(self):
+ clear_db_runs()
+
+ def test_old_version_strips_first_task_reschedule_start_date(
+ self,
+ old_ver_client,
+ session,
+ create_task_instance,
+ ):
+ ti = create_task_instance(
+ task_id="test_first_task_reschedule_start_date_compat",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=timezone.datetime(2024, 9, 30, 12),
+ dag_id=str(uuid4()),
+ )
+ session.add(
+ TaskReschedule(
+ ti_id=ti.id,
+ start_date=timezone.datetime(2024, 9, 30, 10),
+ end_date=timezone.datetime(2024, 9, 30, 10, 1),
+ reschedule_date=timezone.datetime(2024, 9, 30, 10, 2),
+ )
+ )
+ session.commit()
+
+ response =
old_ver_client.patch(f"/execution/task-instances/{ti.id}/run",
json=RUN_PATCH_BODY)
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["task_reschedule_count"] == 1
+ assert "first_task_reschedule_start_date" not in result
diff --git a/go-sdk/pkg/execution/genmodels/models.gen.go
b/go-sdk/pkg/execution/genmodels/models.gen.go
index d7d779b15cf..6e53729efe9 100644
--- a/go-sdk/pkg/execution/genmodels/models.gen.go
+++ b/go-sdk/pkg/execution/genmodels/models.gen.go
@@ -1684,6 +1684,10 @@ type TIRunContext struct {
// DagRun corresponds to the JSON schema field "dag_run".
DagRun DagRun `msgpack:"dag_run"`
+ // FirstTaskRescheduleStartDate corresponds to the JSON schema field
+ // "first_task_reschedule_start_date".
+ FirstTaskRescheduleStartDate interface{}
`msgpack:"first_task_reschedule_start_date,omitempty"`
+
// Loop corresponds to the JSON schema field "loop".
Loop *LoopContext `msgpack:"loop,omitempty"`
@@ -1716,51 +1720,19 @@ type TIRunContext struct {
XcomKeysToClear []string `msgpack:"xcom_keys_to_clear,omitempty"`
}
-type TaskArgBinding interface{}
-
-type TaskBreadcrumbsResult struct {
- // Breadcrumbs corresponds to the JSON schema field "breadcrumbs".
- Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem
`msgpack:"breadcrumbs"`
-
- // Type corresponds to the JSON schema field "type".
- Type string `msgpack:"type,omitempty"`
-}
-
-type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{}
-
-// Task callback status information.
-//
-// A Class with information about the success/failure TI callback to be
executed.
-// Currently, only failure
-// callbacks when tasks are externally killed or experience heartbeat timeouts
are
-// run via DagFileProcessorProcess.
-type TaskCallbackRequest struct {
- // BundleName corresponds to the JSON schema field "bundle_name".
- BundleName string `msgpack:"bundle_name"`
-
- // BundleVersion corresponds to the JSON schema field "bundle_version".
- BundleVersion interface{} `msgpack:"bundle_version"`
-
- // ContextFromServer corresponds to the JSON schema field
"context_from_server".
- ContextFromServer *TIRunContext
`msgpack:"context_from_server,omitempty"`
-
- // Filepath corresponds to the JSON schema field "filepath".
- Filepath string `msgpack:"filepath"`
-
- // Msg corresponds to the JSON schema field "msg".
- Msg interface{} `msgpack:"msg,omitempty"`
+type TriggerKwargs map[string]interface{}
- // TaskCallbackType corresponds to the JSON schema field
"task_callback_type".
- TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"`
+type Warnings []interface{}
- // TI corresponds to the JSON schema field "ti".
- TI TaskInstance `msgpack:"ti"`
+type TaskArgBinding interface{}
- // Type corresponds to the JSON schema field "type".
- Type string `msgpack:"type,omitempty"`
+// Variable schema for responses with fields that are needed for Runtime.
+type VariableResponse struct {
+ // Key corresponds to the JSON schema field "key".
+ Key string `msgpack:"key"`
- // VersionData corresponds to the JSON schema field "version_data".
- VersionData *VersionData `msgpack:"version_data,omitempty"`
+ // Value corresponds to the JSON schema field "value".
+ Value interface{} `msgpack:"value"`
}
type TaskIds []string
@@ -1804,24 +1776,69 @@ type TaskInstance struct {
TryNumber int `msgpack:"try_number"`
}
-type TaskInstanceState string
-
const TaskInstanceStateAwaitingInput TaskInstanceState = "awaiting_input"
const TaskInstanceStateDeferred TaskInstanceState = "deferred"
+const TaskInstanceStateSkipped TaskInstanceState = "skipped"
+const TaskInstanceStateUpstreamFailed TaskInstanceState = "upstream_failed"
+const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule"
+const TaskInstanceStateUpForRetry TaskInstanceState = "up_for_retry"
const TaskInstanceStateFailed TaskInstanceState = "failed"
-const TaskInstanceStateQueued TaskInstanceState = "queued"
-const TaskInstanceStateRemoved TaskInstanceState = "removed"
const TaskInstanceStateRestarting TaskInstanceState = "restarting"
+const TaskInstanceStateSuccess TaskInstanceState = "success"
const TaskInstanceStateRunning TaskInstanceState = "running"
+const TaskInstanceStateQueued TaskInstanceState = "queued"
const TaskInstanceStateScheduled TaskInstanceState = "scheduled"
-const TaskInstanceStateSkipped TaskInstanceState = "skipped"
-const TaskInstanceStateSuccess TaskInstanceState = "success"
-const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule"
-const TaskInstanceStateUpForRetry TaskInstanceState = "up_for_retry"
-const TaskInstanceStateUpstreamFailed TaskInstanceState = "upstream_failed"
+const TaskInstanceStateRemoved TaskInstanceState = "removed"
+
+type TaskInstanceState string
type TaskOutlets []AssetProfile
+type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{}
+
+type TaskBreadcrumbsResult struct {
+ // Breadcrumbs corresponds to the JSON schema field "breadcrumbs".
+ Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem
`msgpack:"breadcrumbs"`
+
+ // Type corresponds to the JSON schema field "type".
+ Type string `msgpack:"type,omitempty"`
+}
+
+// Task callback status information.
+//
+// A Class with information about the success/failure TI callback to be
executed.
+// Currently, only failure
+// callbacks when tasks are externally killed or experience heartbeat timeouts
are
+// run via DagFileProcessorProcess.
+type TaskCallbackRequest struct {
+ // BundleName corresponds to the JSON schema field "bundle_name".
+ BundleName string `msgpack:"bundle_name"`
+
+ // BundleVersion corresponds to the JSON schema field "bundle_version".
+ BundleVersion interface{} `msgpack:"bundle_version"`
+
+ // ContextFromServer corresponds to the JSON schema field
"context_from_server".
+ ContextFromServer *TIRunContext
`msgpack:"context_from_server,omitempty"`
+
+ // Filepath corresponds to the JSON schema field "filepath".
+ Filepath string `msgpack:"filepath"`
+
+ // Msg corresponds to the JSON schema field "msg".
+ Msg interface{} `msgpack:"msg,omitempty"`
+
+ // TaskCallbackType corresponds to the JSON schema field
"task_callback_type".
+ TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"`
+
+ // TI corresponds to the JSON schema field "ti".
+ TI TaskInstance `msgpack:"ti"`
+
+ // Type corresponds to the JSON schema field "type".
+ Type string `msgpack:"type,omitempty"`
+
+ // VersionData corresponds to the JSON schema field "version_data".
+ VersionData *VersionData `msgpack:"version_data,omitempty"`
+}
+
// Response containing the first reschedule date for a task instance.
type TaskRescheduleStartDate struct {
// StartDate corresponds to the JSON schema field "start_date".
@@ -1831,6 +1848,12 @@ type TaskRescheduleStartDate struct {
Type string `msgpack:"type,omitempty"`
}
+type TaskStateState string
+
+const TaskStateStateFailed TaskStateState = "failed"
+const TaskStateStateSkipped TaskStateState = "skipped"
+const TaskStateStateRemoved TaskStateState = "removed"
+
// Update a task's state.
//
// If a process exits without sending one of these the state will be derived
from
@@ -1854,12 +1877,6 @@ type TaskState struct {
Type string `msgpack:"type,omitempty"`
}
-type TaskStateState string
-
-const TaskStateStateFailed TaskStateState = "failed"
-const TaskStateStateRemoved TaskStateState = "removed"
-const TaskStateStateSkipped TaskStateState = "skipped"
-
// Response to GetTaskStateStore; wraps the generated API response for
supervisor
// to worker comms.
type TaskStateStoreResult struct {
@@ -1880,19 +1897,6 @@ type TaskStatesResult struct {
Type string `msgpack:"type,omitempty"`
}
-// Variable schema for responses with fields that are needed for Runtime.
-type VariableResponse struct {
- // Key corresponds to the JSON schema field "key".
- Key string `msgpack:"key"`
-
- // Value corresponds to the JSON schema field "value".
- Value interface{} `msgpack:"value"`
-}
-
-type Warnings []interface{}
-
-type TriggerKwargs map[string]interface{}
-
type TriggerDagRun struct {
// Conf corresponds to the JSON schema field "conf".
Conf *Conf `msgpack:"conf,omitempty"`
diff --git a/go-sdk/schema/supervisor-schema.json
b/go-sdk/schema/supervisor-schema.json
index 7e09a736de7..77888c5adfd 100644
--- a/go-sdk/schema/supervisor-schema.json
+++ b/go-sdk/schema/supervisor-schema.json
@@ -5226,6 +5226,19 @@
"title": "Task Reschedule Count",
"type": "integer"
},
+ "first_task_reschedule_start_date": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "First Task Reschedule Start Date"
+ },
"max_tries": {
"title": "Max Tries",
"type": "integer"
diff --git a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
index d6e9e45f0d2..b5343588148 100644
--- a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
+++ b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
@@ -856,6 +856,9 @@ class TIRunContext(BaseModel):
dag_run: DagRun
loop: LoopContext | None = None
task_reschedule_count: Annotated[int | None, Field(title="Task Reschedule
Count")] = 0
+ first_task_reschedule_start_date: Annotated[
+ AwareDatetime | None, Field(title="First Task Reschedule Start Date")
+ ] = None
max_tries: Annotated[int, Field(title="Max Tries")]
variables: Annotated[list[VariableResponse] | None,
Field(title="Variables")] = None
connections: Annotated[list[ConnectionResponse] | None,
Field(title="Connections")] = None
diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
index 7e09a736de7..77888c5adfd 100644
--- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
+++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
@@ -5226,6 +5226,19 @@
"title": "Task Reschedule Count",
"type": "integer"
},
+ "first_task_reschedule_start_date": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "First Task Reschedule Start Date"
+ },
"max_tries": {
"title": "Max Tries",
"type": "integer"
diff --git
a/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py
b/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py
index 647e5dc9193..0f35e9f0e6f 100644
--- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py
+++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py
@@ -40,6 +40,7 @@ def get_bundle() -> VersionBundle:
from airflow.sdk.execution_time.schema.versions.v2026_10_30 import (
AddArgBindingsToSupervisorTIRunContext,
AddDagDefinitionsToDagFileParsingResult,
+ AddFirstTaskRescheduleStartDateToSupervisorTIRunContext,
AddLoopRuntime,
AddRegionCoordinatesToSupervisorTaskInstance,
AddRetryReasonToTaskState,
@@ -54,6 +55,7 @@ def get_bundle() -> VersionBundle:
AddRetryReasonToTaskState,
AddDagDefinitionsToDagFileParsingResult,
AddRegionCoordinatesToSupervisorTaskInstance,
+ AddFirstTaskRescheduleStartDateToSupervisorTIRunContext,
),
Version("2026-06-16"),
)
diff --git
a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py
b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py
index baffc8c9d0f..d1efcf0b8c3 100644
--- a/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py
+++ b/task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py
@@ -95,3 +95,19 @@ class AddDagDefinitionsToDagFileParsingResult(VersionChange):
schema(DagFileParsingResult).field("parsed_definitions").didnt_exist,
schema(DagFileParsingResult).field("dag_source_codes").didnt_exist,
)
+
+
+class AddFirstTaskRescheduleStartDateToSupervisorTIRunContext(VersionChange):
+ """
+ Add the ``first_task_reschedule_start_date`` field carrying the earliest
reschedule start date.
+
+ The supervisor-schema mirror of the execution API's
+ ``AddFirstTaskRescheduleStartDateField``, named apart so the two migrations
+ are not confused.
+ """
+
+ description = __doc__
+
+ instructions_to_migrate_to_previous_version = (
+
schema(TIRunContext).field("first_task_reschedule_start_date").didnt_exist,
+ )
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 652225b6673..b90ec01e21e 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -730,6 +730,11 @@ class RuntimeTaskInstance(TaskInstance):
# If the task has not been rescheduled, there is no need to ask
the supervisor
return None
+ if from_server := self._ti_context_from_server:
+ # Older servers don't send this field, so fall through to asking
the supervisor below.
+ if (first_reschedule_date :=
from_server.first_task_reschedule_start_date) is not None:
+ return first_reschedule_date
+
max_tries: int = self.max_tries
retries: int = self.task.retries or 0
first_try_number = max_tries - retries + 1
diff --git a/task-sdk/tests/conftest.py b/task-sdk/tests/conftest.py
index a7d14ef1b54..1fee940436c 100644
--- a/task-sdk/tests/conftest.py
+++ b/task-sdk/tests/conftest.py
@@ -239,6 +239,7 @@ class MakeTIContextCallable(Protocol):
run_after: str | datetime = ...,
run_type: str = ...,
task_reschedule_count: int = ...,
+ first_task_reschedule_start_date: str | datetime | None = ...,
conf: dict[str, Any] | None = ...,
should_retry: bool = ...,
max_tries: int = ...,
@@ -259,6 +260,7 @@ class MakeTIContextDictCallable(Protocol):
run_after: str | datetime = ...,
run_type: str = ...,
task_reschedule_count: int = ...,
+ first_task_reschedule_start_date: str | datetime | None = ...,
conf=None,
consumed_asset_events: Sequence[AssetEventDagRunReference] = ...,
) -> dict[str, Any]: ...
@@ -281,6 +283,7 @@ def make_ti_context() -> MakeTIContextCallable:
run_after: str | datetime = "2024-12-01T01:00:00Z",
run_type: str = "manual",
task_reschedule_count: int = 0,
+ first_task_reschedule_start_date: str | datetime | None = None,
conf: dict[str, Any] | None = None,
should_retry: bool = False,
max_tries: int = 0,
@@ -304,6 +307,7 @@ def make_ti_context() -> MakeTIContextCallable:
partition_key=None,
),
task_reschedule_count=task_reschedule_count,
+ first_task_reschedule_start_date=first_task_reschedule_start_date,
max_tries=max_tries,
should_retry=should_retry,
)
@@ -326,6 +330,7 @@ def make_ti_context_dict(make_ti_context:
MakeTIContextCallable) -> MakeTIContex
run_after: str | datetime = "2024-12-01T00:00:00Z",
run_type: str = "manual",
task_reschedule_count: int = 0,
+ first_task_reschedule_start_date: str | datetime | None = None,
conf=None,
consumed_asset_events: Sequence[AssetEventDagRunReference] = (),
) -> dict[str, Any]:
@@ -341,6 +346,7 @@ def make_ti_context_dict(make_ti_context:
MakeTIContextCallable) -> MakeTIContex
run_type=run_type,
conf=conf,
task_reschedule_count=task_reschedule_count,
+ first_task_reschedule_start_date=first_task_reschedule_start_date,
consumed_asset_events=consumed_asset_events,
)
return context.model_dump(exclude_unset=True, mode="json")
diff --git a/task-sdk/tests/task_sdk/execution_time/schema/test_migrator.py
b/task-sdk/tests/task_sdk/execution_time/schema/test_migrator.py
index a5ac1de22d2..e8c4a16aff2 100644
--- a/task-sdk/tests/task_sdk/execution_time/schema/test_migrator.py
+++ b/task-sdk/tests/task_sdk/execution_time/schema/test_migrator.py
@@ -28,8 +28,10 @@ schema diverges from head.
from __future__ import annotations
+import datetime
import subprocess
import sys
+import uuid
from typing import Literal
from uuid import UUID
@@ -50,13 +52,23 @@ from task_sdk.execution_time.schema._mock_version_bundle
import (
)
from airflow.sdk import TaskInstanceState
-from airflow.sdk.api.datamodels._generated import LoopContext,
PreviousTIResponse
+from airflow.sdk.api.datamodels._generated import (
+ BundleInfo,
+ DagRun,
+ DagRunState,
+ DagRunType,
+ LoopContext,
+ PreviousTIResponse,
+ TaskInstance,
+ TIRunContext,
+)
from airflow.sdk.execution_time.comms import (
GetXCom,
GetXComCount,
GetXComSequenceItem,
GetXComSequenceSlice,
PreviousTIResult,
+ StartupDetails,
TaskState,
)
from airflow.sdk.execution_time.schema import (
@@ -580,3 +592,63 @@ class TestRealBundleRetryReason:
body = {"type": "TaskState", "state": "failed", "end_date": None,
"rendered_map_index": None}
out = real_migrator.upgrade(body, TaskState, "2026-06-16")
assert out["retry_reason"] is None
+
+
+class TestRealBundleFirstTaskRescheduleStartDate:
+ """
+ Drive the *real* supervisor bundle through the
``first_task_reschedule_start_date`` migration.
+
+ ``AddFirstTaskRescheduleStartDateToSupervisorTIRunContext`` is
``didnt_exist``-only, so the
+ downgrade re-validation is the only place it is visible: dropping it from
the bundle, or
+ registering it under the wrong version, leaves the field on the wire for a
pinned runtime.
+ """
+
+ FIRST_RESCHEDULE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+
+ @pytest.fixture
+ def startup_details(self):
+ now = datetime.datetime.now(datetime.UTC)
+ return StartupDetails(
+ ti=TaskInstance(
+ id=uuid.uuid4(),
+ task_id="wait",
+ dag_id="d",
+ run_id="r",
+ try_number=1,
+ dag_version_id=uuid.uuid4(),
+ ),
+ dag_rel_path="d.py",
+ bundle_info=BundleInfo(name="b", version=None),
+ start_date=now,
+ ti_context=TIRunContext(
+ dag_run=DagRun(
+ dag_id="d",
+ run_id="r",
+ logical_date=now,
+ data_interval_start=None,
+ data_interval_end=None,
+ start_date=now,
+ end_date=None,
+ run_type=DagRunType.MANUAL,
+ state=DagRunState.RUNNING,
+ run_after=now,
+ consumed_asset_events=[],
+ partition_key=None,
+ ),
+ max_tries=1,
+ first_task_reschedule_start_date=self.FIRST_RESCHEDULE,
+ ),
+ sentry_integration="",
+ )
+
+ @pytest.fixture
+ def real_migrator(self) -> SchemaVersionMigrator:
+ return get_schema_version_migrator()
+
+ def test_downgrade_strips_field_for_previous_version(self, real_migrator,
startup_details):
+ out = real_migrator.downgrade(startup_details,
"2026-06-16").model_dump()
+ assert "first_task_reschedule_start_date" not in out["ti_context"]
+
+ def test_head_version_keeps_field(self, real_migrator, startup_details):
+ out = real_migrator.downgrade(startup_details, "2026-10-30")
+ assert out.ti_context.first_task_reschedule_start_date ==
self.FIRST_RESCHEDULE
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 8d4ade1a340..2cd63cce594 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -3594,7 +3594,7 @@ class TestRuntimeTaskInstance:
def test_get_first_reschedule_date(
self, create_runtime_ti, mock_supervisor_comms, task_reschedule_count,
expected_date
):
- """Test that the first reschedule date is fetched from the
Supervisor."""
+ """Test that the first reschedule date falls back to the Supervisor."""
task = BaseOperator(task_id="hello")
runtime_ti = create_runtime_ti(task=task,
task_reschedule_count=task_reschedule_count)
@@ -3605,6 +3605,22 @@ class TestRuntimeTaskInstance:
context = runtime_ti.get_template_context()
assert runtime_ti.get_first_reschedule_date(context=context) ==
expected_date
+ def test_get_first_reschedule_date_uses_context_from_server(
+ self, create_runtime_ti, make_ti_context, mock_supervisor_comms
+ ):
+ """Test that first reschedule date from server context avoids a
Supervisor request."""
+ first_reschedule_date = timezone.datetime(2025, 1, 1)
+ task = BaseOperator(task_id="hello")
+ runtime_ti = create_runtime_ti(task=task, task_reschedule_count=1)
+ runtime_ti._ti_context_from_server = make_ti_context(
+ task_reschedule_count=1,
+ first_task_reschedule_start_date=first_reschedule_date,
+ )
+
+ context = runtime_ti.get_template_context()
+ assert runtime_ti.get_first_reschedule_date(context=context) ==
first_reschedule_date
+ mock_supervisor_comms.send.assert_not_called()
+
def test_get_ti_count(self, mock_supervisor_comms):
"""Test that get_ti_count sends the correct request and returns the
count."""
mock_supervisor_comms.send.return_value = TICount(count=2)
diff --git a/ts-sdk/schema/supervisor-schema.json
b/ts-sdk/schema/supervisor-schema.json
index 7e09a736de7..77888c5adfd 100644
--- a/ts-sdk/schema/supervisor-schema.json
+++ b/ts-sdk/schema/supervisor-schema.json
@@ -5226,6 +5226,19 @@
"title": "Task Reschedule Count",
"type": "integer"
},
+ "first_task_reschedule_start_date": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "First Task Reschedule Start Date"
+ },
"max_tries": {
"title": "Max Tries",
"type": "integer"
diff --git a/ts-sdk/src/generated/supervisor.ts
b/ts-sdk/src/generated/supervisor.ts
index 31196f868a5..7faf8c48770 100644
--- a/ts-sdk/src/generated/supervisor.ts
+++ b/ts-sdk/src/generated/supervisor.ts
@@ -237,6 +237,7 @@ export type MaxIterations = number;
export type TerminalTaskId = string;
export type TerminalIsMapped = boolean;
export type TaskRescheduleCount = number;
+export type FirstTaskRescheduleStartDate = string | null;
export type MaxTries = number;
export type Key = string;
export type Value = string | null;
@@ -1049,6 +1050,7 @@ export interface TIRunContext {
dag_run: DagRun;
loop?: LoopContext | null;
task_reschedule_count?: TaskRescheduleCount;
+ first_task_reschedule_start_date?: FirstTaskRescheduleStartDate;
max_tries: MaxTries;
variables?: Variables;
connections?: Connections;