fix: compute the /mangas total only on the first page

GET /mangas recomputed count(*) over FILTER_WHERE (up to five correlated
subqueries) on every page, so pagination cost scaled with catalog size. The
total doesn't change as a caller walks pages, so compute it once at offset 0
and return null thereafter (the envelope already serialises total as
Option<i64>; the frontend types it number|null and the library route ignores
it).

Bump to 0.124.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 21:14:15 +02:00
parent ce9a727c73
commit ebf0b8289b
7 changed files with 85 additions and 22 deletions

View File

@@ -157,7 +157,9 @@ async fn list(
order,
};
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
Ok(Json(PagedResponse::with_optional_total(
items, limit, offset, total,
)))
}
async fn get_one(

View File

@@ -34,4 +34,18 @@ impl<T> PagedResponse<T> {
page: PageInfo { limit, offset, total: Some(total) },
}
}
/// For handlers that compute `total` only on some pages (e.g. the first)
/// and leave it `None` elsewhere.
pub fn with_optional_total(
items: Vec<T>,
limit: i64,
offset: i64,
total: Option<i64>,
) -> Self {
Self {
items,
page: PageInfo { limit, offset, total },
}
}
}

View File

@@ -163,7 +163,7 @@ const FILTER_WHERE: &str = r#"
/// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`)
/// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep
/// the search filter cheap as the library grows.
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i64)> {
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Option<i64>)> {
// Both `col` and `dir` are interpolated from hard-coded enums, never from
// request input, so this is not a SQL injection seam. The trailing `id` is
// a stable tie-break that keeps pagination deterministic across rows with
@@ -223,22 +223,33 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
.fetch_all(pool)
.await?;
let count_sql = format!(
r#"
SELECT count(*) FROM mangas
WHERE {FILTER_WHERE}
"#
);
let (total,): (i64,) = sqlx::query_as(&count_sql)
.bind(search)
.bind(status)
.bind(&query.author_ids)
.bind(&query.genre_ids)
.bind(&query.tag_ids)
.bind(&query.cw_include)
.bind(&query.cw_exclude)
.fetch_one(pool)
.await?;
// The count reuses FILTER_WHERE — up to five correlated NOT EXISTS/unnest
// subqueries (plus a page_content_warnings join under CW filters) over the
// whole filtered set. Recomputing it on every page made pagination scale
// with catalog size. It doesn't change as the caller walks pages, so
// compute it once on the first page (offset 0) and return None thereafter;
// the pagination envelope serialises that as `total: null`.
let total = if query.offset == 0 {
let count_sql = format!(
r#"
SELECT count(*) FROM mangas
WHERE {FILTER_WHERE}
"#
);
let (total,): (i64,) = sqlx::query_as(&count_sql)
.bind(search)
.bind(status)
.bind(&query.author_ids)
.bind(&query.genre_ids)
.bind(&query.tag_ids)
.bind(&query.cw_include)
.bind(&query.cw_exclude)
.fetch_one(pool)
.await?;
Some(total)
} else {
None
};
Ok((rows, total))
}
@@ -249,7 +260,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
pub async fn list_cards(
pool: &PgPool,
query: &ListQuery,
) -> AppResult<(Vec<MangaCard>, i64)> {
) -> AppResult<(Vec<MangaCard>, Option<i64>)> {
let (rows, total) = list(pool, query).await?;
let cards = cards_from_rows(pool, rows).await?;
Ok((cards, total))