API pagination skipping records at page boundaries

Observed behavior:

  • Fetching /v1/work_orders with limit=100
  • Using cursor from next_cursor in response
  • Page 1 returns records A–J (IDs: 1001–1100)
  • Page 2 returns records B–K (IDs: 1101–1200), but record A from page 1 never appears on page 2
  • After ~10 pages, total count is 40 short of expected

Reproduction:

GET /v1/work_orders?limit=100&cursor=eyJpZCI6MTEwMH0=
# response
{
  "data": [...],
  "next_cursor": "eyJpZCI6MTIwMH0=",
  "has_more": true
}

Timestamp ordering in DB appears stable. No concurrent deletes during fetch. Same behavior observed with customers endpoint.

Documentation states cursor is "opaque" — need to know if implementation is timestamp-based vs ID-based. Gap suggests records with identical sort values are being dropped at boundary.

Parents
  • From a specification standpoint, cursor-based pagination (a.k.a. "keyset pagination") should guarantee stability if and only if the sort key(s) are unique. The current default of created_at — which has millisecond granularity — clearly violates this precondition when ingestion throughput exceeds 1K records/second or when bulk operations set identical timestamps.

    The sort_by=id workaround is effectively switching to a candidate key (assuming id is monotonic), which satisfies the uniqueness requirement. However, I would note that id monotonicity is an implementation detail, not a documented contract. Applications requiring strict ordering guarantees should probably not rely on this.

    It is worth asking whether the API should reject non-unique sort keys with a 400 and a requirement to specify a tie-breaker, i.e., enforce UNIQUE constraint equivalent at the API layer. This would fail fast rather than fail silent.

  • That's a fair critique — we should probably document the sort key behavior more explicitly at minimum. Right now the "opaque cursor" language lets us hand-wave the implementation, which... yeah, leads to exactly these surprises.

    The ULID migration gets us there practically (128-bit sortable unique IDs), but you're right that we don't enforce uniqueness constraints at the API layer. I'll bring that back to the team — at very least we could warn when a sort key has duplicates in the result set.

Reply
  • That's a fair critique — we should probably document the sort key behavior more explicitly at minimum. Right now the "opaque cursor" language lets us hand-wave the implementation, which... yeah, leads to exactly these surprises.

    The ULID migration gets us there practically (128-bit sortable unique IDs), but you're right that we don't enforce uniqueness constraints at the API layer. I'll bring that back to the team — at very least we could warn when a sort key has duplicates in the result set.

Children
No Data