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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.6"
|
version = "0.124.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.6"
|
version = "0.124.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ async fn list(
|
|||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
|
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(
|
async fn get_one(
|
||||||
|
|||||||
@@ -34,4 +34,18 @@ impl<T> PagedResponse<T> {
|
|||||||
page: PageInfo { limit, offset, total: Some(total) },
|
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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const FILTER_WHERE: &str = r#"
|
|||||||
/// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`)
|
/// (`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
|
/// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep
|
||||||
/// the search filter cheap as the library grows.
|
/// 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
|
// 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
|
// 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
|
// 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)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let count_sql = format!(
|
// The count reuses FILTER_WHERE — up to five correlated NOT EXISTS/unnest
|
||||||
r#"
|
// subqueries (plus a page_content_warnings join under CW filters) over the
|
||||||
SELECT count(*) FROM mangas
|
// whole filtered set. Recomputing it on every page made pagination scale
|
||||||
WHERE {FILTER_WHERE}
|
// 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,): (i64,) = sqlx::query_as(&count_sql)
|
let total = if query.offset == 0 {
|
||||||
.bind(search)
|
let count_sql = format!(
|
||||||
.bind(status)
|
r#"
|
||||||
.bind(&query.author_ids)
|
SELECT count(*) FROM mangas
|
||||||
.bind(&query.genre_ids)
|
WHERE {FILTER_WHERE}
|
||||||
.bind(&query.tag_ids)
|
"#
|
||||||
.bind(&query.cw_include)
|
);
|
||||||
.bind(&query.cw_exclude)
|
let (total,): (i64,) = sqlx::query_as(&count_sql)
|
||||||
.fetch_one(pool)
|
.bind(search)
|
||||||
.await?;
|
.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))
|
Ok((rows, total))
|
||||||
}
|
}
|
||||||
@@ -249,7 +260,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
|||||||
pub async fn list_cards(
|
pub async fn list_cards(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
query: &ListQuery,
|
query: &ListQuery,
|
||||||
) -> AppResult<(Vec<MangaCard>, i64)> {
|
) -> AppResult<(Vec<MangaCard>, Option<i64>)> {
|
||||||
let (rows, total) = list(pool, query).await?;
|
let (rows, total) = list(pool, query).await?;
|
||||||
let cards = cards_from_rows(pool, rows).await?;
|
let cards = cards_from_rows(pool, rows).await?;
|
||||||
Ok((cards, total))
|
Ok((cards, total))
|
||||||
|
|||||||
@@ -99,6 +99,42 @@ async fn list_returns_total_count_independent_of_pagination(pool: PgPool) {
|
|||||||
assert_eq!(body["page"]["total"], 3);
|
assert_eq!(body["page"]["total"], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
for title in ["One Piece", "Berserk", "Vinland Saga"] {
|
||||||
|
seed(&h.app, &cookie, title).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 1 (offset 0): total is the full population.
|
||||||
|
let body0 = common::body_json(
|
||||||
|
h.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get("/api/v1/mangas?limit=2&offset=0"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(body0["page"]["total"], 3);
|
||||||
|
|
||||||
|
// Page 2 (offset 2): total is omitted (null) — the correlated count is
|
||||||
|
// not recomputed on every page. Items still paginate correctly.
|
||||||
|
let body1 = common::body_json(
|
||||||
|
h.app
|
||||||
|
.oneshot(common::get("/api/v1/mangas?limit=2&offset=2"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
body1["page"]["total"].is_null(),
|
||||||
|
"total should be null past the first page, got {}",
|
||||||
|
body1["page"]["total"]
|
||||||
|
);
|
||||||
|
assert_eq!(body1["items"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
|
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.124.6",
|
"version": "0.124.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user