Skip to content

Keep Dag and deadline list serialization off the API server event loop - #76525

Merged
pierrejeambrun merged 5 commits into
apache:mainfrom
ccoliu:api-list-routes-off-event-loop
Nov 5, 2026
Merged

pierrejeambrun merged 5 commits into
apache:mainfrom
ccoliu:api-list-routes-off-event-loop

Conversation

@ccoliu

@ccoliu ccoliu commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Part of #76425 (the GET /api/v2/dags item, plus the other list routes with the same shape).

GET /dags, PATCH /dags and the UI GET /dags/{dag_id}/dagRuns/{dag_run_id}/deadlines passed ORM rows into an Iterable[...] response field. Pydantic validates Iterable items lazily, while FastAPI serializes the response on the event loop, so each Dag's tags (and, with [core] multi_team = True, each Dag run's team) were loaded there, on the sync engine. Once the connection pool is busy, that blocks the whole API server.

Following #74247, the collection fields are now list[...] and the routes validate the rows themselves, so the loads run in the threadpool with the rest of the route. PATCH /dags also eager-loads the tags with selectinload, as GET /dags already does, so they aren't loaded one Dag at a time.

How I found the affected routes

Rather than going by the shape of the code, I ran the existing tests of every list route (public/test_dags.py, test_connections.py, test_variables.py, test_dag_versions.py, test_dag_warning.py, test_pools.py, test_backfills.py, test_dag_run.py, test_task_instances.py, test_xcom.py, test_event_logs.py, test_import_error.py, test_job.py, test_assets.py, test_dag_bundles.py, test_hitl.py, ui/test_deadlines.py) with a plugin that records every statement executed on the event loop thread, the same check as the event_loop_queries fixture. Leaving out the async-engine audit log writes and the authorization lookups already tracked in #76425, the only remaining list-route hits were these three. Several of the other list routes pass the same unconsumed ScalarResult, but nothing is left to query while they serialize: the route either eager-loads with joinedload or builds the items first, or the response model reads columns only.

Tests

  • New regression tests use event_loop_queries: test_get_dags_should_not_query_on_event_loop, test_patch_dags_should_not_query_tags_on_event_loop and, for deadlines in multi-team mode, test_should_not_query_on_event_loop. All three fail without the fix, each listing the dag_tag or team query that ran on the loop.
  • With the fix, public/test_dags.py, ui/test_deadlines.py, ui/test_dags.py and ui/test_calendar.py pass (373 passed, 6 skipped), and the recording plugin reports no non-audit statements on the event loop for them.
  • The JSON schema of DAGCollectionResponse and DeadlineCollectionResponse is unchanged (Iterable and list both render as an array), so the generated OpenAPI spec and UI client don't change.

The PATCH /dags test only checks for dag_tag statements, since action_logging writes the audit log on the event loop through the async engine, which doesn't block it.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 5.5)

Generated-by: Claude Code (Claude Opus 5.5) following the guidelines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@henry3260 henry3260 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the PR description might mislead others. The last line of "How I found the
affected routes" says the other collection routes fetch their rows inside the route and
have no lazy relationships in their response models. But several of them pass the same
unconsumed ScalarResult as the three fixed here, and a few of those models do read a
relationship (dag_display_name through AliasPath("dag_model", ...)). What keeps them
off the loop is the route: joinedload, or building the items first, or a column-only
model.

Maybe reword it as:

Several of the other list routes pass the same unconsumed ScalarResult, but nothing is
left to query while they serialize: the route either eager-loads with joinedload or
builds the items first, or the response model reads columns only.

@henry3260 henry3260 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

@ccoliu

ccoliu commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

I think the PR description might mislead others. The last line of "How I found the affected routes" says the other collection routes fetch their rows inside the route and have no lazy relationships in their response models. But several of them pass the same unconsumed ScalarResult as the three fixed here, and a few of those models do read a relationship (dag_display_name through AliasPath("dag_model", ...)). What keeps them off the loop is the route: joinedload, or building the items first, or a column-only model.

Maybe reword it as:

Several of the other list routes pass the same unconsumed ScalarResult, but nothing is
left to query while they serialize: the route either eager-loads with joinedload or
builds the items first, or the response model reads columns only.

You're right, sorry for the misleading, fixed.

ccoliu and others added 2 commits November 5, 2026 22:52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list fields already make pydantic validate the items when the route builds the response, so validating each row in the route was redundant.

@pierrejeambrun pierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed a small adjustment commit, but looking good overall.

Thanks for the fix. 👍

@pierrejeambrun
pierrejeambrun enabled auto-merge (squash) November 5, 2026 15:29
@pierrejeambrun pierrejeambrun added this to the Airflow 3.4.1 milestone Nov 5, 2026
@pierrejeambrun
pierrejeambrun merged commit 24464f6 into apache:main Nov 5, 2026
72 checks passed
henry3260 added a commit to henry3260/airflow that referenced this pull request Nov 7, 2026
A response field annotated with Iterable is validated by pydantic lazily,
while FastAPI serializes the response on the event loop. Each item's
attributes are resolved there, on the sync engine: a lazy relationship or a
selectin post-load becomes a query that blocks the whole API server once
the connection pool is busy.

PoolCollectionResponse.pools was fixed by hand in apache#74247, which left an
explanatory comment in the datamodel, and the dags and deadlines fields in
apache#76525. Twenty fields still have the shape, and nothing stops a new one
from being added, because nothing about the annotation hints at the cost:
list[...] validates on construction, inside the route, and renders to the
same type: array in the OpenAPI schema, so the two read as interchangeable.

The known fields are grandfathered as strict xfails rather than fixed here,
so the list stays visible in the test report and can only shrink. The shape
of the check follows the one proposed for the same class of problem in
apache#76424.

Incomplete models are rebuilt before their fields are read. An annotation
naming something defined later in its own module stays an unparsed
ForwardRef that get_origin cannot see through, as AssetResponse.aliases
does, so a lazy field behind one would go unnoticed.
henry3260 added a commit to henry3260/airflow that referenced this pull request Nov 7, 2026
A response field annotated with Iterable is validated by pydantic lazily,
while FastAPI serializes the response on the event loop. Each item's
attributes are resolved there, on the sync engine: a lazy relationship or a
selectin post-load becomes a query that blocks the whole API server once
the connection pool is busy.

PoolCollectionResponse.pools was fixed by hand in apache#74247, which left an
explanatory comment in the datamodel, and the dags and deadlines fields in
apache#76525. Twenty fields still have the shape, and nothing stops a new one
from being added, because nothing about the annotation hints at the cost:
list[...] validates on construction, inside the route, and renders to the
same type: array in the OpenAPI schema, so the two read as interchangeable.

The known fields are grandfathered as strict xfails rather than fixed here,
so the list stays visible in the test report and can only shrink. The shape
of the check follows the one proposed for the same class of problem in
apache#76424.

Incomplete models are rebuilt before their fields are read. An annotation
naming something defined later in its own module stays an unparsed
ForwardRef that get_origin cannot see through, as AssetResponse.aliases
does, so a lazy field behind one would go unnoticed.
henry3260 added a commit to henry3260/airflow that referenced this pull request Nov 7, 2026
A response field annotated with Iterable is validated by pydantic lazily,
while FastAPI serializes the response on the event loop. Each item's
attributes are resolved there, on the sync engine: a lazy relationship or a
selectin post-load becomes a query that blocks the whole API server once
the connection pool is busy.

PoolCollectionResponse.pools was fixed by hand in apache#74247, which left an
explanatory comment in the datamodel, and the dags and deadlines fields in
apache#76525. Twenty fields still have the shape, and nothing stops a new one
from being added, because nothing about the annotation hints at the cost:
list[...] validates on construction, inside the route, and renders to the
same type: array in the OpenAPI schema, so the two read as interchangeable.

The known fields are grandfathered as strict xfails rather than fixed here,
so the list stays visible in the test report and can only shrink. The shape
of the check follows the one proposed for the same class of problem in
apache#76424.

Incomplete models are rebuilt before their fields are read. An annotation
naming something defined later in its own module stays an unparsed
ForwardRef that get_origin cannot see through, as AssetResponse.aliases
does, so a lazy field behind one would go unnoticed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:API Airflow's REST/HTTP API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants