perf: post-listing queries are unbounded; paginate profile/feed/tag/admin lists #154
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rosa/vernier#154
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found during a pre-production review.
Location:
crates/infra/src/repositories/sqlite.rs(post-listing queries); consumed by profile, feed, tag, and admin handlers incrates/web/src/handlers/Severity: Unbounded growth — scaling / availability
Problem
Every post-listing query is
ORDER BY … DESCwith noLIMIT/OFFSET. The listing paths — a user's Profile, RSS/Atom feeds, tag pages, and the admin post/user lists — each load the full matching history into memory on every request and render all of it. Confirmed across the compiled statements in.sqlx/(noLIMITin any listing query).For a service whose top design goal is "runs on a small box... for years" (ARCHITECTURE.md, Goal #1), this degrades without bound as post count grows: memory per request, render time, and — for feeds — payload size all scale with total history rather than a fixed window.
Failure scenario
A prolific author accumulates thousands of Notes. Every hit on their Profile, every feed poll by every RSS reader, and every tag page reloads and renders the entire corpus. Feed responses grow to megabytes; a burst of feed pollers can pin CPU and memory on the small box the project targets.
Suggested fix
created_at-cursor fits theORDER BY … DESCshape better thanOFFSETand stays cheap deep into history). Push the limit down into the repository query, not the handler.Since the port method signatures change, this touches the
AppRepositoryport and the Posts Service, not just SQL.Triage: scope narrowed, feed cap split out
The feed-bounding half of this issue is decision-free and has been split to its own
ready-for-agentissue (feed item cap). This issue now covers only the profile, tag-listing, and admin post/user lists — the parts that need design decisions before an agent can build them.Marked
ready-for-humanbecause the remaining work turns on judgment calls, not a mechanical fix:ORDER BY created_at DESCshape, stays cheap deep into history) vs.LIMIT/OFFSET(simpler, degrades on deep pages).AppRepositorylisting method signatures and the Posts Service, so the shape wants deciding before implementation.Once these are settled (a
/grillingpass would do it), this can move toready-for-agentwith a concrete brief.Agent Brief
Supersedes the earlier
ready-for-humanscope note above. Design settled via a grilling session; this brief is the contract.Category: enhancement
Summary: Paginate the four unbounded post/user listing surfaces with opaque keyset cursors and two-way (Newer/Older) hyperlink navigation.
Current behavior:
The public profile "listed" feed, the tag listing, the admin posts list, and the admin users list each load their entire collection in one query (
ORDER BY … DESC, no limit) and render every row. The backing port methods return unboundedVec<T>. This grows without bound as content accumulates, against the "runs on a small box for years" goal.Desired behavior:
Each of those four surfaces returns at most one page of rows, newest-first as today, with in-page links to the adjacent pages. Navigation is keyset (cursor)-based, not offset-based, and is expressed as plain hyperlinks (no client JS — the site has no JS framework and CSP is
script-src 'self').Settled design:
Scope — exactly these four: profile listed (public), tag listing (public), admin posts, admin users. Explicitly unchanged: the author-only pending list on the profile page stays unbounded (small, author-only; a dedicated pending page is a possible future follow-up); the dashboard (not a list); and the feeds (bounded separately in #158).
Keyset cursor with a unique tiebreaker. The cursor is the last row's full sort-key tuple, with the primary key appended as the final tiebreaker and added to
ORDER BYso the order is strictly total (no skipped or repeated rows across pages):(published_at, created_at, id)for profile/tag,(created_at, id)for admin posts and admin users. Ids are UUIDv7, so they order consistently with the timestamps.Opaque cursor in the URL. A single
?cursor=<base64url>query parameter encoding the key tuple. No signing (it only encodes public timestamps). An undecodable cursor is a malformed URL → 404. A well-formed cursor that points past the last row yields a normal empty page, not an error.Page size: 20 for the public surfaces (profile, tag), 50 for the admin surfaces. Two named constants, not magic numbers.
Two-way navigation. Render "← Newer" and "Older →" links at the foot of the list. Determine "has older" by fetching
limit + 1rows and trimming the extra. "Has newer" is true whenever the request carried a cursor (i.e. you are not on the first page). The "Newer" direction runs the keyset comparison andORDER BYreversed, then re-reverses the fetched rows for display; this stays hidden inside the repository/service.rel="prev"/rel="next"on the public pages only (profile, tag), emitted in the document head from the same cursors:next= older,prev= newer, per the HTML pagination convention. Admin pages omit these.Key interfaces:
Cursorthat owns its own base64url encode/decode (so the web layer never handles the key tuple directly, and decode failure is representable), andPage<T> { items: Vec<T>, older: Option<Cursor>, newer: Option<Cursor> }>, where each cursor field isSomeexactly when that link should render. KeepPage<T>general-purpose — it should serve any keyset-paged listing, not just these four.Direction(Older / Newer) input.Vec<T>to takingcursor: Option<Cursor>+direction: Directionand returningPage<T>. Thelimit + 1probe and the reverse-and-re-reverse for the Newer direction live behind the port, in the SQLite implementation. The web layer performs no DB access and no cursor-tuple handling — it passes the opaque cursor through and builds URLs fromPage.older/Page.newer(architecture invariant:webreaches persistence only through domain services).Page<T>to the handlers.Storage:
posts(author_id, published_at, created_at, id),posts(created_at, id),users(created_at, id). Ascending is sufficient — SQLite scans them backward for the DESC pages. The tag query'sjson_each(tags)cannot be indexed; theposts(author_id, published_at, …)index still carries the author+order portion.Documentation:
Acceptance criteria:
idtiebreaker is exercised).LIMIT), not by post-fetch truncation.?cursor=yields 404; a valid cursor past the end yields an empty page with a working "Newer" link and no "Older" link.rel="prev"/rel="next"(next=older, prev=newer) when those pages exist; admin pages do not.CursorandPage<T>live in the domain; the web crate does no DB access and no cursor-tuple decoding.Cursor/Pageglossary entries are added.format,ci, andclippymise tasks pass.Out of scope: