From 38efbff7e183fdb69869f8ed38add09000d81f1c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 19 Oct 2026 12:39:32 -0400 Subject: [PATCH] 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). --- internal/database/querier.go | 5 +++++ internal/database/queries.sql.go | 7 ++++++- internal/database/queries/queries.sql | 7 ++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index 0720684e..7271861e 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 17ed040f..222e9822 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 6c66801c..966335e0 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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