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 21c858eb67b Fix missing task.queued_duration metric in Airflow 3
(#67592)
21c858eb67b is described below
commit 21c858eb67bbdd9115d5a5dff64c1c720030d58e
Author: Tung, Hsiao-Yu <[email protected]>
AuthorDate: Wed Oct 14 08:29:54 2026 +0800
Fix missing task.queued_duration metric in Airflow 3 (#67592)
* Fix missing task.queued_duration metric in Airflow 3
task.queued_duration (and its registry-derived legacy name
dag.<dag_id>.<task_id>.queued_duration) stopped firing after the Airflow 3
worker switched to the Task SDK / supervisor / Execution API. The only emit
site was TaskInstance.emit_state_change_metric, reachable solely from the
legacy LocalTaskJob path, while Airflow 3 workers flip the TI to RUNNING
through the ti_run Execution API endpoint instead.
Emit it from ti_run on the genuine QUEUED -> RUNNING transition, tagged via
DagRun.stats_tags so the restored metric stays sliceable the same way as its
sibling task.scheduled_duration (dag_id, task_id, queue, run_type, team_name
under multi-team, and Dag tags when configured). Resumes from deferral are
skipped so a deferral cycle does not add a second sample inside the same
try.
closes: #63503
closes: #66067
* Pin the team_name tag on task.queued_duration under multi-team
With multi-team off the tag is absent from both the metric and any
expectation
derived from stats_tags, so the assertion held for the wrong reason and
would
not have caught the tag going missing.
* Describe what the queued_duration guard actually does
The comment claimed to mirror a legacy emit that fired on every transition
to
RUNNING; that emit skipped retries and fired on deferral resumes, so the
claim
was inverted on both paths. It also credited the trigger with setting
next_method, which the DEFERRED transition does, and asserted one sample
per try
without accounting for operators deferred before they are ever queued.
* Show start_from_trigger tasks emit no queued_duration
These operators are deferred straight from SCHEDULED without being queued,
so
the resume the next_method guard skips is the only queue wait they ever
have.
* Count task.queued_duration once per queue wait
A resume from deferral waits for a worker slot exactly as a first run does,
and
an operator with start_from_trigger=True never reaches the queue until that
resume, so suppressing it left the only wait those tasks have unmeasured.
Dag tags are eager-loaded because this path runs once per task start, where
the
lazy load stats_tags otherwise falls back to costs a query per task.
* Emit task.queued_duration only once the run request cannot fail
The SDK retries a 5xx from this route, and the rollback returns the task
instance to QUEUED for that retry to pick up, so a sample taken before the
response was settled counted a single queue wait twice.
Airflow 2 already reported the wait on deferral resumes; what changes for
anyone upgrading is retries and reschedule-mode pokes, so the newsfragment
names those instead.
---
airflow-core/newsfragments/67592.bugfix.rst | 1 +
.../execution_api/routes/task_instances.py | 39 ++-
.../versions/head/test_task_instances.py | 360 +++++++++++++++++++++
3 files changed, 399 insertions(+), 1 deletion(-)
diff --git a/airflow-core/newsfragments/67592.bugfix.rst
b/airflow-core/newsfragments/67592.bugfix.rst
new file mode 100644
index 00000000000..e9b1604aa6a
--- /dev/null
+++ b/airflow-core/newsfragments/67592.bugfix.rst
@@ -0,0 +1 @@
+Restore the ``task.queued_duration`` metric, which stopped being emitted when
Airflow 3 workers moved to the Task SDK, and record it once per queue wait.
Airflow 2 skipped retries and reschedule-mode sensor pokes, because
``end_date`` was still set from the previous attempt or poke, and both now emit
a sample of their own: a sensor poking every minute for an hour reports about
60 samples where Airflow 2 reported one. Alerts and dashboards carried over
from Airflow 2 that aggregate this t [...]
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 3f462882ed9..3f234b4c40b 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
@@ -43,6 +43,7 @@ from sqlalchemy.sql import select
from sqlalchemy.sql.dml import Update
from structlog.contextvars import bind_contextvars
+from airflow._shared.observability.metrics import stats
from airflow._shared.observability.traces import override_ids
from airflow._shared.state import TaskScope
from airflow._shared.timezones import timezone
@@ -184,6 +185,8 @@ def ti_run(
TI.try_number,
TI.max_tries,
TI.start_date,
+ TI.queue,
+ TI.queued_dttm,
TI.next_method,
TI.hostname,
TI.unixname,
@@ -225,6 +228,10 @@ def ti_run(
query = update(TI).where(TI.id == task_instance_id).values(data)
previous_state = ti.state
+ # Set on a genuine QUEUED -> RUNNING transition so task.queued_duration is
emitted once the
+ # DagRun is loaded below (its stats_tags supply the tags). A duplicate
start request falls
+ # through the branch below without raising, so the flag keeps it from
emitting twice.
+ emit_queued_duration = False
if previous_state == TaskInstanceState.RESTARTING and (ti.hostname,
ti.unixname, ti.pid) != (
ti_run_payload.hostname,
@@ -274,6 +281,13 @@ def ti_run(
extra=json.dumps({"host_name": ti_run_payload.hostname}) if
ti_run_payload.hostname else None,
)
)
+ # One sample per queue wait, not per try: the scheduler refreshes
queued_dttm on every
+ # queueing, so a retry and a resume from deferral each waited for a
slot of their own.
+ # task.scheduled_duration skips retries (emit_state_change_metric
returns early while
+ # end_date is set), so the two disagree on retries by design.
+ # queued_dttm is unset only for runs that skip the scheduler's
queueing, e.g. dag.test().
+ emit_queued_duration = ti.queued_dttm is not None
+
# Ensure there is no end date set and clear retry policy overrides from
the previous attempt.
query = query.values(
end_date=None,
@@ -290,11 +304,23 @@ def ti_run(
result = session.execute(query)
log.info("Task instance state updated", rows_affected=getattr(result,
"rowcount", 0))
+ # stats_tags lazy-loads dag_model.tags when dag tags are emitted as
metric tags, and ti_run
+ # runs once per task start -- load them up front as the scheduler loop
does.
+ dag_tag_options = (
+ (joinedload(DR.dag_model).selectinload(DagModel.tags),)
+ if emit_queued_duration and conf.getboolean("metrics",
"dag_tags_in_metrics", fallback=False)
+ else ()
+ )
+
dr = (
session.scalars(
select(DR)
.filter_by(dag_id=ti.dag_id, run_id=ti.run_id)
- .options(joinedload(DR.consumed_asset_events),
*eager_load_teams(DR.dag_model))
+ .options(
+ joinedload(DR.consumed_asset_events),
+ *eager_load_teams(DR.dag_model),
+ *dag_tag_options,
+ )
)
.unique()
.one_or_none()
@@ -393,6 +419,17 @@ def ti_run(
if token.claims.scope == "workload":
issue_execution_token(services, response, sub=str(task_instance_id))
+ if emit_queued_duration:
+ # Emitted last so a 5xx cannot double-count the wait: the SDK retries
those, and the
+ # rollback returns the TI to QUEUED for the retry to sample it again.
Only the session
+ # commit can still fail past this point.
+ # Tags mirror the sibling task.scheduled_duration, which
emit_state_change_metric sends
+ # as {**ti.stats_tags, "queue": ti.queue}; stats_tags reads the team
off the transient
+ # _team_name. stats.timing also emits the legacy dotted name from the
metrics registry.
+ dr._team_name = dr.team_name
+ tags = {**dr.stats_tags, "task_id": ti.task_id, "queue": ti.queue}
+ stats.timing("task.queued_duration", timezone.utcnow() -
ti.queued_dttm, tags=tags)
+
return context
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 40b7f029a50..2a6e703e098 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
@@ -1886,6 +1886,364 @@ class TestTIRunState:
assert logs[0].owner == ti.task.owner
assert logs[0].extra == '{"host_name": "random-hostname"}'
+ @pytest.mark.parametrize(
+ "scenario",
+ ["first_run", "retry", "deferral_resume"],
+ )
+ def test_ti_run_emits_queued_duration_metric(
+ self, client, session, create_task_instance, time_machine, scenario
+ ):
+ """task.queued_duration is emitted once per queue wait.
+
+ The scheduler refreshes queued_dttm every time it queues a task, so a
retry and a
+ resume from deferral each waited for a worker slot of their own and
must emit too. A
+ retry still carries the previous attempt's end_date on the row when
ti_run is reached,
+ so this also asserts the emit does not depend on end_date being unset.
+ """
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+
+ ti = create_task_instance(
+ task_id=f"test_ti_run_emits_queued_duration_metric_{scenario}",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=queued_at,
+ dag_id=str(uuid4()),
+ )
+ ti.queued_dttm = queued_at
+ ti.queue = "default"
+ if scenario == "retry":
+ # A retried TI still has the previous attempt's end_date set on
the row until
+ # ti_run clears it; the metric must fire regardless.
+ ti.end_date = queued_at.add(seconds=10)
+ elif scenario == "deferral_resume":
+ ti.next_method = "execute_complete"
+ session.commit()
+
+ # The metric has to stay sliceable the same way as its sibling
task.scheduled_duration,
+ # which emit_state_change_metric sends as {**ti.stats_tags, "queue":
ti.queue}. Deriving
+ # the expectation from that same expression makes the two drift apart
only if this fails.
+ expected_tags = {**ti.stats_tags, "queue": ti.queue}
+ assert "run_type" in expected_tags
+
+ time_machine.move_to(run_at, tick=False)
+
+ with mock.patch(
+ "airflow.api_fastapi.execution_api.routes.task_instances.stats",
autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ mock_stats.timing.assert_called_once_with(
+ "task.queued_duration",
+ run_at - queued_at,
+ tags=expected_tags,
+ )
+
+ @mock.patch(
+
"airflow.api_fastapi.execution_api.routes.task_instances.get_arg_bindings",
+ autospec=True,
+ return_value=[{"name": "country", "kind": "hologram", "value": "uk"}],
+ )
+ def test_ti_run_skips_queued_duration_metric_when_the_response_fails(
+ self, _, client, session, create_task_instance, time_machine
+ ):
+ """The SDK retries a 5xx, and the rollback leaves the TI in QUEUED for
that retry to pick
+ up, so a sample sent before the response is settled counts one queue
wait twice."""
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(run_at, tick=False)
+
+ ti = create_task_instance(
+
task_id="test_ti_run_skips_queued_duration_metric_when_the_response_fails",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=queued_at,
+ dag_id=str(uuid4()),
+ )
+ ti.queued_dttm = queued_at
+ session.commit()
+
+ with mock.patch(
+ "airflow.api_fastapi.execution_api.routes.task_instances.stats",
autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 500
+ assert response.json()["detail"]["reason"] == "invalid_arg_bindings"
+ mock_stats.timing.assert_not_called()
+
+ def test_ti_run_skips_queued_duration_metric_without_queued_dttm(
+ self, client, session, create_task_instance, time_machine
+ ):
+ """queued_dttm is what the wait is measured from, so a row without one
(a run that skips
+ the scheduler's queueing, e.g. dag.test()) has nothing to report."""
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(run_at, tick=False)
+
+ ti = create_task_instance(
+
task_id="test_ti_run_skips_queued_duration_metric_without_queued_dttm",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=queued_at,
+ dag_id=str(uuid4()),
+ )
+ ti.queued_dttm = None
+ session.commit()
+
+ with mock.patch(
+ "airflow.api_fastapi.execution_api.routes.task_instances.stats",
autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ mock_stats.timing.assert_not_called()
+
+ def test_ti_run_skips_queued_duration_metric_on_duplicate_start(
+ self, client, session, create_task_instance, time_machine
+ ):
+ """A start request replayed by the same worker after a network glitch
returns the same
+ 200 without re-running the transition, so it must not double-count the
queue wait."""
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(run_at, tick=False)
+
+ ti = create_task_instance(
+
task_id="test_ti_run_skips_queued_duration_metric_on_duplicate_start",
+ state=State.RUNNING,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ start_date=queued_at,
+ dag_id=str(uuid4()),
+ )
+ ti.queued_dttm = queued_at
+ ti.hostname = "random-hostname"
+ ti.unixname = "random-unixname"
+ ti.pid = 100
+ session.commit()
+
+ with mock.patch(
+ "airflow.api_fastapi.execution_api.routes.task_instances.stats",
autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ mock_stats.timing.assert_not_called()
+
+ def test_ti_run_queued_duration_metric_is_tagged_with_team_name(
+ self, client, session, dag_maker, time_machine
+ ):
+ """The team tag has to be pinned with multi-team on: with it off both
the metric and any
+ expectation derived from stats_tags omit the key, so they would agree
for the wrong reason.
+ """
+ from airflow.models.dagbundle import DagBundleModel
+ from airflow.models.team import Team
+
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(queued_at, tick=False)
+
+ dag_id = str(uuid4())
+ with dag_maker(dag_id=dag_id, session=session):
+ EmptyOperator(task_id="task")
+ dr = dag_maker.create_dagrun(
+ run_id="test", logical_date=queued_at, state=DagRunState.RUNNING,
start_date=queued_at
+ )
+ ti = dr.get_task_instance(task_id="task")
+ session.execute(
+ update(TaskInstance)
+ .where(TaskInstance.id == ti.id)
+ .values(state=State.QUEUED, queued_dttm=queued_at, queue="default")
+ )
+
+ bundle_name = f"bundle-{dag_id}"
+ team_name = f"team-{dag_id[:8]}"
+ bundle = DagBundleModel(name=bundle_name)
+ bundle.teams.append(Team(name=team_name))
+ session.add(bundle)
+ session.flush()
+ session.execute(update(DagModel).where(DagModel.dag_id ==
dag_id).values(bundle_name=bundle_name))
+ session.commit()
+
+ time_machine.move_to(run_at, tick=False)
+
+ with conf_vars({("core", "multi_team"): "True"}):
+ with mock.patch(
+
"airflow.api_fastapi.execution_api.routes.task_instances.stats", autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ mock_stats.timing.assert_called_once()
+ assert mock_stats.timing.call_args.kwargs["tags"]["team_name"] ==
team_name
+
+ def test_ti_run_queued_duration_metric_carries_dag_tags(self, client,
session, dag_maker, time_machine):
+ """With dag_tags_in_metrics on, stats_tags would lazy-load
dag_model.tags per task start;
+ the select eager-loads them instead, so the tags have to survive that
route."""
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(queued_at, tick=False)
+
+ dag_id = str(uuid4())
+ with dag_maker(dag_id=dag_id, tags=["critical", "env:prod"],
session=session):
+ EmptyOperator(task_id="task")
+ dr = dag_maker.create_dagrun(
+ run_id="test", logical_date=queued_at, state=DagRunState.RUNNING,
start_date=queued_at
+ )
+ ti = dr.get_task_instance(task_id="task")
+ session.execute(
+ update(TaskInstance)
+ .where(TaskInstance.id == ti.id)
+ .values(state=State.QUEUED, queued_dttm=queued_at, queue="default")
+ )
+ session.commit()
+
+ time_machine.move_to(run_at, tick=False)
+
+ with conf_vars({("metrics", "dag_tags_in_metrics"): "True"}):
+ with mock.patch(
+
"airflow.api_fastapi.execution_api.routes.task_instances.stats", autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ mock_stats.timing.assert_called_once()
+ tags = mock_stats.timing.call_args.kwargs["tags"]
+ assert (tags["critical"], tags["env"]) == ("", "prod")
+
+ def test_ti_run_emits_queued_duration_for_start_from_trigger(
+ self, client, session, dag_maker, time_machine
+ ):
+ """A start_from_trigger operator is deferred straight from SCHEDULED
and only reaches the
+ queue once the trigger fires, so that resume is the one wait it ever
has to report.
+ """
+ from airflow.sdk import BaseOperator
+ from airflow.triggers.base import StartTriggerArgs
+
+ class StartFromTriggerOperator(BaseOperator):
+ start_trigger_args = StartTriggerArgs(
+ trigger_cls="airflow.triggers.testing.SuccessTrigger",
+ trigger_kwargs={},
+ next_method="execute_complete",
+ timeout=None,
+ )
+ start_from_trigger = True
+
+ def execute_complete(self):
+ pass
+
+ queued_at = timezone.parse("2024-09-30T12:00:00Z")
+ run_at = queued_at.add(seconds=42)
+ time_machine.move_to(queued_at, tick=False)
+
+ with dag_maker(dag_id=str(uuid4()), session=session):
+ StartFromTriggerOperator(task_id="task")
+ dr = dag_maker.create_dagrun(run_id="test", logical_date=queued_at,
state=DagRunState.RUNNING)
+
+ ti = dr.get_task_instance(task_id="task")
+ ti.task = dr.dag.get_task("task")
+ assert dr.schedule_tis((ti,), session=session) == 0
+ session.merge(ti)
+ session.commit()
+
+ # Read the row back rather than the in-memory TI: what ti_run sees is
the persisted state,
+ # and the point of this test is that the deferral lands without the TI
ever being queued.
+ state, next_method, queued_dttm = session.execute(
+ select(TaskInstance.state, TaskInstance.next_method,
TaskInstance.queued_dttm).where(
+ TaskInstance.id == ti.id
+ )
+ ).one()
+ assert (state, next_method, queued_dttm) ==
(TaskInstanceState.DEFERRED, "execute_complete", None)
+
+ # The trigger fires and the scheduler queues it -- the first and only
real queue wait.
+ session.execute(
+ update(TaskInstance)
+ .where(TaskInstance.id == ti.id)
+ .values(state=State.QUEUED, queued_dttm=queued_at, queue="default")
+ )
+ session.commit()
+
+ time_machine.move_to(run_at, tick=False)
+
+ with mock.patch(
+ "airflow.api_fastapi.execution_api.routes.task_instances.stats",
autospec=True
+ ) as mock_stats:
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "random-hostname",
+ "unixname": "random-unixname",
+ "pid": 100,
+ "start_date": run_at.isoformat(),
+ },
+ )
+
+ assert response.status_code == 200
+ # Assert off the call rather than the in-memory TI, whose queue is
stale after the
+ # raw update above.
+ mock_stats.timing.assert_called_once()
+ assert mock_stats.timing.call_args.args == ("task.queued_duration",
run_at - queued_at)
+ assert mock_stats.timing.call_args.kwargs["tags"]["queue"] == "default"
+
class TestTIUpdateState:
def test_loop_continues_while_earlier_body_branch_is_running(self, client,
session, dag_maker):
@@ -3358,6 +3716,8 @@ class TestTIUpdateState:
try_number=1,
max_tries=0,
start_date=None,
+ queue="default",
+ queued_dttm=timezone.utcnow(),
next_method=None,
hostname=None,
unixname=None,