perf: denormalize content-warning filter to manga_content_warnings
The content-warning include/exclude filter (and its count) ran a correlated page_content_warnings->pages->chapters join per candidate manga — O(mangas * pages) on every filtered list. Add a manga_content_warnings(manga_id, warning) table holding each manga's deduped warning union, maintained by triggers that recompute an affected manga's set (delete+reinsert of the tiny five-label set, robust across page/chapter/manga cascade deletes), backfilled in the migration. The filter is now a single indexed lookup. 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.22"
|
version = "0.124.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.22"
|
version = "0.124.23"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
-- Denormalized manga -> content-warning set.
|
||||||
|
--
|
||||||
|
-- The list filter previously tested each candidate manga with a correlated
|
||||||
|
-- `page_content_warnings -> pages -> chapters` join, i.e. O(mangas * pages) on
|
||||||
|
-- every filtered list AND its count. This table holds the DISTINCT union of a
|
||||||
|
-- manga's page warnings so the filter is a single indexed lookup.
|
||||||
|
--
|
||||||
|
-- Kept in sync by triggers that recompute an affected manga's set from current
|
||||||
|
-- data (the set is tiny — at most the five moderation labels — so a
|
||||||
|
-- delete-and-reinsert per change is cheap and always correct, sidestepping the
|
||||||
|
-- cascade-ordering hazards of incremental maintenance).
|
||||||
|
|
||||||
|
CREATE TABLE manga_content_warnings (
|
||||||
|
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
|
||||||
|
warning text NOT NULL
|
||||||
|
CHECK (warning IN ('sexual', 'nudity', 'gore', 'violence', 'disturbing')),
|
||||||
|
PRIMARY KEY (manga_id, warning)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- warning -> mangas, for the include/exclude list filters.
|
||||||
|
CREATE INDEX manga_content_warnings_warning_idx ON manga_content_warnings (warning);
|
||||||
|
|
||||||
|
-- Recompute a single manga's warning set from the live per-page rows.
|
||||||
|
CREATE OR REPLACE FUNCTION mcw_refresh_for_manga(mid uuid) RETURNS void AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Skip when the manga is gone (e.g. mid-cascade of a manga delete) so we
|
||||||
|
-- never re-insert a row that would violate the FK / resurrect a deleted set.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM mangas WHERE id = mid) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
DELETE FROM manga_content_warnings WHERE manga_id = mid;
|
||||||
|
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||||
|
SELECT DISTINCT mid, pw.warning
|
||||||
|
FROM page_content_warnings pw
|
||||||
|
JOIN pages p ON p.id = pw.page_id
|
||||||
|
JOIN chapters c ON c.id = p.chapter_id
|
||||||
|
WHERE c.manga_id = mid;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- page_content_warnings changed for a page: refresh that page's manga.
|
||||||
|
CREATE OR REPLACE FUNCTION mcw_on_pcw_change() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
mid uuid;
|
||||||
|
pid uuid := COALESCE(NEW.page_id, OLD.page_id);
|
||||||
|
BEGIN
|
||||||
|
-- The page (and thus chapter) may already be gone when this fires as part
|
||||||
|
-- of a pages/chapters cascade; in that case the pages/chapters triggers do
|
||||||
|
-- the refresh instead, so a missing join here is harmless.
|
||||||
|
SELECT c.manga_id INTO mid
|
||||||
|
FROM pages p JOIN chapters c ON c.id = p.chapter_id
|
||||||
|
WHERE p.id = pid;
|
||||||
|
IF mid IS NOT NULL THEN
|
||||||
|
PERFORM mcw_refresh_for_manga(mid);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER mcw_pcw_ins AFTER INSERT ON page_content_warnings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||||
|
CREATE TRIGGER mcw_pcw_del AFTER DELETE ON page_content_warnings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||||
|
|
||||||
|
-- A page was deleted (directly, or via a chapter cascade): its
|
||||||
|
-- page_content_warnings rows are already cascade-gone, so recompute from the
|
||||||
|
-- chapter's manga. If the chapter is gone too, the chapters trigger covers it.
|
||||||
|
CREATE OR REPLACE FUNCTION mcw_on_page_delete() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
mid uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT c.manga_id INTO mid FROM chapters c WHERE c.id = OLD.chapter_id;
|
||||||
|
IF mid IS NOT NULL THEN
|
||||||
|
PERFORM mcw_refresh_for_manga(mid);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER mcw_page_del AFTER DELETE ON pages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION mcw_on_page_delete();
|
||||||
|
|
||||||
|
-- A chapter was deleted (directly, or via a manga cascade): recompute from the
|
||||||
|
-- chapter's manga. `chapters.manga_id` is on the row, so it is always available
|
||||||
|
-- even after the child pages have cascaded; the refresh no-ops when the manga
|
||||||
|
-- itself is being deleted.
|
||||||
|
CREATE OR REPLACE FUNCTION mcw_on_chapter_delete() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM mcw_refresh_for_manga(OLD.manga_id);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER mcw_chapter_del AFTER DELETE ON chapters
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION mcw_on_chapter_delete();
|
||||||
|
|
||||||
|
-- Backfill from existing per-page rows.
|
||||||
|
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||||
|
SELECT DISTINCT c.manga_id, pw.warning
|
||||||
|
FROM page_content_warnings pw
|
||||||
|
JOIN pages p ON p.id = pw.page_id
|
||||||
|
JOIN chapters c ON c.id = p.chapter_id
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -144,17 +144,13 @@ const FILTER_WHERE: &str = r#"
|
|||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM page_content_warnings pw
|
SELECT 1 FROM manga_content_warnings mcw
|
||||||
JOIN pages p ON p.id = pw.page_id
|
WHERE mcw.manga_id = mangas.id AND mcw.warning = req.w
|
||||||
JOIN chapters c ON c.id = p.chapter_id
|
|
||||||
WHERE c.manga_id = mangas.id AND pw.warning = req.w
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM page_content_warnings pw
|
SELECT 1 FROM manga_content_warnings mcw
|
||||||
JOIN pages p ON p.id = pw.page_id
|
WHERE mcw.manga_id = mangas.id AND mcw.warning = ANY($7::text[])
|
||||||
JOIN chapters c ON c.id = p.chapter_id
|
|
||||||
WHERE c.manga_id = mangas.id AND pw.warning = ANY($7::text[])
|
|
||||||
)
|
)
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,46 @@ async fn list_filters_by_content_warning(pool: PgPool) {
|
|||||||
assert_eq!(ids, want);
|
assert_eq!(ids, want);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn denormalized_warnings_track_chapter_deletion(pool: PgPool) {
|
||||||
|
// The denormalized manga_content_warnings table must stay in sync when the
|
||||||
|
// underlying pages disappear. Deleting the only chapter (which cascade-
|
||||||
|
// deletes its pages and their page_content_warnings) must drop the manga
|
||||||
|
// from the include filter.
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let gory = seed_manga(&pool, "Gory", &[&["gore"]]).await;
|
||||||
|
|
||||||
|
// Present in the denorm table and matched by the filter.
|
||||||
|
let count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1")
|
||||||
|
.bind(gory)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "warning denormalized on insert");
|
||||||
|
assert_eq!(list_ids(&h.app, "cw_include=gore").await, vec![gory.to_string()]);
|
||||||
|
|
||||||
|
// Delete the chapter → cascade removes pages + page_content_warnings →
|
||||||
|
// trigger recomputes the (now empty) set for this manga.
|
||||||
|
sqlx::query("DELETE FROM chapters WHERE manga_id = $1")
|
||||||
|
.bind(gory)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1")
|
||||||
|
.bind(gory)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0, "warning removed after chapter (and pages) deleted");
|
||||||
|
assert!(
|
||||||
|
list_ids(&h.app, "cw_include=gore").await.is_empty(),
|
||||||
|
"manga no longer matches the include filter"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn list_rejects_unknown_warning(pool: PgPool) {
|
async fn list_rejects_unknown_warning(pool: PgPool) {
|
||||||
let h = common::harness(pool.clone());
|
let h = common::harness(pool.clone());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.124.22",
|
"version": "0.124.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user