Skip to content

Release DB connection before deserializing DAGs in grid structure endpoint#69832

Open
seanmuth wants to merge 3 commits into
apache:mainfrom
seanmuth:fix/grid-structure-idle-in-tx-65712
Open

Release DB connection before deserializing DAGs in grid structure endpoint#69832
seanmuth wants to merge 3 commits into
apache:mainfrom
seanmuth:fix/grid-structure-idle-in-tx-65712

Conversation

@seanmuth

@seanmuth seanmuth commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Overview

get_dag_structure (GET /ui/grid/structure/{dag_id}) streams historical SerializedDagModel rows with a yield_per server-side cursor while performing CPU-bound serdag.dag deserialization and task-group merging between fetches. Because the cursor stays open across that work, the read transaction — and, under PgBouncer transaction pooling, the pooled server connection — stays pinned for the entire render of a large DAG.

For DAGs with many tasks and many historical versions this holds a connection open for a long time (multiple minutes in the field). At scale that exhausts the PgBouncer pool and starves task-instance heartbeat updates, contributing to the metadata-DB contention tracked in #65712.

Why this matters

The UI API and the execution API are served by the same api-server process and share a single metadata-DB connection pool. A UI endpoint that holds a pooled connection across a multi-minute render therefore directly starves execution-API calls — task-SDK heartbeats and state updates — competing for that pool. When those heartbeat/state calls are delayed, workers time out and retry, which piles more load onto the same pool. Keeping UI read handlers from holding connections across CPU-bound work is what prevents UI traffic from degrading task execution.

Change

Materialize the (page-bounded) set of historical serialized DAGs, detach them from the session, and commit the read transaction so the connection returns to the pool before the deserialization/merge loop runs.

SerializedDagModel.dag only reads the already-loaded data column, so deserialization works on detached instances with no further DB access. The result set is bounded by this endpoint's page of runs (a single DAG's versions), so materializing it is a safe trade for not holding the connection open across CPU-bound work.

This mirrors the per-unit session-release pattern already applied to the streaming ti_summaries endpoint in #65010.

Related

Testing

  • Existing grid structure endpoint tests pass, including test_structure_includes_historical_removed_task_with_proper_shape, which exercises the historical-versions loop that was changed.

🤖 Generated with Claude Code — Claude Opus 4.8 (1M context)

…point

`get_dag_structure` (`GET /ui/grid/structure/{dag_id}`) streamed historical
`SerializedDagModel` rows with a `yield_per` server-side cursor while running
CPU-bound `serdag.dag` deserialization and task-group merging between fetches.
That keeps the read transaction — and, under PgBouncer transaction pooling, the
pooled server connection — pinned for the entire render of a large DAG.

At scale this holds connections open for minutes, exhausting the PgBouncer pool
and starving task-instance heartbeats, contributing to the contention tracked in
apache#65712.

Materialize the (page-bounded) historical serialized DAGs, detach them, and
commit the read transaction so the connection returns to the pool *before* the
deserialization/merge. `SerializedDagModel.dag` only reads the already-loaded
`data` column, so it works on detached instances.

This mirrors the per-unit session-release pattern already applied to the
streaming `ti_summaries` endpoint in apache#65010.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py Outdated
Comment thread airflow-core/newsfragments/69832.bugfix.rst Outdated
Address review: instead of materializing all historical SerializedDagModel
rows at once (which regressed the yield_per=5 memory profile), fetch their ids
and process them in batches of 5, each batch loaded and detached in its own
short-lived session that is closed before the batch is deserialized.

This keeps peak memory bounded to one batch (matching the previous behaviour)
while still releasing the DB connection during the CPU-bound deserialization.
Also drop the newsfragment per review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
@potiuk

potiuk commented Jul 20, 2026

Copy link
Copy Markdown
Member

@seanmuth — There are 3 unresolved review thread(s) on this PR from @ephraimbuddy @kaxil. Could you either push a fix or reply in each thread explaining why the feedback doesn't apply? Once you believe the feedback is addressed, mark the thread as resolved so the reviewer isn't re-pinged needlessly. Thanks!


Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you.

Comment on lines +197 to +211
# Identify the historical serialized Dags for the visible runs (ids only, cheap),
# then process them in small batches so we never hold a DB connection open across
# the CPU-bound deserialization/merge below.
#
# Deserializing (``serdag.dag``) and merging a large DAG's task groups is
# expensive. Streaming the rows with a server-side cursor (``yield_per``) and
# deserializing between fetches keeps the transaction — and, under PgBouncer
# transaction pooling, the pooled server connection — pinned for the entire
# render; at scale that exhausts the pool and starves task-instance heartbeats.
# See https://github.com/apache/airflow/issues/65712.
#
# Each batch is loaded and detached in its own short-lived session that is closed
# before the batch is deserialized, so the connection is released during the CPU
# work while peak memory stays bounded to one batch (``SerializedDagModel.dag``
# only reads the already-loaded ``data`` column, so it works on detached rows).

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'm not sure this comment is really adding value... at least not here because, it immediately precedes a very simple query.

maybe just move this to the commit message? it's kinda high level rationale for why this change overall. the area where it sorta makes sense to have comments is below where we're batching things and we can state succinctly the rationale, which you kinda sorta already do.

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.

maybe just say

# we get the ids so that we can split serialization into batches and balance round trips and mem usage

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.

+1 this comment is too verbose

@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.

Very nice, thanks for the PR.

A couple suggestions / nits. (nothing blocking)

Tested the PR locally against a fictive database with a lot of historical versions. Can confirm the gain. (31s vs 0.22s for transaction age)

),
)
.execution_options(yield_per=5) # balance between peak memory usage and round trips
.order_by(SerializedDagModel.id)

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.

Why a new order_by? I believe this induce a behavior change in the ordering later on. I wouldn't add this if not necessary.

session.expunge(serdag) # to allow garbage collection
serdag_ids = list(session.scalars(serdag_id_query))
# Release the request session's transaction/connection before the batched work.
session.commit()

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.

session.close() reads better here and achieve the same

@pierrejeambrun pierrejeambrun added this to the Airflow 3.3.1 milestone Jul 23, 2026
@pierrejeambrun pierrejeambrun added the backport-to-v3-3-test Backport to v3-3-test label Jul 23, 2026
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 backport-to-v3-3-test Backport to v3-3-test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants