This is an automated email from the ASF dual-hosted git repository.

bbovenzi 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 e4d0382fa18 Restore count to Dag Run and Task Instance lists (#71252)
e4d0382fa18 is described below

commit e4d0382fa1877c6b5185c6ed2b79c02c04fac23d
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Aug 12 16:05:40 2026 -0400

    Restore count to Dag Run and Task Instance lists (#71252)
    
    * add bounds to dag run and TI lists
    
    * Fix CI
---
 .../src/airflow/api_fastapi/common/db/common.py    | 30 +++++++++++++++
 .../api_fastapi/core_api/datamodels/dag_run.py     | 10 ++++-
 .../core_api/datamodels/task_instances.py          | 10 ++++-
 .../core_api/openapi/v2-rest-api-generated.yaml    | 44 ++++++++++++++++------
 .../api_fastapi/core_api/routes/public/dag_run.py  | 15 +++++++-
 .../core_api/routes/public/task_instances.py       | 19 ++++++++--
 .../api_fastapi/core_api/routes/ui/dashboard.py    |  5 +--
 .../ui/openapi-gen/queries/ensureQueryData.ts      | 10 +++--
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts | 10 +++--
 .../src/airflow/ui/openapi-gen/queries/queries.ts  | 10 +++--
 .../src/airflow/ui/openapi-gen/queries/suspense.ts | 10 +++--
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 28 +++++++++++++-
 .../ui/openapi-gen/requests/services.gen.ts        | 10 +++--
 .../airflow/ui/openapi-gen/requests/types.gen.ts   | 12 +++++-
 .../ui/src/components/DataTable/DataTable.test.tsx | 36 ++++++++++++++++++
 .../ui/src/components/DataTable/DataTable.tsx      | 12 ++++--
 .../ui/src/pages/Dag/Backfills/Backfills.test.tsx  |  1 +
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |  2 +
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   |  2 +
 .../core_api/routes/public/test_dag_run.py         | 31 ++++++++++++---
 .../core_api/routes/public/test_task_instances.py  | 22 ++++++++---
 .../src/airflowctl/api/datamodels/generated.py     | 18 ++++++++-
 22 files changed, 282 insertions(+), 65 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/db/common.py 
b/airflow-core/src/airflow/api_fastapi/common/db/common.py
index ff1e3f7043e..8abcaf59fa8 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/common.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/common.py
@@ -26,6 +26,7 @@ from collections.abc import AsyncGenerator, Generator, 
Sequence
 from typing import TYPE_CHECKING, Annotated, Literal, overload
 
 from fastapi import Depends
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import Session
 
@@ -37,6 +38,11 @@ if TYPE_CHECKING:
 
     from airflow.api_fastapi.core_api.base import OrmClause
 
+# Rows a single scan reads. Result sets that fit are counted exactly; wider 
ones report a floor.
+# Shared by the dashboard's historical metrics and by cursor-paginated 
listings, which surface an
+# item count without counting every matching row — the point of cursor 
pagination on large tables.
+EXACT_COUNT_LIMIT = 50_000
+
 
 def _get_session() -> Generator[Session, None, None]:
     with create_session(scoped=False) as session:
@@ -59,6 +65,30 @@ def apply_filters_to_select(
     return statement
 
 
+def bounded_total_entries(
+    *,
+    statement: Select,
+    filters: Sequence[OrmClause | None] | None = None,
+    session: Session,
+) -> tuple[int, int]:
+    """
+    Count the rows a cursor-paginated listing matches, reading at most 
``EXACT_COUNT_LIMIT``.
+
+    Returns ``(total, limit)`` where ``total`` is ``min(actual_count, 
EXACT_COUNT_LIMIT)`` — a
+    ``total`` equal to ``limit`` means only that at least that many rows match 
— and ``limit`` is
+    the cap that was applied, for the caller to surface as 
``total_entries_limit``.
+
+    The ``LIMIT`` sits inside the counted subquery so the database stops 
scanning once the cap is
+    reached, keeping the count cheap on tables that cursor pagination exists 
to handle. ORDER BY is
+    stripped for the same reason :func:`~airflow.utils.db.get_query_count` 
strips it: it cannot
+    change a count and only constrains the planner.
+    """
+    statement = apply_filters_to_select(statement=statement, filters=filters)
+    bounded = statement.order_by(None).limit(EXACT_COUNT_LIMIT).subquery()
+    total = session.scalar(select(func.count()).select_from(bounded)) or 0
+    return total, EXACT_COUNT_LIMIT
+
+
 async def _get_async_session() -> AsyncGenerator[AsyncSession, None]:
     async with create_session_async() as session:
         yield session
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
index d728f8fd0b0..1b1dd37eb9e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
@@ -202,8 +202,14 @@ class DAGRunCollectionResponse(BaseModel):
     dag_runs: Iterable[DAGRunResponse]
     total_entries: int | None = Field(
         default=None,
-        description="Total number of matching items. Populated for offset 
pagination, "
-        "``null`` when using cursor pagination.",
+        description="Number of matching items. For offset pagination this is 
the exact total. "
+        "For cursor pagination it is capped at ``total_entries_limit``; a 
value equal to that "
+        "limit means at least that many items match.",
+    )
+    total_entries_limit: int | None = Field(
+        default=None,
+        description="Cap applied to ``total_entries`` under cursor pagination. 
``null`` for offset "
+        "pagination, where ``total_entries`` is exact.",
     )
     next_cursor: str | None = Field(
         default=None,
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
index 4ea8a3c1f72..7166835ce61 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
@@ -107,8 +107,14 @@ class TaskInstanceCollectionResponse(BaseModel):
     task_instances: Iterable[TaskInstanceResponse]
     total_entries: int | None = Field(
         default=None,
-        description="Total number of matching items. Populated for offset 
pagination, "
-        "``null`` when using cursor pagination.",
+        description="Number of matching items. For offset pagination this is 
the exact total. "
+        "For cursor pagination it is capped at ``total_entries_limit``; a 
value equal to that "
+        "limit means at least that many items match.",
+    )
+    total_entries_limit: int | None = Field(
+        default=None,
+        description="Cap applied to ``total_entries`` under cursor pagination. 
``null`` for offset "
+        "pagination, where ``total_entries`` is exact.",
     )
     next_cursor: str | None = Field(
         default=None,
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index b3ed8a3a2b8..5200635ec9e 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -2270,8 +2270,11 @@ paths:
         **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor`
         from the response).
 
-        When `cursor` is provided, `offset` is ignored and `total_entries` is 
not
-        returned.
+        When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped
+        at
+
+        `total_entries_limit` (a value equal to that limit means at least that 
many
+        runs match).
 
         ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor``
         is ``null``
@@ -8175,13 +8178,16 @@ paths:
         **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor`
         from the response).
 
-        When `cursor` is provided, `offset` is ignored and `total_entries` is 
not
-        returned.
+        When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped
+        at
 
-        ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor``
-        is ``null``
+        `total_entries_limit` (a value equal to that limit means at least that 
many
+        task instances
 
-        on the first page.'
+        match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor``
+        is
+
+        ``null`` on the first page.'
       operationId: get_task_instances
       security:
       - OAuth2PasswordBearer: []
@@ -13410,8 +13416,16 @@ components:
           - type: integer
           - type: 'null'
           title: Total Entries
-          description: Total number of matching items. Populated for offset 
pagination,
-            ``null`` when using cursor pagination.
+          description: Number of matching items. For offset pagination this is 
the
+            exact total. For cursor pagination it is capped at 
``total_entries_limit``;
+            a value equal to that limit means at least that many items match.
+        total_entries_limit:
+          anyOf:
+          - type: integer
+          - type: 'null'
+          title: Total Entries Limit
+          description: Cap applied to ``total_entries`` under cursor 
pagination. ``null``
+            for offset pagination, where ``total_entries`` is exact.
         next_cursor:
           anyOf:
           - type: string
@@ -15422,8 +15436,16 @@ components:
           - type: integer
           - type: 'null'
           title: Total Entries
-          description: Total number of matching items. Populated for offset 
pagination,
-            ``null`` when using cursor pagination.
+          description: Number of matching items. For offset pagination this is 
the
+            exact total. For cursor pagination it is capped at 
``total_entries_limit``;
+            a value equal to that limit means at least that many items match.
+        total_entries_limit:
+          anyOf:
+          - type: integer
+          - type: 'null'
+          title: Total Entries Limit
+          description: Cap applied to ``total_entries`` under cursor 
pagination. ``null``
+            for offset pagination, where ``total_entries`` is exact.
         next_cursor:
           anyOf:
           - type: string
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index f2310f092d5..d43e3b56fd8 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -37,7 +37,12 @@ from airflow.api_fastapi.common.cursors import (
     parse_cursor,
 )
 from airflow.api_fastapi.common.dagbag import DagBagDep, get_dag_for_run, 
get_latest_version_of_dag
-from airflow.api_fastapi.common.db.common import SessionDep, 
apply_filters_to_select, paginated_select
+from airflow.api_fastapi.common.db.common import (
+    SessionDep,
+    apply_filters_to_select,
+    bounded_total_entries,
+    paginated_select,
+)
 from airflow.api_fastapi.common.db.dag_runs import (
     attach_dag_versions_to_runs,
     eager_load_dag_run_for_list,
@@ -587,7 +592,8 @@ def get_dag_runs(
     **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 
     **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-    When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
+    When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped at
+    `total_entries_limit` (a value equal to that limit means at least that 
many runs match).
     ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
     on the first page.
     """
@@ -696,8 +702,13 @@ def get_dag_runs(
 
         attach_dag_versions_to_runs(dag_runs, session=session)
 
+        total_entries, total_entries_limit = bounded_total_entries(
+            statement=query, filters=filters, session=session
+        )
         return DAGRunCollectionResponse(
             dag_runs=dag_runs,
+            total_entries=total_entries,
+            total_entries_limit=total_entries_limit,
             next_cursor=(encode_cursor(dag_runs[-1], order_by) if has_next and 
dag_runs else None),
             previous_cursor=(
                 make_backward_cursor(encode_cursor(dag_runs[0], order_by)) if 
has_prev and dag_runs else None
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index b5558746ff0..6ccb01dd68d 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -40,7 +40,12 @@ from airflow.api_fastapi.common.dagbag import (
     get_latest_version_of_dag,
     resolve_run_on_latest_version,
 )
-from airflow.api_fastapi.common.db.common import SessionDep, 
apply_filters_to_select, paginated_select
+from airflow.api_fastapi.common.db.common import (
+    SessionDep,
+    apply_filters_to_select,
+    bounded_total_entries,
+    paginated_select,
+)
 from airflow.api_fastapi.common.db.dags import eager_load_teams
 from airflow.api_fastapi.common.db.task_instances import 
eager_load_TI_and_TIH_for_validation
 from airflow.api_fastapi.common.parameters import (
@@ -547,9 +552,10 @@ def get_task_instances(
     **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 
     **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-    When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
-    ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-    on the first page.
+    When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped at
+    `total_entries_limit` (a value equal to that limit means at least that 
many task instances
+    match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+    ``null`` on the first page.
     """
     use_cursor = cursor is not None
     dag_run = None
@@ -642,8 +648,13 @@ def get_task_instances(
             has_prev = bool(cursor)
             has_next = has_more
 
+        total_entries, total_entries_limit = bounded_total_entries(
+            statement=query, filters=filters, session=session
+        )
         return TaskInstanceCollectionResponse(
             task_instances=task_instances,
+            total_entries=total_entries,
+            total_entries_limit=total_entries_limit,
             next_cursor=(
                 encode_cursor(task_instances[-1], order_by) if has_next and 
task_instances else None
             ),
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py
index 62c51c79d2e..483cbc009dc 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py
@@ -25,7 +25,7 @@ from sqlalchemy.sql.expression import case, false
 
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
-from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.db.common import EXACT_COUNT_LIMIT, SessionDep
 from airflow.api_fastapi.common.parameters import DateTimeQuery, 
OptionalDateTimeQuery
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.datamodels.ui.dashboard import (
@@ -44,9 +44,6 @@ if TYPE_CHECKING:
 
 dashboard_router = AirflowRouter(tags=["Dashboard"], prefix="/dashboard")
 
-# Rows a single scan reads. Windows that fit are counted exactly; wider ones 
report a floor.
-EXACT_COUNT_LIMIT = 50_000
-
 
 _ROUNDING = Context(prec=2, rounding=ROUND_FLOOR)
 
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index c7d379c85d9..8f7ed508d3a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -341,7 +341,8 @@ export const ensureUseDagRunServiceGetDagRunData = 
(queryClient: QueryClient, {
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
runs match).
 * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
 * on the first page.
 * @param data The data for the request.
@@ -1105,9 +1106,10 @@ export const 
ensureUseTaskInstanceServiceGetMappedTaskInstanceData = (queryClien
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
-* ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-* on the first page.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
task instances
+* match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+* ``null`` on the first page.
 * @param data The data for the request.
 * @param data.dagId
 * @param data.dagRunId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index f0af02f94f6..fb50dcc1247 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -341,7 +341,8 @@ export const prefetchUseDagRunServiceGetDagRun = 
(queryClient: QueryClient, { da
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
runs match).
 * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
 * on the first page.
 * @param data The data for the request.
@@ -1105,9 +1106,10 @@ export const 
prefetchUseTaskInstanceServiceGetMappedTaskInstance = (queryClient:
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
-* ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-* on the first page.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
task instances
+* match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+* ``null`` on the first page.
 * @param data The data for the request.
 * @param data.dagId
 * @param data.dagRunId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index 0c69edfd97b..f1d64923f48 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -341,7 +341,8 @@ export const useDagRunServiceGetDagRun = <TData = 
Common.DagRunServiceGetDagRunD
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
runs match).
 * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
 * on the first page.
 * @param data The data for the request.
@@ -1105,9 +1106,10 @@ export const useTaskInstanceServiceGetMappedTaskInstance 
= <TData = Common.TaskI
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
-* ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-* on the first page.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
task instances
+* match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+* ``null`` on the first page.
 * @param data The data for the request.
 * @param data.dagId
 * @param data.dagRunId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index ec1e3504f7d..1c0bb7d0033 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -341,7 +341,8 @@ export const useDagRunServiceGetDagRunSuspense = <TData = 
Common.DagRunServiceGe
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
runs match).
 * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
 * on the first page.
 * @param data The data for the request.
@@ -1105,9 +1106,10 @@ export const 
useTaskInstanceServiceGetMappedTaskInstanceSuspense = <TData = Comm
 * **Offset (default):** use `limit` and `offset` query parameters. Returns 
`total_entries`.
 *
 * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-* When `cursor` is provided, `offset` is ignored and `total_entries` is not 
returned.
-* ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-* on the first page.
+* When `cursor` is provided, `offset` is ignored and `total_entries` is capped 
at
+* `total_entries_limit` (a value equal to that limit means at least that many 
task instances
+* match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+* ``null`` on the first page.
 * @param data The data for the request.
 * @param data.dagId
 * @param data.dagRunId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 1a1f29b0951..d9afa239aa2 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -3640,7 +3640,19 @@ export const $DAGRunCollectionResponse = {
                 }
             ],
             title: 'Total Entries',
-            description: 'Total number of matching items. Populated for offset 
pagination, ``null`` when using cursor pagination.'
+            description: 'Number of matching items. For offset pagination this 
is the exact total. For cursor pagination it is capped at 
``total_entries_limit``; a value equal to that limit means at least that many 
items match.'
+        },
+        total_entries_limit: {
+            anyOf: [
+                {
+                    type: 'integer'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Total Entries Limit',
+            description: 'Cap applied to ``total_entries`` under cursor 
pagination. ``null`` for offset pagination, where ``total_entries`` is exact.'
         },
         next_cursor: {
             anyOf: [
@@ -6587,7 +6599,19 @@ export const $TaskInstanceCollectionResponse = {
                 }
             ],
             title: 'Total Entries',
-            description: 'Total number of matching items. Populated for offset 
pagination, ``null`` when using cursor pagination.'
+            description: 'Number of matching items. For offset pagination this 
is the exact total. For cursor pagination it is capped at 
``total_entries_limit``; a value equal to that limit means at least that many 
items match.'
+        },
+        total_entries_limit: {
+            anyOf: [
+                {
+                    type: 'integer'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Total Entries Limit',
+            description: 'Cap applied to ``total_entries`` under cursor 
pagination. ``null`` for offset pagination, where ``total_entries`` is exact.'
         },
         next_cursor: {
             anyOf: [
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 0fc5c1c220f..53f5da2de09 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -1103,7 +1103,8 @@ export class DagRunService {
      * **Offset (default):** use `limit` and `offset` query parameters. 
Returns `total_entries`.
      *
      * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-     * When `cursor` is provided, `offset` is ignored and `total_entries` is 
not returned.
+     * When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped at
+     * `total_entries_limit` (a value equal to that limit means at least that 
many runs match).
      * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
      * on the first page.
      * @param data The data for the request.
@@ -2700,9 +2701,10 @@ export class TaskInstanceService {
      * **Offset (default):** use `limit` and `offset` query parameters. 
Returns `total_entries`.
      *
      * **Cursor:** pass `cursor` (empty string for the first page, then 
`next_cursor` from the response).
-     * When `cursor` is provided, `offset` is ignored and `total_entries` is 
not returned.
-     * ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is ``null``
-     * on the first page.
+     * When `cursor` is provided, `offset` is ignored and `total_entries` is 
capped at
+     * `total_entries_limit` (a value equal to that limit means at least that 
many task instances
+     * match). ``next_cursor`` is ``null`` when there are no more pages; 
``previous_cursor`` is
+     * ``null`` on the first page.
      * @param data The data for the request.
      * @param data.dagId
      * @param data.dagRunId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 92b30f77199..cf8c86e997c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1002,9 +1002,13 @@ export type DAGRunClearBody = {
 export type DAGRunCollectionResponse = {
     dag_runs: Array<DAGRunResponse>;
     /**
-     * Total number of matching items. Populated for offset pagination, 
``null`` when using cursor pagination.
+     * Number of matching items. For offset pagination this is the exact 
total. For cursor pagination it is capped at ``total_entries_limit``; a value 
equal to that limit means at least that many items match.
      */
     total_entries?: number | null;
+    /**
+     * Cap applied to ``total_entries`` under cursor pagination. ``null`` for 
offset pagination, where ``total_entries`` is exact.
+     */
+    total_entries_limit?: number | null;
     /**
      * Token pointing to the next page. Populated for cursor pagination, 
``null`` when using offset pagination or when there is no next page.
      */
@@ -1758,9 +1762,13 @@ export type TaskInletAssetReference = {
 export type TaskInstanceCollectionResponse = {
     task_instances: Array<TaskInstanceResponse>;
     /**
-     * Total number of matching items. Populated for offset pagination, 
``null`` when using cursor pagination.
+     * Number of matching items. For offset pagination this is the exact 
total. For cursor pagination it is capped at ``total_entries_limit``; a value 
equal to that limit means at least that many items match.
      */
     total_entries?: number | null;
+    /**
+     * Cap applied to ``total_entries`` under cursor pagination. ``null`` for 
offset pagination, where ``total_entries`` is exact.
+     */
+    total_entries_limit?: number | null;
     /**
      * Token pointing to the next page. Populated for cursor pagination, 
``null`` when using offset pagination or when there is no next page.
      */
diff --git 
a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx 
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
index a4713410f36..aed86cfa8fb 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
@@ -183,6 +183,42 @@ describe("DataTable", () => {
     expect(screen.getByRole("heading")).toHaveTextContent("2 task");
   });
 
+  it("renders row count heading for cursor pagination when total is provided", 
() => {
+    render(
+      <DataTable
+        columns={columns}
+        data={data}
+        initialState={{ pagination, sorting: [] }}
+        modelName="task"
+        nextCursor="next"
+        onStateChange={onStateChange}
+        total={2}
+        totalEntriesLimit={50_000}
+      />,
+      { wrapper: ChakraWrapper },
+    );
+
+    expect(screen.getByRole("heading")).toHaveTextContent("2 task");
+  });
+
+  it("renders a capped row count heading when total reaches 
totalEntriesLimit", () => {
+    render(
+      <DataTable
+        columns={columns}
+        data={data}
+        initialState={{ pagination, sorting: [] }}
+        modelName="task"
+        nextCursor="next"
+        onStateChange={onStateChange}
+        total={50_000}
+        totalEntriesLimit={50_000}
+      />,
+      { wrapper: ChakraWrapper },
+    );
+
+    expect(screen.getByRole("heading")).toHaveTextContent("50,000+ task");
+  });
+
   it("does not render row count heading when showRowCountHeading is false", () 
=> {
     render(
       <DataTable
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx 
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
index 9adc95b6490..fbcec76a677 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -63,6 +63,7 @@ type DataTableProps<TData> = {
   readonly showRowCountHeading?: boolean;
   readonly skeletonCount?: number;
   readonly total?: number;
+  readonly totalEntriesLimit?: number;
 };
 
 const defaultGetRowCanExpand = () => false;
@@ -88,10 +89,11 @@ export const DataTable = <TData,>({
   showRowCountHeading = true,
   skeletonCount = 10,
   total = 0,
+  totalEntriesLimit,
 }: DataTableProps<TData>) => {
   "use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is 
resolved
 
-  const { t: translate } = useTranslation(["common"]);
+  const { i18n, t: translate } = useTranslation(["common"]);
   const ref = useRef<{ tableRef: TanStackTable<TData> | undefined }>({
     tableRef: undefined,
   });
@@ -169,13 +171,17 @@ export const DataTable = <TData,>({
     [modelName, translate],
   );
   const showRowCount = Boolean(
-    showRowCountHeading && !hasCursorPagination && !Boolean(isLoading) && 
!Boolean(isFetching) && total > 0,
+    showRowCountHeading && !Boolean(isLoading) && !Boolean(isFetching) && 
total > 0,
   );
+  // Cursor pagination reports the total capped at totalEntriesLimit, so a 
total that reaches the
+  // cap means "at least this many" and is rendered as "N+".
+  const isCapped = totalEntriesLimit !== undefined && total >= 
totalEntriesLimit;
+  const totalLabel = `${total.toLocaleString(i18n.language)}${isCapped ? "+" : 
""}`;
   const noRowsModelName = translateModelName(0);
 
   const rowCountHeading = showRowCount ? (
     <Heading py={3} size="md">
-      {`${total} ${translateModelName(total)}`}
+      {`${totalLabel} ${translateModelName(total)}`}
     </Heading>
   ) : undefined;
 
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
index 824121bb042..4e5a88f66a8 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
@@ -48,6 +48,7 @@ vi.mock("openapi/queries", () => ({
 
 vi.mock("react-i18next", () => ({
   useTranslation: () => ({
+    i18n: { language: "en" },
     // eslint-disable-next-line id-length
     t: (key: string, options?: { id?: number }) =>
       key === "components:backfill.viewSlots"
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index 26a5fd0ae93..eb868302896 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -387,6 +387,8 @@ export const DagRuns = () => {
         nextCursor={nextCursor}
         onStateChange={setTableURLState}
         previousCursor={previousCursor}
+        total={data?.total_entries ?? 0}
+        totalEntriesLimit={data?.total_entries_limit ?? undefined}
       />
       <ActionBar.Root closeOnInteractOutside={false} 
open={Boolean(selectedRows.size)}>
         <ActionBar.Content>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index 7f6a899adf6..a67e964d15d 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -427,6 +427,8 @@ export const TaskInstances = () => {
         nextCursor={nextCursor}
         onStateChange={setTableURLState}
         previousCursor={previousCursor}
+        total={data?.total_entries ?? 0}
+        totalEntriesLimit={data?.total_entries_limit ?? undefined}
       />
       <ActionBar.Root closeOnInteractOutside={false} 
open={Boolean(selectedRows.size)}>
         <ActionBar.Content>
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 974ebf82d3e..d574fc58637 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -678,7 +678,8 @@ class TestGetDagRuns:
         body = response.json()
         assert body["next_cursor"] is not None
         assert body["previous_cursor"] is None
-        assert body["total_entries"] is None
+        assert body["total_entries"] == 4
+        assert body["total_entries_limit"] == 50_000
         assert len(body["dag_runs"]) == 2
 
         response2 = test_client.get(
@@ -688,7 +689,8 @@ class TestGetDagRuns:
         assert response2.status_code == 200, response2.json()
         body2 = response2.json()
         assert body2["previous_cursor"] is not None
-        assert body2["total_entries"] is None
+        assert body2["total_entries"] == 4
+        assert body2["total_entries_limit"] == 50_000
         assert len(body2["dag_runs"]) == 2
         first_page_ids = {(r["dag_id"], r["dag_run_id"]) for r in 
body["dag_runs"]}
         second_page_ids = {(r["dag_id"], r["dag_run_id"]) for r in 
body2["dag_runs"]}
@@ -700,14 +702,15 @@ class TestGetDagRuns:
     )
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
     def test_cursor_pagination_returns_cursor_response(self, test_client, 
order_by):
-        """When cursor param is provided, response has cursor fields and no 
total_entries."""
+        """When cursor param is provided, response has cursor fields and a 
bounded total_entries."""
         response1 = test_client.get(
             "/dags/~/dagRuns",
             params={"limit": 2, "order_by": order_by, "cursor": ""},
         )
         assert response1.status_code == 200
         body1 = response1.json()
-        assert body1["total_entries"] is None
+        assert body1["total_entries"] == 4
+        assert body1["total_entries_limit"] == 50_000
         assert len(body1["dag_runs"]) == 2
         next_cursor = body1["next_cursor"]
         assert next_cursor is not None
@@ -721,7 +724,8 @@ class TestGetDagRuns:
         body2 = response2.json()
         assert body2["next_cursor"] is None
         assert body2["previous_cursor"] is not None
-        assert body2["total_entries"] is None
+        assert body2["total_entries"] == 4
+        assert body2["total_entries_limit"] == 50_000
 
     @pytest.mark.parametrize(
         "order_by",
@@ -744,7 +748,8 @@ class TestGetDagRuns:
             )
             assert response.status_code == 200, response.json()
             body = response.json()
-            assert body["total_entries"] is None
+            assert body["total_entries"] == total_runs
+            assert body["total_entries_limit"] == 50_000
             forward_pages.append(body)
             forward_ids.extend((r["dag_id"], r["dag_run_id"]) for r in 
body["dag_runs"])
 
@@ -779,6 +784,20 @@ class TestGetDagRuns:
         all_backward = backward_ids + [(r["dag_id"], r["dag_run_id"]) for r in 
forward_pages[-1]["dag_runs"]]
         assert all_backward == forward_ids
 
+    @mock.patch("airflow.api_fastapi.common.db.common.EXACT_COUNT_LIMIT", 2)
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_cursor_pagination_total_entries_capped(self, test_client):
+        """With more matching runs than the cap, total_entries is reported as 
the cap."""
+        response = test_client.get(
+            "/dags/~/dagRuns",
+            params={"limit": 1, "order_by": "id", "cursor": ""},
+        )
+        assert response.status_code == 200, response.json()
+        body = response.json()
+        # 4 runs match but the count stops scanning at the cap.
+        assert body["total_entries"] == 2
+        assert body["total_entries_limit"] == 2
+
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
     def test_cursor_pagination_invalid_token(self, test_client):
         response = test_client.get(
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index 6dc6ecac582..0efc3e003e0 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -2081,11 +2081,12 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
         body = response.json()
         assert body["next_cursor"] is not None
         assert body["previous_cursor"] is None
-        assert body["total_entries"] is None
+        assert body["total_entries"] == 5
+        assert body["total_entries_limit"] == 50_000
         assert len(body["task_instances"]) == 3
 
     def test_cursor_pagination_returns_cursor_response(self, test_client, 
session):
-        """When cursor param is provided, response has cursor fields and no 
total_entries."""
+        """When cursor param is provided, response has cursor fields and a 
bounded total_entries."""
         dag_id = "example_python_operator"
         self.create_task_instances(
             session,
@@ -2101,7 +2102,8 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
         )
         assert response1.status_code == 200
         body1 = response1.json()
-        assert body1["total_entries"] is None
+        assert body1["total_entries"] == 5
+        assert body1["total_entries_limit"] == 50_000
         assert len(body1["task_instances"]) == 3
         next_cursor = body1["next_cursor"]
         assert next_cursor is not None
@@ -2115,7 +2117,8 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
         body2 = response2.json()
         assert body2["next_cursor"] is None
         assert body2["previous_cursor"] is not None
-        assert body2["total_entries"] is None
+        assert body2["total_entries"] == 5
+        assert body2["total_entries_limit"] == 50_000
 
     def test_cursor_pagination_forward_and_backward_consistency(self, 
test_client, session):
         """Walk all pages forward via next_cursor, then backward via 
previous_cursor, and compare."""
@@ -2142,7 +2145,8 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
             )
             assert response.status_code == 200, response.json()
             body = response.json()
-            assert body["total_entries"] is None
+            assert body["total_entries"] == total_tis
+            assert body["total_entries_limit"] == 50_000
             forward_pages.append(body)
             forward_ids.extend(ti["id"] for ti in body["task_instances"])
 
@@ -4896,6 +4900,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
                 }
             ],
             "total_entries": 1,
+            "total_entries_limit": None,
             "next_cursor": None,
             "previous_cursor": None,
         }
@@ -5173,6 +5178,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
                         }
                     ],
                     "total_entries": 1,
+                    "total_entries_limit": None,
                     "next_cursor": None,
                     "previous_cursor": None,
                 },
@@ -5312,6 +5318,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
                 }
             ],
             "total_entries": 1,
+            "total_entries_limit": None,
             "next_cursor": None,
             "previous_cursor": None,
         }
@@ -5376,6 +5383,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
                 }
             ],
             "total_entries": 1,
+            "total_entries_limit": None,
             "next_cursor": None,
             "previous_cursor": None,
         }
@@ -5472,6 +5480,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
                     }
                 ],
                 "total_entries": 1,
+                "total_entries_limit": None,
                 "next_cursor": None,
                 "previous_cursor": None,
             }
@@ -5752,6 +5761,7 @@ class 
TestPatchTaskInstanceDryRun(TestTaskInstanceEndpoint):
                 }
             ],
             "total_entries": 1,
+            "total_entries_limit": None,
             "next_cursor": None,
             "previous_cursor": None,
         }
@@ -6041,6 +6051,7 @@ class 
TestPatchTaskInstanceDryRun(TestTaskInstanceEndpoint):
                         }
                     ],
                     "total_entries": 1,
+                    "total_entries_limit": None,
                     "next_cursor": None,
                     "previous_cursor": None,
                 },
@@ -6124,6 +6135,7 @@ class 
TestPatchTaskInstanceDryRun(TestTaskInstanceEndpoint):
         assert response.json() == {
             "task_instances": [],
             "total_entries": 0,
+            "total_entries_limit": None,
             "next_cursor": None,
             "previous_cursor": None,
         }
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index ca3c63d6168..3e9681b4c18 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -2368,10 +2368,17 @@ class DAGRunCollectionResponse(BaseModel):
     total_entries: Annotated[
         int | None,
         Field(
-            description="Total number of matching items. Populated for offset 
pagination, ``null`` when using cursor pagination.",
+            description="Number of matching items. For offset pagination this 
is the exact total. For cursor pagination it is capped at 
``total_entries_limit``; a value equal to that limit means at least that many 
items match.",
             title="Total Entries",
         ),
     ] = None
+    total_entries_limit: Annotated[
+        int | None,
+        Field(
+            description="Cap applied to ``total_entries`` under cursor 
pagination. ``null`` for offset pagination, where ``total_entries`` is exact.",
+            title="Total Entries Limit",
+        ),
+    ] = None
     next_cursor: Annotated[
         str | None,
         Field(
@@ -2491,10 +2498,17 @@ class TaskInstanceCollectionResponse(BaseModel):
     total_entries: Annotated[
         int | None,
         Field(
-            description="Total number of matching items. Populated for offset 
pagination, ``null`` when using cursor pagination.",
+            description="Number of matching items. For offset pagination this 
is the exact total. For cursor pagination it is capped at 
``total_entries_limit``; a value equal to that limit means at least that many 
items match.",
             title="Total Entries",
         ),
     ] = None
+    total_entries_limit: Annotated[
+        int | None,
+        Field(
+            description="Cap applied to ``total_entries`` under cursor 
pagination. ``null`` for offset pagination, where ``total_entries`` is exact.",
+            title="Total Entries Limit",
+        ),
+    ] = None
     next_cursor: Annotated[
         str | None,
         Field(

Reply via email to