Repository navigation
Keep Dag and deadline list serialization off the API server event loop - #76525
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
henry3260
left a comment
There was a problem hiding this comment.
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 withjoinedloador
builds the items first, or the response model reads columns only.
You're right, sorry for the misleading, fixed. |
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
left a comment
There was a problem hiding this comment.
I just pushed a small adjustment commit, but looking good overall.
Thanks for the fix. 👍
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.
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.
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.
Part of #76425 (the
GET /api/v2/dagsitem, plus the other list routes with the same shape).GET /dags,PATCH /dagsand the UIGET /dags/{dag_id}/dagRuns/{dag_run_id}/deadlinespassed ORM rows into anIterable[...]response field. Pydantic validatesIterableitems lazily, while FastAPI serializes the response on the event loop, so each Dag'stags(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 /dagsalso eager-loads the tags withselectinload, asGET /dagsalready 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 theevent_loop_queriesfixture. 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 unconsumedScalarResult, but nothing is left to query while they serialize: the route either eager-loads withjoinedloador builds the items first, or the response model reads columns only.Tests
event_loop_queries:test_get_dags_should_not_query_on_event_loop,test_patch_dags_should_not_query_tags_on_event_loopand, for deadlines in multi-team mode,test_should_not_query_on_event_loop. All three fail without the fix, each listing thedag_tagor team query that ran on the loop.public/test_dags.py,ui/test_deadlines.py,ui/test_dags.pyandui/test_calendar.pypass (373 passed, 6 skipped), and the recording plugin reports no non-audit statements on the event loop for them.DAGCollectionResponseandDeadlineCollectionResponseis unchanged (Iterableandlistboth render as an array), so the generated OpenAPI spec and UI client don't change.The
PATCH /dagstest only checks fordag_tagstatements, sinceaction_loggingwrites 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?
Generated-by: Claude Code (Claude Opus 5.5) following the guidelines