Repository navigation
/query paginates with no sort, so page boundaries rest on MongoDB natural order #300
Description
Summary
/query pages with db.find(props).limit(limit).skip(skip) (controllers/crud.js:87) and applies no sort. HEAD /query does the same (controllers/history.js:91). Without a sort, skip counts into MongoDB's natural order, which the server makes no guarantee about and which the client cannot reason about.
In practice it holds today, because RERUM marks deletions rather than removing documents, so the collection is close to append-only. That is a property of the data policy, not a promise the API makes, and it is not something a client can rely on across a compaction, an index change, a resharding, or a storage-engine upgrade.
This matters now rather than later because both of the paging improvements that follow are built on the assumption that consecutive pages tile a stable total order. Link: rel="next" is only honest if page boundaries are deterministic, and keyset cursors require an actual sort key. This issue is the prerequisite for both.
Why this matters
Paging correctness rests on an accident. Two clients making the same paged walk minutes apart are not guaranteed to see the same page boundaries, and no error surfaces when they do not. A document can be skipped or returned twice with nothing distinguishing that from correct behavior.
It blocks the fixes. rel="next" promises "there is another page, and here is where it starts." Under natural order, that promise is only as stable as the storage layer happens to be. Cursor-based continuation (#303) is stronger still: a keyset cursor is literally a position in a sort order, so it cannot be built without one.
It is a silent divergence from /search. /search intends a relevance order (it does not currently achieve one; see #307). /query intends nothing. Two endpoints under the same getPagination() contract should both be able to state what order they page in.
Evidence
Verified 2026-09-02 and re-run 2026-09-03, read-only.
/query does tile correctly today. Five sequential limit=100 pages from skip=50000 return the same 500 ids in the same order as one limit=500 request, and repeating a request returns the same page. So this is a latent correctness issue and a blocker for the follow-on work, not an observed failure.
The absence is in the source: controllers/crud.js:87 and controllers/history.js:91 contain no sort call, and no other layer supplies one.
Affected lines
| File | Line | Current |
|---|---|---|
controllers/crud.js |
87 | db.find(props).limit(limit).skip(skip) — no sort |
controllers/history.js |
91 | Same shape for HEAD /query |
controllers/gog.js |
36, 167 | Aggregation-based; confirm the same ordering guarantee applies |
Proposed change
Sort ascending on _id:
let matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()_id is always indexed, is unique, and is the natural cursor key for the follow-on work, so this is the cheapest total order available and the only one that does not require a new index.
Two things to verify before merging:
- Query planning. Run
explain()on representative/querybodies with an unindexedpropsfilter to confirm Mongo chooses an_idindex scan rather than a collection scan followed by a blocking in-memory sort. A blocking sort would trade a latent correctness problem for a real performance one, and the dev collection is large enough to show it. - Mixed
_idtypes.newID()returnsnew ObjectId().toHexString(), so modern RERUM_idvalues are strings. Legacy v0 documents on the dev collection carry embedded-object_idvalues (roughly 6400 of them; production has none). BSON type ordering sorts strings and embedded objects into separate, deterministically ordered blocks. The result is still a total order and paging is still correct, but the ordering is not "oldest to newest" and should not be documented as if it were.
Apply the same sort to HEAD /query so the two verbs page over the same order.
Then state the ordering guarantee in public/API.html and in openapi/contracts/core-provider.openapi.yaml. An order clients cannot read about is not much better than no order.
Notes
- Breaking in the loosest sense. Result order changes for every existing
/queryclient. Nothing documented promises the current order, but a client relying on it will notice. Land on dev first and soak. - Prerequisite for
/querypaged responses carry norel="next", so no client can tell a full page from the last page #302 and Add cursor-based continuation to/queryso paging depth is unbounded and cost is flat #303. - Sorting on
_idis the recommendation from the parent draft (the detailed report on #299, "What is working correctly"), reached independently. - If
explain()shows a blocking sort on common query shapes, say so on this thread before merging. The alternative is a compound index, which is a larger decision than this issue should make on its own.
Acceptance criteria
-
/queryandHEAD /queryboth sort ascending on_id -
explain()output for representative query bodies is posted on the issue, confirming no blocking in-memory sort - Sequential paged results still equal a single larger request, in the same order
- Paging is verified across mixed
_idtypes on the dev collection, including the legacy embedded-object documents - The ordering guarantee is stated in
public/API.htmland in the OpenAPI contract
Activity
/queryand/searchhonest and self-describing #299thehabes commentedon Sep 10, 2026
explain()evidence: no blocking in-memory sortRun 2026-09-10 against the dev collection, read-only. MongoDB 8.0.32,
annotationStore.alpha, 463,284 documents. Indexes present:_id_,slug,prime,target_1,target.id_1,target.@id_1,target.source_1,target.source.id_1,target.source.@id_1.This is the acceptance criterion asking for
explain()output before merging.Query plans
executionStatsatlimit=100&skip=1000, each shape with and without.sort({ _id: 1 }).{"@type":"oa:Annotation"}LIMIT <- SKIP <- COLLSCAN{"@type":"oa:Annotation"}LIMIT <- SKIP <- FETCH <- IXSCAN{"type":"Annotation"}LIMIT <- SKIP <- COLLSCAN{"type":"Annotation"}LIMIT <- SKIP <- FETCH <- IXSCAN{"__rerum.APIversion":{"$exists":true}}LIMIT <- SKIP <- COLLSCAN{"__rerum.APIversion":{"$exists":true}}LIMIT <- SKIP <- FETCH <- IXSCAN{"__rerum.generatedBy":"<a real agent>"}LIMIT <- SKIP <- COLLSCAN{"__rerum.generatedBy":"<a real agent>"}LIMIT <- SKIP <- FETCH <- IXSCAN{"__rerum.isReleased":""}LIMIT <- SKIP <- COLLSCAN{"__rerum.isReleased":""}LIMIT <- SKIP <- FETCH <- IXSCANNo
SORTstage appears in any of the ten plans. With the sort in place the planner stops doing a collection scan and walks the_idindex instead, so the ordering is satisfied by the index rather than by a blocking in-memory sort. That is the outcome this issue was checking for, and it clears the concern about trading a latent correctness problem for a real performance one.The alternative compound index discussed at the bottom of this issue is not needed.
Latency against depth
Timings above are single-shot and noisy. These are medians of three runs at
limit=100, which is what the timing claims should rest on.skip{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"__rerum.APIversion":{"$exists":true}}{"__rerum.APIversion":{"$exists":true}}{"__rerum.APIversion":{"$exists":true}}{"__rerum.APIversion":{"$exists":true}}{"__rerum.APIversion":{"$exists":true}}Four of the five shapes tested get faster with the sort, because an index scan beats a collection scan on a selective predicate. The exception is
{"@type":"oa:Annotation"}, which gets slower at every depth and tops out at 494 ms atskip=99000. Those documents are sparse in_idorder, so the index scan examines 108,676 keys to fill a page of 100 while the collection scan finds matches densely packed near the start of natural order.That worst case sits at the top of the 340-445 ms band already recorded for
/queryon #309, so it is not a new performance regime. It is worth knowing that the shape most likely to be slower is a common one.Mixed
_idtypesAll three BSON types coexist on dev, as this issue predicted.
_idtypeobjectIdstringobject(embedded)BSON orders
stringbefore embeddedobjectbeforeobjectId, so an ascending_idsort produces three contiguous blocks in that order. Across the whole collection the transitions land at index 107,959 and 114,386, both just past theskipceiling of 100,000. A filtered query reaches them sooner:{"@type":{"$exists":true}}crosses at 25,149 and 31,576.Confirming the earlier note on this thread: the order is total and complete, but it is not oldest-to-newest and should not be documented as if it were.
Tiling verification
Five
limit=100pages equal onelimit=500request, same records in the same order, no repeats. Checked at the driver and again through the running endpoint.skip=2000,{"__rerum.APIversion":{"$exists":true}}skip=99000, same bodystringtoobjecttransition,skip=107709objecttoobjectIdtransition,skip=114136stringtoobjecttransition,skip=24899,{"@type":{"$exists":true}}objecttoobjectIdtransition,skip=31326, same bodyRepeating a request returns the same page.
A trap for anyone re-verifying this
Do not key records on
@idwhen checking dev for duplicates. The 500-record window atskip=31326of{"@type":{"$exists":true}}contains 36 distinct documents that all carry the same stored@id:That string is baked into the stored data. It is a v0 write that concatenated an embedded-object
_idinto a URL, not anything the current code does and not a paging fault. The documents are genuinely distinct, and tiling is correct once records are keyed on their content. It is a cousin of the_id.toString()bug in #307, but that one is a live dedup key in/searchwhile this one is dead data already on disk.Worth its own issue if nobody has filed it. It means those 36 records are not addressable by their own
@id.Status
Implemented on
301-limit-skipin #310:.sort({ _id: 1 })onPOST /query(controllers/crud.js) andHEAD /query(controllers/history.js), plus the ordering guarantee written intopublic/API.html. Full suite passes at 238 tests. The OpenAPI half of the last acceptance criterion is still open and belongs with #305.Remaining caveat unchanged from the issue body: result order changes for every existing
/queryclient, so this wants a soak on dev.thehabes commentedon Sep 10, 2026
Correction to the
explain()figures aboveThe table in my earlier comment was measured on the development cluster only, at a single depth. Development turns out to be the pathological case rather than the representative one, so those numbers overstate the cost of the
_idsort against production by roughly two orders of magnitude. Corrected figures below.The acceptance criterion still holds. No plan contains a blocking in-memory
SORTstage, on either cluster. The planner satisfies the ordering from the_id_index directly. The compound index raised at the bottom of this issue is not required to meet that.What I described wrongly
_idis always indexed and the new plan does use it, asIXSCAN(_id_). The subtlety I missed is that_id_supplies the order but never the predicate. Neither cluster has an index on@typeortype, so those are evaluated in theFETCHstage: every document in_idorder is loaded and tested untilskip + limitmatches accumulate. In every sorted plandocsExaminedequalskeysExamined, which is the signature of a fetch-and-discard walk.The old unsorted plan avoided that by filtering during a
COLLSCANand stopping as soon as the page was full. It was fast because it could quit early, not because collection scans are cheap.Why development is the wrong place to measure this
BSON orders
stringbeforeobjectbeforeobjectId, and the_idcomposition of the two collections is opposite:_idBSON typestringobjectobjectIdDevelopment puts a 107,959 document legacy v0
stringblock at the front of every sorted scan, and that block matches@type: oa:Annotationonly 0.4% of the time. So the walk wades through all of it before reaching data that matches: fillingskip=1000&limit=100examines 107,959 string documents for 383 matches, then takes 717 more from the object block, which is exactly the 108,676 keys observed.Production has no second block, so that cliff does not exist there.
Production figures
explain('executionStats'),limit=100, 336,792 documents. Prevalence is from a 2,000 document random sample.{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"@type":"oa:Annotation"}{"@type":"sc:AnnotationList"}{"@type":"sc:AnnotationList"}{"type":"Annotation"}{"type":"Annotation"}{"@type":"Gloss"}{"@type":"Gloss"}The ratio is a consistent two to three times, and the worst database time observed is 558 ms. The dominant shape, which is half the collection, costs 48 to 125 ms.
The rows returning 0 records are requests past the end of the result set, which a client following the documented short-page rule never sends.
One detail worth keeping:
oa:Annotationexamines 34,313 documents to return its first 100, despite being 51% of the collection. Lexicographic_idorder does cluster by document type on production too. It simply has no separate type block to turn that clustering into a cliff.Ordering guarantee
Page tiling verified through the running app at
skip0, 1000 and 50000 on production: twolimit=10pages tile onelimit=20page exactly, with every record distinct. Verified the same way on development at four depths, including across both BSON type boundaries.Where this leaves the change
The sort is correct and should stay. At production's numbers the cost is an optimisation question for #309 rather than anything blocking.
If it is worth optimising later, a compound index whose prefix is the predicate and whose next key is
_idremoves the fetch-and-discard entirely, because an equality match on the prefix leaves the following key already in sorted order:The existing
primeindex already demonstrates the resulting plan shape, so nothing has to be built to see it:SKIPapplies to index keys and only the returned documents are fetched.Aside, relevant to #307
Production holds zero documents with an embedded-object
_id, against 6,427 on development. The_id.toString()bug tracked there cannot fire in production today.All measurements above are reads. Nothing was written and no index was created.