-
Notifications
You must be signed in to change notification settings - Fork 17.5k
Fix missing task.queued_duration metric in Airflow 3 #67592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f3fb97
bbb32fe
898be27
d78cd90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,7 @@ | |
| from sqlalchemy.sql import select | ||
| 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 | ||
|
|
@@ -159,6 +160,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, | ||
|
|
@@ -199,6 +202,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 we are already running, but this is a duplicate request from the same client return the same OK | ||
| # -- it's possible there was a network glitch and they never got the response | ||
|
|
@@ -242,6 +249,17 @@ def ti_run( | |
| extra=json.dumps({"host_name": ti_run_payload.hostname}) if ti_run_payload.hostname else None, | ||
| ) | ||
| ) | ||
| # Aims at one sample per try: the scheduler refreshes queued_dttm on every queueing, so a | ||
| # retry measures its own wait, while a resume from deferral is the same try continuing and | ||
| # is skipped via next_method (set on the DEFERRED transition, left in place until resume). | ||
| # Two known gaps in that reading: an operator with start_from_trigger=True is deferred | ||
| # straight from SCHEDULED without ever being queued, so the resume leg skipped here is its | ||
| # only real wait and it goes unmeasured; and this disagrees with task.scheduled_duration on | ||
| # retries, which emit_state_change_metric skips because the previous attempt's end_date is | ||
| # still on the row when the scheduler queues the TI. | ||
| # queued_dttm is None only in rare races and test setups. | ||
| emit_queued_duration = ti.queued_dttm is not None and ti.next_method is None | ||
|
|
||
| # Ensure there is no end date set and clear retry policy overrides from the previous attempt. | ||
| query = query.values( | ||
| end_date=None, | ||
|
|
@@ -297,7 +315,21 @@ def ti_run( | |
| or 0 | ||
| ) | ||
|
|
||
| dr.team_name = get_team_name_for_ti(task_instance_id, session) | ||
| team_name = get_team_name_for_ti(task_instance_id, session) | ||
| dr.team_name = team_name | ||
|
|
||
| if emit_queued_duration: | ||
| # Tag via dr.stats_tags so this stays sliceable the same way as its sibling | ||
| # task.scheduled_duration, which emit_state_change_metric sends as | ||
| # {**ti.stats_tags, "queue": ti.queue} -- that is dag_run.stats_tags plus task_id. | ||
| # Team lives on the Bundle rather than the DagRun schema, so stats_tags cannot resolve | ||
| # it here; add the value looked up above instead. Falsy values are pruned from | ||
| # stats_tags, so only set it when there is a team. The registry-derived legacy name | ||
| # dag.<dag_id>.<task_id>.queued_duration is emitted by stats.timing automatically. | ||
| tags = {**dr.stats_tags, "task_id": ti.task_id, "queue": ti.queue} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Non-blocking, and the config is off by default.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — the earlier select takes |
||
| if team_name: | ||
| tags["team_name"] = team_name | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On the On the test: yes please, pin the multi-team leg with
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test added with
|
||
| stats.timing("task.queued_duration", timezone.utcnow() - ti.queued_dttm, tags=tags) | ||
|
|
||
| context = TIRunContext( | ||
| dag_run=dr, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This guard came in at @henry3260's request and it does what it says, but it leaves
task.queued_durationand its siblingtask.scheduled_durationdisagreeing about which transitions count, in opposite directions. Taking the three ways a TI reaches RUNNING:queued_duration.schedule_tissetsSCHEDULED,scheduled_dttmandtry_numberwithout clearingend_date, so the previous attempt'send_dateis still on the row when the scheduler queues the TI, andemit_state_change_metricreturns early.scheduled_duration. TheDEFERREDupdate never touchesend_dateandti_runalready set it toNone, so that guard passes, and the trigger refreshesscheduled_dttm, so the sample is a real measurement.The resume's queue wait is equally real:
queued_dttmis refreshed on every queueing and the critical section selectsSCHEDULEDwith nonext_methodfilter, so a deferrable task sits in QUEUED again waiting for a worker slot beforeexecute_completeruns. Skipping it means that wait is never measured, and for sensor-heavy deployments the resume leg is where most of the queue time lives.I would drop
and ti.next_method is Noneand emit per queue wait, which is also where @ashb'send_datechange pointed: one sample per real wait rather than one per try. The per-try reading is defensible too, but then the retry case should not emit either, and the comment should name the axis so the asymmetry reads as deliberate.Either answer works for me, and this is the only thing I would like settled before merge. Adding these samples after release shifts percentiles for anyone alerting on the timer, which is cheap to decide now and awkward to change later.
On why this did not come up in my last pass: I was looking at the tag set then, and only walked the resume path through the scheduler this time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start_from_trigger=Trueoperators are deferred straight from SCHEDULED, never queued, sothe resume this guard skips is their only queue wait — they emit nothing at all today.
Test added.
Also fixed my comment: legacy skipped retries and emitted on resume (it runs before
end_dateis cleared), the inverse of what I claimed.Per queue wait is the only axis that measures those, so that's my vote — your call.