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

pierrejeambrun pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new c183d07374a Fix cursor pagination dropping rows when sorting by a 
nullable column (#68869) (#70739)
c183d07374a is described below

commit c183d07374a6d3a8b859cdfa8738175bfec9b2a3
Author: Rahul Vats <[email protected]>
AuthorDate: Thu Jul 30 21:50:21 2026 +0530

    Fix cursor pagination dropping rows when sorting by a nullable column 
(#68869) (#70739)
    
    * Fix cursor pagination dropping rows when sorting by a nullable column
    
    Cursor (keyset) paginated REST list endpoints silently dropped rows when the
    sort column was nullable. The keyset predicate and the generated ORDER BY
    disagreed on where NULLs sort, so once a page boundary fell on the 
NULL/non-NULL
    edge every row on one side of it was skipped with no error. This is silent 
data
    loss from the public API under a common sort such as start_date.
    
    Cross-dialect NULLS FIRST/LAST is not portable (unsupported on MySQL and old
    SQLite), so the cursor path now pins NULL placement with a portable 
CASE-based
    null-rank key shared by both the keyset ORDER BY and the keyset predicate, 
so the
    two can no longer disagree on any backend. The rank follows the column's 
sort
    direction, so NULLs order as the largest value (last when ascending, first 
when
    descending), matching PostgreSQL's default. PostgreSQL result order is 
therefore
    unchanged; SQLite and MySQL NULL ordering shifts to align with it. The 
cursor
    token format is unchanged (the rank is derived, not encoded) and offset
    pagination is untouched.
    
    * Add newsfragment for nullable-column cursor pagination fix
    
    * Preserve index usage for nullable-column cursor pagination
    
    The keyset ORDER BY ranked NULLs with a portable CASE expression so NULL
    placement was uniform across backends. A computed ORDER BY key cannot use a
    B-tree index, so large lists fall back to a full sort. Match each backend's
    native NULL placement in the keyset predicate instead and keep the ORDER BY 
a
    bare column, preserving the index; the fix still returns every row exactly 
once.
    EXPLAIN on Postgres confirms an Index Only Scan rather than a Sort.
    
    (cherry picked from commit 3b13e593ac697c5cb3dd6ded9c63e0f5c24a547a)
    
    Co-authored-by: Steve Ahn <[email protected]>
---
 .../src/airflow/api_fastapi/common/cursors.py      |  66 ++++++-----
 .../api_fastapi/core_api/routes/public/dag_run.py  |   7 +-
 .../core_api/routes/public/task_instances.py       |  11 +-
 .../tests/unit/api_fastapi/common/test_cursors.py  | 123 +++++++++++++++++++--
 .../core_api/routes/public/test_dag_run.py         |  34 ++++++
 .../core_api/routes/public/test_task_instances.py  | 101 +++++++++++++++++
 6 files changed, 299 insertions(+), 43 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/cursors.py 
b/airflow-core/src/airflow/api_fastapi/common/cursors.py
index 2eb5569f890..c775e72ab70 100644
--- a/airflow-core/src/airflow/api_fastapi/common/cursors.py
+++ b/airflow-core/src/airflow/api_fastapi/common/cursors.py
@@ -28,7 +28,7 @@ from typing import Any
 
 import msgspec
 from fastapi import HTTPException, status
-from sqlalchemy import and_, or_
+from sqlalchemy import and_, false, or_, true
 from sqlalchemy.sql import Select
 from sqlalchemy.sql.elements import ColumnElement
 from sqlalchemy.sql.sqltypes import Uuid
@@ -43,55 +43,57 @@ def _b64url_decode_padded(token: str) -> bytes:
     return base64.urlsafe_b64decode(token.encode("ascii"))
 
 
-def _nonstrict_bound(col: ColumnElement, value: Any, is_desc: bool) -> 
ColumnElement[bool]:
+def _dialect_nulls_last(is_desc: bool, dialect: str) -> bool:
     """
-    Inclusive range edge on the leading column at each nesting level (``>=`` / 
``<=``).
+    Where a plain ``ORDER BY col`` puts NULLs on this backend.
 
-    When *value* is ``None`` the column is nullable and the cursor sits at a
-    NULL boundary.  ``col IS NULL`` is used instead of ``col >= NULL`` (which
-    SQLAlchemy rejects and SQL evaluates as UNKNOWN).
+    PostgreSQL sorts NULLs last for ASC, first for DESC; MySQL and SQLite treat
+    NULL as the lowest value, so NULLs come first for ASC and last for DESC.
     """
-    if value is None:
-        return col.is_(None)
-    return col <= value if is_desc else col >= value
+    return (not is_desc) if dialect == "postgresql" else is_desc
 
 
-def _strict_bound(col: ColumnElement, value: Any, is_desc: bool) -> 
ColumnElement[bool]:
+def _bounds(
+    col: ColumnElement, value: Any, is_desc: bool, dialect: str
+) -> tuple[ColumnElement[bool], ColumnElement[bool]]:
     """
-    Strict inequality for ``or_`` branches (``<`` / ``>``).
-
-    When *value* is ``None`` the cursor is at a NULL boundary.  The only rows
-    that can be "strictly after" a NULL are non-NULL rows (regardless of
-    whether the database sorts NULLs first or last), so ``col IS NOT NULL`` is
-    used.  When the surrounding ``_nonstrict_bound`` already constrains
-    ``col IS NULL``, this branch evaluates to FALSE and the inner keyset
-    predicate takes over — which is the correct behaviour.
+    ``(non_strict, strict)`` keyset bounds matching the backend's native NULL 
placement.
+
+    A NULL *value* means the cursor sits in the NULL block; otherwise a 
trailing
+    NULL block (when NULLs sort last) is also admitted after a non-NULL cursor.
+    The ``col IS NULL`` terms are vacuous for non-nullable columns.
     """
+    nulls_last = _dialect_nulls_last(is_desc, dialect)
     if value is None:
-        return col.is_not(None)
-    return col < value if is_desc else col > value
+        if nulls_last:
+            return col.is_(None), false()
+        return true(), col.is_not(None)
+    ge = col <= value if is_desc else col >= value
+    gt = col < value if is_desc else col > value
+    if nulls_last:
+        return or_(ge, col.is_(None)), or_(gt, col.is_(None))
+    return ge, gt
 
 
 def _nested_keyset_predicate(
-    resolved: list[tuple[str, ColumnElement, bool]], values: list[Any]
+    resolved: list[tuple[str, ColumnElement, bool]], values: list[Any], 
dialect: str
 ) -> ColumnElement[bool]:
     """
     Keyset predicate for rows strictly after the cursor in ``ORDER BY`` order.
 
     Uses nested ``and_(non-strict, or_(strict, ...))`` so leading sort keys use
     inclusive range bounds and inner branches use strict inequalities—friendly
-    for composite index range scans. Logically equivalent to an OR-of-prefix-
-    equalities formulation.
+    for composite index range scans. NULL placement follows each backend's
+    native ordering for a plain ``ORDER BY`` (see :func:`_bounds`), so the
+    ``ORDER BY`` stays a bare column and can still use an index.
     """
     n = len(resolved)
     _, col, is_desc = resolved[n - 1]
-    inner: ColumnElement[bool] = _strict_bound(col, values[n - 1], is_desc)
+    _, inner = _bounds(col, values[n - 1], is_desc, dialect)
     for i in range(n - 2, -1, -1):
         _, col_i, is_desc_i = resolved[i]
-        inner = and_(
-            _nonstrict_bound(col_i, values[i], is_desc_i),
-            or_(_strict_bound(col_i, values[i], is_desc_i), inner),
-        )
+        non_strict, strict = _bounds(col_i, values[i], is_desc_i, dialect)
+        inner = and_(non_strict, or_(strict, inner))
     return inner
 
 
@@ -163,7 +165,7 @@ def decode_cursor(token: str) -> list[Any]:
 
 
 def apply_cursor_filter(
-    statement: Select, token: str, sort_param: SortParam, *, is_backward: bool 
= False
+    statement: Select, token: str, sort_param: SortParam, dialect: str, *, 
is_backward: bool = False
 ) -> Select:
     """
     Apply a keyset pagination WHERE clause from a cursor token.
@@ -173,6 +175,10 @@ def apply_cursor_filter(
     flipped so the predicate selects rows strictly *before* the cursor in the
     original sort order.  The caller is responsible for reversing the ORDER BY
     and the final result list when using a backward cursor.
+
+    *dialect* is the backend dialect name 
(``session.get_bind().dialect.name``);
+    the predicate matches that backend's native NULL placement, so the keyset
+    ``ORDER BY`` stays a bare column and keeps using indexes.
     """
     raw_values = decode_cursor(token)
 
@@ -185,4 +191,4 @@ def apply_cursor_filter(
     if is_backward:
         resolved = [(name, col, not is_desc) for name, col, is_desc in 
resolved]
 
-    return statement.where(_nested_keyset_predicate(resolved, parsed_values))
+    return statement.where(_nested_keyset_predicate(resolved, parsed_values, 
dialect))
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 84e2f77f8fa..7b03c7b3ece 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
@@ -663,14 +663,17 @@ def get_dag_runs(
             "int", limit.value
         )  # LimitFilter value is guaranteed to be set to the default value of 
QueryLimit
         cursor_limit = LimitFilter().set_value(page_limit + 1)
-        dag_run_select = apply_filters_to_select(statement=query, 
filters=[*filters, order_by, cursor_limit])
+        dag_run_select = apply_filters_to_select(statement=query, 
filters=[*filters, cursor_limit])
+        dag_run_select = order_by.to_orm(dag_run_select)
 
         is_backward = False
         if cursor:
             token, is_backward = parse_cursor(cursor)
             if is_backward:
                 dag_run_select = order_by.to_orm(dag_run_select, reversed=True)
-            dag_run_select = apply_cursor_filter(dag_run_select, token, 
order_by, is_backward=is_backward)
+            dag_run_select = apply_cursor_filter(
+                dag_run_select, token, order_by, 
session.get_bind().dialect.name, is_backward=is_backward
+            )
 
         fetched = list(session.scalars(dag_run_select))
         has_more = len(fetched) > page_limit
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 fc28c377fe6..7305e4e89be 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
@@ -607,9 +607,8 @@ def get_task_instances(
             "int", limit.value
         )  # LimitFilter value is guaranteed to be set to the default value of 
QueryLimit
         cursor_limit = LimitFilter().set_value(page_limit + 1)
-        task_instance_select = apply_filters_to_select(
-            statement=query, filters=[*filters, order_by, cursor_limit]
-        )
+        task_instance_select = apply_filters_to_select(statement=query, 
filters=[*filters, cursor_limit])
+        task_instance_select = order_by.to_orm(task_instance_select)
 
         is_backward = False
         if cursor:
@@ -617,7 +616,11 @@ def get_task_instances(
             if is_backward:
                 task_instance_select = order_by.to_orm(task_instance_select, 
reversed=True)
             task_instance_select = apply_cursor_filter(
-                task_instance_select, token, order_by, is_backward=is_backward
+                task_instance_select,
+                token,
+                order_by,
+                session.get_bind().dialect.name,
+                is_backward=is_backward,
             )
 
         fetched = list(session.scalars(task_instance_select))
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_cursors.py 
b/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
index 8bf8adbba36..f101efebc9d 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
@@ -26,7 +26,7 @@ from unittest.mock import MagicMock
 import msgspec
 import pytest
 from fastapi import HTTPException
-from sqlalchemy import select
+from sqlalchemy import Column, String, select
 
 from airflow.api_fastapi.common.cursors import apply_cursor_filter, 
decode_cursor, encode_cursor
 from airflow.api_fastapi.common.parameters import SortParam
@@ -126,7 +126,7 @@ class TestCursorPagination:
         token = _msgpack_cursor_token(["only-one-value"])
 
         with pytest.raises(HTTPException, match="does not match"):
-            apply_cursor_filter(select(TaskInstance), token, sp)
+            apply_cursor_filter(select(TaskInstance), token, sp, "sqlite")
 
     def test_apply_cursor_filter_ascending(self):
         sp = self._make_sort_param_with_resolved_columns(["start_date"])
@@ -136,7 +136,7 @@ class TestCursorPagination:
         ]
         token = _msgpack_cursor_token(values)
 
-        stmt = apply_cursor_filter(select(TaskInstance), token, sp)
+        stmt = apply_cursor_filter(select(TaskInstance), token, sp, "sqlite")
         sql = str(stmt)
         assert ">" in sql
 
@@ -148,7 +148,7 @@ class TestCursorPagination:
         ]
         token = _msgpack_cursor_token(values)
 
-        stmt = apply_cursor_filter(select(TaskInstance), token, sp)
+        stmt = apply_cursor_filter(select(TaskInstance), token, sp, "sqlite")
         sql = str(stmt)
         assert "<" in sql
 
@@ -193,8 +193,117 @@ class TestCursorPagination:
         sp.set_value(["_rendered_map_index", "map_index"])
         token = _msgpack_cursor_token([None, 49, 
"019462ab-1234-5678-9abc-def012345678"])
 
-        # Should not raise ArgumentError from SQLAlchemy.
-        stmt = apply_cursor_filter(select(TaskInstance), token, sp)
+        # Should not raise ArgumentError from SQLAlchemy; the NULL boundary is
+        # expressed with IS [NOT] NULL rather than a comparison against NULL.
+        stmt = apply_cursor_filter(select(TaskInstance), token, sp, "sqlite")
         sql = str(stmt)
-        assert "IS NULL" in sql
         assert "IS NOT NULL" in sql
+
+
+class TestKeysetPaginationNullableColumn:
+    """End-to-end: cursor pagination over a nullable sort column returns every 
row exactly once.
+
+    When the keyset predicate and the ORDER BY disagree on where NULLs fall, 
one side
+    of the NULL/non-NULL boundary is silently dropped. The predicate matches 
each
+    backend's native NULL placement so the ORDER BY stays a bare (indexable) 
column.
+    """
+
+    @staticmethod
+    def _seed_session(rows):
+        """Build an in-memory model with one nullable column and seed it with 
``(id, val)`` rows."""
+        from sqlalchemy import Integer, create_engine
+        from sqlalchemy.orm import Session, declarative_base
+
+        base = declarative_base()
+
+        class Item(base):
+            __tablename__ = "keyset_items"
+            id = Column(Integer, primary_key=True)
+            val = Column(String, nullable=True)
+
+        engine = create_engine("sqlite://")
+        base.metadata.create_all(engine)
+        session = Session(engine)
+        session.add_all([Item(id=i, val=v) for i, v in rows])
+        session.commit()
+        return Item, session
+
+    @staticmethod
+    def _walk_forward(session, model, sort, page_size):
+        collected: list[int] = []
+        token = None
+        for _ in range(50):  # guard against an infinite paging loop
+            stmt = sort.to_orm(select(model)).limit(page_size)
+            if token is not None:
+                stmt = apply_cursor_filter(stmt, token, sort, "sqlite")
+            rows = list(session.scalars(stmt))
+            if not rows:
+                break
+            collected.extend(r.id for r in rows)
+            token = encode_cursor(rows[-1], sort)
+        return collected
+
+    @pytest.mark.parametrize(
+        ("order_by", "expected_order"),
+        [
+            pytest.param(["val"], [1, 2, 3, 4, 5, 6], 
id="ascending-nulls-first"),
+            pytest.param(["-val"], [6, 5, 4, 3, 2, 1], 
id="descending-nulls-last"),
+        ],
+    )
+    def test_forward_pagination_returns_all_rows(self, order_by, 
expected_order):
+        model, session = self._seed_session([(1, None), (2, None), (3, None), 
(4, "a"), (5, "b"), (6, "c")])
+        try:
+            sort = SortParam(["val"], model)
+            sort.set_value(order_by)
+            collected = self._walk_forward(session, model, sort, page_size=2)
+        finally:
+            session.close()
+
+        assert sorted(collected) == [1, 2, 3, 4, 5, 6], f"rows 
dropped/duplicated: {collected}"
+        assert len(collected) == len(set(collected)), f"rows duplicated: 
{collected}"
+        assert collected == expected_order
+
+    def test_multiple_nullable_columns_no_rows_dropped(self):
+        """Two nullable sort columns: every NULL/non-NULL combination paged 
exactly once."""
+        from sqlalchemy import Integer, create_engine
+        from sqlalchemy.orm import Session, declarative_base
+
+        base = declarative_base()
+
+        class Pair(base):
+            __tablename__ = "keyset_pairs"
+            id = Column(Integer, primary_key=True)
+            a = Column(String, nullable=True)
+            b = Column(String, nullable=True)
+
+        engine = create_engine("sqlite://")
+        base.metadata.create_all(engine)
+        rows = [
+            (1, None, None),
+            (2, None, "y"),
+            (3, "p", None),
+            (4, "p", "y"),
+            (5, "q", None),
+            (6, "q", "z"),
+        ]
+        with Session(engine) as session:
+            session.add_all([Pair(id=i, a=a, b=b) for i, a, b in rows])
+            session.commit()
+            sort = SortParam(["a", "b"], Pair)
+            sort.set_value(["a", "b"])
+            collected = self._walk_forward(session, Pair, sort, page_size=2)
+
+        assert sorted(collected) == [1, 2, 3, 4, 5, 6], f"rows 
dropped/duplicated: {collected}"
+        assert len(collected) == len(set(collected)), f"rows duplicated: 
{collected}"
+
+    def test_nullable_column_without_nulls_unaffected(self):
+        """The common case (a nullable column that happens to hold no NULLs) 
still pages correctly."""
+        model, session = self._seed_session([(1, "a"), (2, "b"), (3, "c"), (4, 
"d"), (5, "e")])
+        try:
+            sort = SortParam(["val"], model)
+            sort.set_value(["val"])
+            collected = self._walk_forward(session, model, sort, page_size=2)
+        finally:
+            session.close()
+
+        assert collected == [1, 2, 3, 4, 5]
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 66bbadf2c85..d8b7eeae5e1 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
@@ -690,6 +690,40 @@ class TestGetDagRuns:
         )
         assert response.status_code == 400
 
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_cursor_pagination_nullable_sort_column_returns_all_rows(self, 
test_client, session):
+        """Cursor pagination sorted by a nullable column must not silently 
drop rows.
+
+        With a NULL in the sort column, the keyset predicate and the ORDER BY 
can disagree
+        on NULL placement and drop every row on one side of the NULL/non-NULL 
boundary.
+        """
+        # Null out one run's start_date so the NULL/non-NULL boundary is 
crossed mid-walk.
+        run = session.scalar(select(DagRun).where(DagRun.run_id == 
DAG1_RUN1_ID))
+        run.start_date = None
+        session.commit()
+
+        full = test_client.get("/dags/~/dagRuns", params={"limit": 100})
+        assert full.status_code == 200, full.json()
+        full_ids = {(r["dag_id"], r["dag_run_id"]) for r in 
full.json()["dag_runs"]}
+        assert len(full_ids) == 4
+
+        collected: list[tuple[str, str]] = []
+        cursor_token: str | None = ""
+        for _ in range(20):
+            resp = test_client.get(
+                "/dags/~/dagRuns",
+                params={"limit": 1, "order_by": "start_date", "cursor": 
cursor_token},
+            )
+            assert resp.status_code == 200, resp.json()
+            body = resp.json()
+            collected.extend((r["dag_id"], r["dag_run_id"]) for r in 
body["dag_runs"])
+            cursor_token = body.get("next_cursor")
+            if cursor_token is None:
+                break
+
+        assert len(collected) == len(set(collected)), "cursor pages overlapped"
+        assert set(collected) == full_ids, "cursor pagination dropped rows 
across the NULL boundary"
+
     @pytest.mark.parametrize(
         ("dag_id", "query_params", "expected_dag_id_list"),
         [
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 266ea938104..ae3a8388fea 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
@@ -2154,6 +2154,107 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
         assert ids1.isdisjoint(ids2), "Pages must not overlap"
         assert len(ids1) + len(ids2) == total_tis
 
+    def test_cursor_pagination_nullable_sort_column_returns_all_rows(self, 
test_client, session):
+        """Cursor pagination sorted by a nullable column must not silently 
drop rows.
+
+        With NULLs present, the keyset predicate and the ORDER BY can disagree 
on NULL
+        placement, so every row on one side of the NULL/non-NULL boundary is 
dropped
+        without error.
+        """
+        dag_id = "example_python_operator"
+        # Three TIs with NULL start_date and three with distinct values, so 
both the NULL and
+        # the non-NULL block span more than one page and the boundary is 
crossed mid-walk.
+        self.create_task_instances(
+            session,
+            task_instances=[
+                {"start_date": None},
+                {"start_date": None},
+                {"start_date": None},
+                {"start_date": DEFAULT_DATETIME_1 + dt.timedelta(minutes=1)},
+                {"start_date": DEFAULT_DATETIME_1 + dt.timedelta(minutes=2)},
+                {"start_date": DEFAULT_DATETIME_1 + dt.timedelta(minutes=3)},
+            ],
+            dag_id=dag_id,
+        )
+
+        # Full set via offset pagination (returns everything).
+        full = test_client.get("/dags/~/dagRuns/~/taskInstances", 
params={"limit": 100})
+        assert full.status_code == 200, full.json()
+        full_ids = {ti["id"] for ti in full.json()["task_instances"]}
+        assert len(full_ids) == 6
+
+        # Walk every page forward via cursor, sorted by the nullable column.
+        collected: list[str] = []
+        cursor_token: str | None = ""
+        for _ in range(20):
+            resp = test_client.get(
+                "/dags/~/dagRuns/~/taskInstances",
+                params={"limit": 2, "order_by": ["start_date"], "cursor": 
cursor_token},
+            )
+            assert resp.status_code == 200, resp.json()
+            body = resp.json()
+            collected.extend(ti["id"] for ti in body["task_instances"])
+            cursor_token = body.get("next_cursor")
+            if cursor_token is None:
+                break
+
+        assert len(collected) == len(set(collected)), "cursor pages overlapped"
+        assert set(collected) == full_ids, "cursor pagination dropped rows 
across the NULL boundary"
+
+    def test_cursor_pagination_forward_backward_consistency_nullable(self, 
test_client, session):
+        """Forward then backward walk over a nullable column must agree, NULLs 
included.
+
+        Backward pagination flips the sort direction, re-deriving the keyset 
bounds; this
+        guards that NULL placement stays consistent in both directions.
+        """
+        dag_id = "example_python_operator"
+        page_size = 3
+        # 3 NULL start_dates + 5 distinct values -> NULL block and non-NULL 
block both span pages.
+        self.create_task_instances(
+            session,
+            task_instances=[{"start_date": None} for _ in range(3)]
+            + [{"start_date": DEFAULT_DATETIME_1 + dt.timedelta(minutes=(i + 
1))} for i in range(5)],
+            dag_id=dag_id,
+        )
+
+        forward_ids: list[str] = []
+        forward_pages: list[dict] = []
+        cursor_token: str | None = ""
+        for _ in range(20):
+            response = test_client.get(
+                "/dags/~/dagRuns/~/taskInstances",
+                params={"limit": page_size, "order_by": ["start_date"], 
"cursor": cursor_token},
+            )
+            assert response.status_code == 200, response.json()
+            body = response.json()
+            forward_pages.append(body)
+            forward_ids.extend(ti["id"] for ti in body["task_instances"])
+            cursor_token = body.get("next_cursor")
+            if cursor_token is None:
+                break
+
+        assert len(forward_ids) == 8
+        assert len(forward_ids) == len(set(forward_ids)), "Forward pages 
should not overlap"
+        assert forward_pages[0]["previous_cursor"] is None
+
+        backward_ids: list[str] = []
+        cursor_token = forward_pages[-1]["previous_cursor"]
+        assert cursor_token is not None
+        for _ in range(20):
+            response = test_client.get(
+                "/dags/~/dagRuns/~/taskInstances",
+                params={"limit": page_size, "order_by": ["start_date"], 
"cursor": cursor_token},
+            )
+            assert response.status_code == 200, response.json()
+            body = response.json()
+            backward_ids = [ti["id"] for ti in body["task_instances"]] + 
backward_ids
+            cursor_token = body.get("previous_cursor")
+            if cursor_token is None:
+                break
+
+        all_backward = backward_ids + [ti["id"] for ti in 
forward_pages[-1]["task_instances"]]
+        assert all_backward == forward_ids, "Backward walk + last page must 
match the forward walk exactly"
+
 
 class TestGetTaskDependencies(TestTaskInstanceEndpoint):
     def setup_method(self):

Reply via email to