API lists: pages ordered by a column that is not unique can repeat one row and skip another #440
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#440
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 on 2026-09-29 in a read-only review of postulo at
95348908d, reading the ordering of every paginated list in the API.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The six lists have not moved; the captures ordering given for comparison is now atapi/api.py:652.Asked without
updated_since, several lists are cut into pages withLIMIT/OFFSETover an ordering that ties. SQL gives tied rows no order, and PostgreSQL says outright that differentLIMIT/OFFSETvalues can return tied rows in different orders because the planner takes the limit into account. So a client reading a list page by page can be given one row twice and another never. Captures, listings and offers already break ties with the primary key; these do not.api/routers/applications.py:70,list_applications:.order_by("-created_at").api/routers/companies.py:42,list_companies:.order_by("name"); a company name is not unique.api/routers/documents.py:73and:89,list_cvsandlist_letters:.order_by("name").api/routers/interviews.py:53,:55and:57,list_interviews:order_by("starts_at")or("-starts_at"); the defaultupcomingstate takes its order fromInterviewQuerySet.upcoming()inapplications/models.py:680, alsoorder_by("starts_at").api/routers/reminders.py:29,list_reminders:.order_by("due_at").api/api.py:650("-created_at", "-pk"),api/routers/listings.py:89and:127("-noted_at", "-pk"),api/routers/offers.py:32("-created_at", "-pk").changed_sinceinapi/paging.pyorders by("updated_at", "pk"), so only the cursor path is safe.GET /api/v1/reminders?limit=20, thenoffset=20. Several reminders share onedue_at(answer-by reminders set for the same morning, say) and straddle the boundary. Because the two queries may order the tied rows differently, one of them can appear on both pages and another on neither. Nothing reports it, and the agent's copy is wrong. SQLite happens to return ties in a stable order, which is why the test suite does not see it.limit/offsetis the pagination the wiki documents for every list, and a client that walks a list end to end has no way to notice a row that was skipped.order_by("-created_at", "-pk")for applications,("name", "pk")for companies, CVs and letters,("starts_at", "pk")or("-starts_at", "-pk")for interviews (includingupcoming()), and("due_at", "pk")for reminders.Done when
tests/test_api_lists.pywalks every paginated list withlimit/offsetand asserts that the queryset each view hands toPagehas an ordering ending inpkor-pk(the tie cannot be reproduced on SQLite, so the ordering is what is checked). It fails today for the six lists above.