fix(db): total ordering for series OFFSET pagination

GetDistinctSeries ordered by MAX(created_at) DESC alone. Bulk imports
inserted in a single transaction share one NOW() timestamp, leaving
large numbers of (library_id, series) groups tied on MAX(created_at);
Postgres does not guarantee tie order between executions, so
LIMIT/OFFSET pages could repeat or drop series across page loads.

Add the GROUP BY keys (library_id, series) as tiebreakers so the sort
is total and deterministic. Comment documents why the tiebreaker is
required so it is not dropped as noise. sqlc regenerated (interface
unchanged; the comment flows into querier.go docs).
This commit is contained in:
John O'Keefe committed 2026-10-19 12:39:32 -04:00
1 parent f4414aa5b6
commit 38efbff7e1
3 files changed
+17 -2

No files matched your search

+5
View File
@@ -221,6 +221,11 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
+6 -1
View File
@@ -3840,7 +3840,7 @@ WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT $4 OFFSET $3
`
@@ -3862,6 +3862,11 @@ type GetDistinctSeriesRow struct {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.LibraryID,
+6 -1
View File
@@ -2880,7 +2880,12 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied on
-- MAX(created_at). Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series. The GROUP BY
-- keys make the sort total and deterministic.
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one