perf(api): bound the listing fetch and order in SQL - #29
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
/apididnot 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 reachthe API at all.
What changed
link_listingpassessortthrough toPosts:get_listing— the databaseorders the rows now — and sizes the fetch to the request via a new
S.window:after=/before=presentS.MAX_DEPTH(1000)The window opens for a cursor because
api_serialize.paginatelocates thecursor 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,controversialandrisingthe rank is computed from live votecounts — 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
WHEREclause 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
newspecifically — 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 = 1and return the first page. A client walkingpages would loop forever without ever learning it had reached the end. It now
returns an empty page with no
after.Also
/api/subredditsgained aLIMITand a total order (s.id DESC), so itswindow is bounded and stable between requests, and selects
public_idso theserializer doesn't re-read it per row.
utils/sortis now unused by application code. It stays because the listingspecs check the SQL
ORDER BYagainst its comparators — that's the only thingkeeping the two definitions honest with each other.
Verification
New
api_pagination_spec(9 cases), including a full cursor walk over 12posts that asserts every id appears exactly once and the walk terminates, a
query-count check that
limit=1andlimit=10cost 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