Skip to content

perf(api): bound the listing fetch and order in SQL - #29

Merged
mburns merged 1 commit into
mainfrom
perf/api-bounded-listings
Aug 24, 2026
Merged

perf(api): bound the listing fetch and order in SQL#29
mburns merged 1 commit into
mainfrom
perf/api-bounded-listings

Conversation

@mburns

@mburns mburns commented Aug 24, 2026

Copy link
Copy Markdown
Member

The last open item from the data-model review, and the half of #23 I
deliberately left out at the time.

The gap

#23 moved the web listings to SQL ordering with windowed fetches. /api did
not come along: its endpoints still read every matching row and ranked them
in Lua through Sort:sort. So the fix that mattered for browsers didn't reach
the API at all.

What changed

link_listing passes sort through to Posts:get_listing — the database
orders the rows now — and sizes the fetch to the request via a new S.window:

request rows fetched
no cursor (every first page, the common case) page + 1 lookahead row
after= / before= present S.MAX_DEPTH (1000)

The window opens for a cursor because api_serialize.paginate locates the
cursor row by scanning the rows it was handed — it can only reach a row the
caller actually fetched.

Why cap rather than do real keyset pagination

This is the part worth pushing back on if you disagree.

Proper keyset pagination needs WHERE (rank, id) < (cursor_rank, cursor_id).
For hot, controversial and rising the rank is computed from live vote
counts
— it changes between requests. A cursor into a ranked listing is
therefore approximate no matter how it's implemented; keyset would move the
inaccuracy around rather than remove it, at the cost of repeating the correlated
vote subqueries inside a WHERE clause for every sort.

Capping deep paging is what search engines and Reddit itself do, and it makes
the failure mode explicit instead of silent. If you want exact deep cursors on
new specifically — where the key is (created_at, id) and genuinely stable —
that's a clean follow-up.

A cursor bug fixed on the way

An unknown cursor — past the cap, or pointing at a row since deleted — used to
fall through to start = 1 and return the first page. A client walking
pages would loop forever without ever learning it had reached the end. It now
returns an empty page with no after.

Also

  • /api/subreddits gained a LIMIT and a total order (s.id DESC), so its
    window is bounded and stable between requests, and selects public_id so the
    serializer doesn't re-read it per row.
  • utils/sort is now unused by application code. It stays because the listing
    specs check the SQL ORDER BY against its comparators — that's the only thing
    keeping the two definitions honest with each other.

Verification

New api_pagination_spec (9 cases), including a full cursor walk over 12
posts that asserts every id appears exactly once and the walk terminates, a
query-count check that limit=1 and limit=10 cost the same number of queries
(i.e. the fetch doesn't scale with the table), the unknown-cursor case, and all
six sorts. Full suite 408 passing, coverage 86.0%, stylua and luacheck clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_0128hUpuk1spKzk4UHburdki

The web listings moved to SQL ordering and windowed fetches, but /api did not:
its endpoints still read every matching row and ranked them in Lua via
Sort:sort. This finishes that job.

link_listing now passes `sort` through to Posts:get_listing, so the database
orders the rows, and sizes the fetch to the request via a new S.window:

- no cursor (every first page, and the common case): the page plus one lookahead
  row.
- with a cursor: S.MAX_DEPTH (1000) rows, because api_serialize.paginate finds
  the cursor row by scanning the rows it was handed, so it can only reach one the
  caller actually fetched.

Capping the depth is a deliberate choice rather than a shortcut. True keyset
pagination would need WHERE (rank, id) < (cursor_rank, cursor_id), and for
hot/controversial/rising the rank is computed from live vote counts -- it moves
between requests, so a cursor into a ranked listing is approximate however it is
implemented. Search engines and Reddit cap deep paging for the same reason.

An unknown cursor (past the cap, or a row since deleted) now answers with an
empty page. It used to fall through to `start = 1` and silently return the first
page, which left a client paging in a loop with no way to notice it had reached
the end.

/api/subreddits gained a LIMIT and a total order (s.id DESC) so its window is
stable between requests, and selects public_id so the serializer does not have to
re-read it.

utils/sort is now unused by application code; it stays because the listing specs
check the SQL ORDER BY against its comparators.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128hUpuk1spKzk4UHburdki
@mburns
mburns merged commit 82c39c2 into main Aug 24, 2026
16 checks passed
@mburns
mburns deleted the perf/api-bounded-listings branch August 24, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant