Keyset cursor uses non-unique created_at and can skip rows #88
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?
Severity: MEDIUM (correctness at scale)
The bug
The keyset cursor filters with
Photo.created_at > ref.created_atordered bycreated_atalone(
backend/app/repositories/photos.py:49-54; same pattern inrepositories/jobs.py:36-39).created_atis not unique — it isdatetime.utcnow()assigned per row, and bulk ingestassigns many in rapid succession. Any tie spanning a page boundary drops the tied rows from all
subsequent pages. Ordering with no tiebreak is also nondeterministic between requests.
Why it matters
Bulk-ingested collections are exactly where ties occur, and a reviewer paging a 30,000-photo grid
would never notice the skipped photos — they would simply never appear. Silent data omission in
the primary browse path.
Fix
Order by
(created_at, id)and filter with a tuple comparison:SQLAlchemy's
tuple_()works on SQLite, and the(collection_id, created_at)index from theindex issue still serves it. Apply the same fix to
JobRepository.Each page also currently spends an extra query resolving the cursor id to a row — worth folding in.
Done when
once across pages
References
backend/app/repositories/photos.py:49-54backend/app/repositories/jobs.py:36-39Fixed in
9288798.Done when
The severity is worse than MEDIUM. I wrote the reproduction before the fix, and the failure is not "rows are skipped" — it is that pagination terminates. With ten photos sharing a timestamp and a page size of one:
All ten share the timestamp, so the strict
>excludes the entire tied group including the nine never seen. One photo was returned; nine vanished from every page. A batch ingested in one go — which is the normal case for this project, a box of scans at a time — could contribute a single photo to the whole browse grid.And nothing surfaces it. The pages look complete, the missing photographs are simply absent, and there is no error, no gap, no count that disagrees. This is silent data omission in the primary browse path, on the most common ingest shape.
The fix is the row-value comparison from the issue,
(created_at, id), ordered the same way. SQLite has supported row values since 3.15 (3.50.4 here) and the existing(collection_id, created_at)index still serves the ordering.JobRepositorygets the same fix with the comparison reversed, since that listing is newest-first — worth calling out because it is exactly the detail that gets copied wrong when one of two call sites is fixed. Ties are if anything likelier there: a bulk rerun enqueues a job per photo in one loop.One suggestion I did not take. The issue notes each page spends an extra query resolving the cursor id and suggests folding it in. I kept the opaque id. Resolving it is one indexed lookup per page, and in exchange the cursor is still scoped to the collection on the way in (#70) and a client cannot hand back a position we never issued. An encoded
(timestamp, id)cursor would be a client-constructible position into the table. That seemed the wrong trade for one lookup, but it is a judgement call and easy to revisit if paging ever shows up in a profile.Tests page every listing to exhaustion and compare the multiset of ids against what was inserted — at page sizes 1, 2, 3 and 5, filtered and unfiltered, both repositories. Asserting that a page "looks right" is precisely the check that missed this for the life of the code. The walk is bounded so a cursor that fails to advance fails the test rather than hanging it.
I also swept for other keyset cursors on non-unique columns; there are none.