Skip to content

Fix cursor pagination dropping rows when sorting by a nullable column#68869

Open
steveahnahn wants to merge 2 commits into
apache:mainfrom
steveahnahn:fix-cursor-pagination-null-rows
Open

Fix cursor pagination dropping rows when sorting by a nullable column#68869
steveahnahn wants to merge 2 commits into
apache:mainfrom
steveahnahn:fix-cursor-pagination-null-rows

Conversation

@steveahnahn

Copy link
Copy Markdown
Contributor

Cursor (keyset) paginated REST list endpoints (GET /dags/{dag_id}/dagRuns and GET .../taskInstances) silently dropped rows when sorted by a nullable column such as start_date, end_date, duration, or state. 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 reachable from the shipping web UI: the Task Instances and Dag Runs lists paginate by cursor and let you sort by clicking a column header, so sorting by Start Date while some queued (not yet started) task instances are present makes rows silently disappear from the grid.

Fix

NULLS FIRST/LAST is not portable (unsupported on MySQL and older SQLite), so the cursor path pins NULL placement with a portable CASE-based null-rank key shared by both the keyset ORDER BY and the keyset predicate, so they can no longer disagree on any backend. The rank follows the column's sort direction, so NULLs sort as the largest value (last when ascending, first when descending), matching PostgreSQL's default. PostgreSQL result ordering is therefore unchanged; SQLite and MySQL NULL ordering shifts to align with PostgreSQL.

  • Cursor token format is unchanged (the rank is derived from the decoded value, not encoded), so existing cursors keep working.
  • Offset pagination is untouched.

The CASE in ORDER BY means the sort on a nullable column cannot use a plain column index. This is the cost of cross-backend portability; a PostgreSQL-only NULLS LAST fast path could be a future optimization.

Tests

  • Fail-first endpoint regressions (taskInstances and dagRuns): paginating by a nullable column returns every row only after the fix.
  • Forward/backward cursor consistency over a nullable column.
  • Unit coverage for the keyset expansion (single and multiple nullable columns, no-NULLs case, rank derivation, nullability detection).

closes: #68858


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

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

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

Cursor pagination silently drops rows when sorting by a nullable column

1 participant