//! Persistence for crawled mangas. //! //! High-level operations: //! - [`ensure_source`]: idempotent registration of a source row. //! - [`upsert_manga_from_source`]: end-to-end "I saw this manga" — //! creates or updates the `mangas` row, threads `manga_sources`, and //! refreshes authors/genres/tags. Returns whether the manga is new, //! updated (metadata_hash changed), or unchanged. //! - [`sync_manga_chapters`]: per-manga chapter reconciliation. Adds //! new ones, refreshes URLs on existing ones, soft-drops vanished. //! - [`mark_run_started`] / [`mark_run_completed`] / //! [`last_run_completed_cleanly`]: per-source recovery flag in //! `crawler_state`. A `false` flag on tick start means the previous //! run did not exit cleanly and the next walk should ignore the //! early-stop condition. //! //! Each public function is a transaction boundary so a partial failure //! mid-call leaves the DB in its pre-call state. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{FromRow, PgPool, Postgres, Transaction}; use uuid::Uuid; use crate::crawler::source::{SourceChapterRef, SourceManga}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpsertStatus { New, Updated, Unchanged, } #[derive(Debug, Clone)] pub struct UpsertedManga { pub manga_id: Uuid, pub status: UpsertStatus, /// Current value of `mangas.cover_image_path` after the upsert. /// `None` means the cover hasn't been downloaded yet — the caller /// uses this to backfill covers for mangas that were synced before /// cover-download support existed. pub cover_image_path: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ChapterDiff { pub new: usize, pub refreshed: usize, pub dropped: usize, } pub async fn ensure_source( pool: &PgPool, id: &str, name: &str, base_url: &str, ) -> sqlx::Result<()> { sqlx::query( r#" INSERT INTO sources (id, name, base_url, enabled) VALUES ($1, $2, $3, true) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, base_url = EXCLUDED.base_url "#, ) .bind(id) .bind(name) .bind(base_url) .execute(pool) .await?; Ok(()) } pub async fn upsert_manga_from_source( pool: &PgPool, source_id: &str, source_url: &str, sm: &SourceManga, ) -> sqlx::Result { let mut tx = pool.begin().await?; let existing: Option<(Uuid, Option)> = sqlx::query_as( r#" SELECT manga_id, metadata_hash FROM manga_sources WHERE source_id = $1 AND source_manga_key = $2 "#, ) .bind(source_id) .bind(&sm.source_manga_key) .fetch_optional(&mut *tx) .await?; let status_db = sm.status.as_deref().unwrap_or("ongoing"); // Note: `cover_image_path` is intentionally not written here. // The repo layer doesn't know about the storage backend, so the // caller (crawler binary) downloads the cover via the `Storage` // trait and sets the path with `repo::manga::set_cover_image_path` // once the bytes have landed. let (manga_id, status) = match existing { None => { let (id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO mangas (title, description, status, alt_titles) VALUES ($1, $2, $3, $4) RETURNING id "#, ) .bind(&sm.title) .bind(sm.summary.as_deref()) .bind(status_db) .bind(&sm.alternative_titles) .fetch_one(&mut *tx) .await?; (id, UpsertStatus::New) } Some((id, prev_hash)) if prev_hash.as_deref() == Some(&sm.metadata_hash) => { (id, UpsertStatus::Unchanged) } Some((id, _)) => { sqlx::query( r#" UPDATE mangas SET title = $1, description = $2, status = $3, alt_titles = $4, updated_at = NOW() WHERE id = $5 "#, ) .bind(&sm.title) .bind(sm.summary.as_deref()) .bind(status_db) .bind(&sm.alternative_titles) .bind(id) .execute(&mut *tx) .await?; (id, UpsertStatus::Updated) } }; sqlx::query( r#" INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url, metadata_hash, last_seen_at, dropped_at) VALUES ($1, $2, $3, $4, $5, NOW(), NULL) ON CONFLICT (source_id, source_manga_key) DO UPDATE SET source_url = EXCLUDED.source_url, metadata_hash = EXCLUDED.metadata_hash, last_seen_at = NOW(), dropped_at = NULL "#, ) .bind(source_id) .bind(&sm.source_manga_key) .bind(manga_id) .bind(source_url) .bind(&sm.metadata_hash) .execute(&mut *tx) .await?; sync_authors(&mut tx, manga_id, &sm.authors).await?; sync_genres(&mut tx, manga_id, &sm.genres).await?; sync_tags(&mut tx, manga_id, &sm.tags).await?; let cover_image_path: Option = sqlx::query_scalar("SELECT cover_image_path FROM mangas WHERE id = $1") .bind(manga_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(UpsertedManga { manga_id, status, cover_image_path, }) } async fn sync_authors( tx: &mut Transaction<'_, Postgres>, manga_id: Uuid, authors: &[String], ) -> sqlx::Result<()> { sqlx::query("DELETE FROM manga_authors WHERE manga_id = $1") .bind(manga_id) .execute(&mut **tx) .await?; for (i, name) in authors.iter().enumerate() { let trimmed = name.trim(); if trimmed.is_empty() { continue; } // Self-update on conflict so the row id is always returned — // can't use DO NOTHING because that suppresses RETURNING. let (author_id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO authors (name) VALUES ($1) ON CONFLICT (lower(name)) DO UPDATE SET name = authors.name RETURNING id "#, ) .bind(trimmed) .fetch_one(&mut **tx) .await?; sqlx::query( r#" INSERT INTO manga_authors (manga_id, author_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING "#, ) .bind(manga_id) .bind(author_id) .bind(i as i32) .execute(&mut **tx) .await?; } Ok(()) } async fn sync_genres( tx: &mut Transaction<'_, Postgres>, manga_id: Uuid, genres: &[String], ) -> sqlx::Result<()> { sqlx::query("DELETE FROM manga_genres WHERE manga_id = $1") .bind(manga_id) .execute(&mut **tx) .await?; for name in genres { let trimmed = name.trim(); if trimmed.is_empty() { continue; } // Case-insensitive lookup so a source-supplied "action" // attaches to the seeded "Action" rather than creating a // second row. let existing: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM genres WHERE lower(name) = lower($1)") .bind(trimmed) .fetch_optional(&mut **tx) .await?; let genre_id = match existing { Some((id,)) => id, None => { let (id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO genres (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = genres.name RETURNING id "#, ) .bind(trimmed) .fetch_one(&mut **tx) .await?; tracing::info!(genre = trimmed, "added new genre from source"); id } }; sqlx::query( "INSERT INTO manga_genres (manga_id, genre_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(manga_id) .bind(genre_id) .execute(&mut **tx) .await?; } Ok(()) } async fn sync_tags( tx: &mut Transaction<'_, Postgres>, manga_id: Uuid, tags: &[String], ) -> sqlx::Result<()> { // Only clear crawler-owned attachments (added_by IS NULL). User- // attached tags are owned by the attaching user and must survive // the recurring metadata pass — see manga_tags.added_by in // migration 0009. // // Note on orphans: `manga_tags.added_by` is `ON DELETE SET NULL`, // so an attachment whose user was deleted becomes // indistinguishable from a crawler-owned row and is cleaned up // here. That mirrors how `api::mangas::detach_tag` already treats // orphans ("nobody owns it, refuse to let anyone but admin clear // them") — the crawler now becomes the eventual reaper. Tracked // by `sync_tags_garbage_collects_orphan_user_attachments` in // backend/tests/crawler_sync.rs. sqlx::query("DELETE FROM manga_tags WHERE manga_id = $1 AND added_by IS NULL") .bind(manga_id) .execute(&mut **tx) .await?; for name in tags { let trimmed = name.trim(); if trimmed.is_empty() { continue; } let (tag_id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO tags (name) VALUES ($1) ON CONFLICT (lower(name)) DO UPDATE SET name = tags.name RETURNING id "#, ) .bind(trimmed) .fetch_one(&mut **tx) .await?; sqlx::query( r#" INSERT INTO manga_tags (manga_id, tag_id, added_by) VALUES ($1, $2, NULL) ON CONFLICT DO NOTHING "#, ) .bind(manga_id) .bind(tag_id) .execute(&mut **tx) .await?; } Ok(()) } pub async fn sync_manga_chapters( pool: &PgPool, source_id: &str, manga_id: Uuid, chapters: &[SourceChapterRef], ) -> sqlx::Result { let mut tx = pool.begin().await?; // Per-manga advisory lock. Two concurrent calls for the same manga // would otherwise both read `seen_keys`, both run the drop UPDATE // filtered on `NOT (key = ANY $3)`, and the later commit could soft- // drop a chapter the earlier commit had just inserted (lost-update // shape under MVCC). `pg_advisory_xact_lock` is scoped to this // transaction: it auto-releases on COMMIT/ROLLBACK so a Rust-side // panic mid-call doesn't strand the lock. The single-arg int8 form // keyed by `hashtextextended(manga_id::text, 0)` shares Postgres' // global advisory-lock namespace with `CRON_LOCK_KEY`, but collision // is 2^-64 per pair (a UUID-derived hash hitting the fixed cron key // is effectively impossible). sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))") .bind(manga_id) .execute(&mut *tx) .await?; let mut diff = ChapterDiff::default(); let seen_keys: Vec = chapters .iter() .map(|c| c.source_chapter_key.clone()) .collect(); for (idx, c) in chapters.iter().enumerate() { // `source_index` captures the chapter's position in the source // DOM (0 = first = newest on this site) so the list query can // reverse it for the user-facing list — see migration 0021. // Every sync overwrites the value on both branches, so a new // chapter inserted at the top of the source shifts every other // row down by one on the next tick. let source_index = idx as i32; // Lookup is constrained by manga_id (via the chapters join) so a // source whose chapter slugs collide across mangas (e.g. // "chapter-1" appearing under two different mangas) attributes // each row to the correct manga. Migration 0017 dropped the // (source_id, source_chapter_key) PK in favour of // (source_id, chapter_id) for exactly this reason. let existing: Option<(Uuid,)> = sqlx::query_as( "SELECT cs.chapter_id \ FROM chapter_sources cs \ JOIN chapters ch ON ch.id = cs.chapter_id \ WHERE cs.source_id = $1 \ AND cs.source_chapter_key = $2 \ AND ch.manga_id = $3", ) .bind(source_id) .bind(&c.source_chapter_key) .bind(manga_id) .fetch_optional(&mut *tx) .await?; match existing { None => { // New chapter row. As of 0013 there's no (manga_id, // number) UNIQUE, so duplicate-numbered chapters from // the source (different uploaders, notices, alt // translations) each get their own row — chapter // identity is the UUID, not the number. let (chapter_id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO chapters (manga_id, number, title, page_count, source_index) VALUES ($1, $2, $3, 0, $4) RETURNING id "#, ) .bind(manga_id) .bind(c.number) .bind(c.title.as_deref()) .bind(source_index) .fetch_one(&mut *tx) .await?; sqlx::query( r#" INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, last_seen_at, dropped_at) VALUES ($1, $2, $3, $4, NOW(), NULL) "#, ) .bind(source_id) .bind(&c.source_chapter_key) .bind(chapter_id) .bind(&c.url) .execute(&mut *tx) .await?; diff.new += 1; } Some((chapter_id,)) => { sqlx::query( "UPDATE chapters SET title = $1, source_index = $2 WHERE id = $3", ) .bind(c.title.as_deref()) .bind(source_index) .bind(chapter_id) .execute(&mut *tx) .await?; // chapter_id is now the natural per-(source, chapter) // identifier — use it directly instead of re-keying on // (source_id, source_chapter_key) which may not be unique. sqlx::query( r#" UPDATE chapter_sources SET source_url = $1, last_seen_at = NOW(), dropped_at = NULL WHERE source_id = $2 AND chapter_id = $3 "#, ) .bind(&c.url) .bind(source_id) .bind(chapter_id) .execute(&mut *tx) .await?; diff.refreshed += 1; } } } // Soft-drop any chapter previously seen from this source for this // manga that's not in the current list. let result = sqlx::query( r#" UPDATE chapter_sources cs SET dropped_at = NOW() FROM chapters ch WHERE cs.chapter_id = ch.id AND ch.manga_id = $1 AND cs.source_id = $2 AND cs.dropped_at IS NULL AND NOT (cs.source_chapter_key = ANY($3)) "#, ) .bind(manga_id) .bind(source_id) .bind(&seen_keys) .execute(&mut *tx) .await?; diff.dropped = result.rows_affected() as usize; tx.commit().await?; Ok(diff) } /// Count the chapters that the source `(source_id, source_manga_key)` /// is currently known to attach to — i.e. the number of `chapter_sources` /// rows for the manga identified by the (source_id, source_manga_key) /// pair, restricted to live (`dropped_at IS NULL`) rows. /// /// Used by the metadata pass's partial-render guard: if `fetch_manga` /// returns an empty `chapters` Vec but the source previously surfaced /// chapters here, that's most likely a chromium snapshot taken between /// the `#chapter_table` wrapper render and its rows render — the /// safest move is to skip `sync_manga_chapters` so the soft-drop /// branch doesn't flip every existing chapter to `dropped_at`. /// /// Returns `Ok(0)` when the manga is brand-new (no `manga_sources` /// row yet), which is the legitimate "this manga has no chapters yet" /// case and must NOT be flagged. pub async fn live_chapter_count_for_source_manga( pool: &PgPool, source_id: &str, source_manga_key: &str, ) -> sqlx::Result { let row: Option<(i64,)> = sqlx::query_as( "SELECT COUNT(*) \ FROM chapter_sources cs \ JOIN chapters c ON c.id = cs.chapter_id \ JOIN manga_sources ms \ ON ms.manga_id = c.manga_id \ AND ms.source_id = cs.source_id \ WHERE ms.source_id = $1 \ AND ms.source_manga_key = $2 \ AND cs.dropped_at IS NULL", ) .bind(source_id) .bind(source_manga_key) .fetch_optional(pool) .await?; Ok(row.map(|(n,)| n).unwrap_or(0)) } /// Mark a metadata pass as in-flight for `source_id`. Stamps /// `last_run_completed:` in `crawler_state` with /// `{"completed": false, "at": now}`. A crash, panic, or SIGKILL after /// this point leaves the flag at `false`, which the next tick reads as /// "previous run did not exit cleanly — walk the full catalog this /// time" (recovery sweep). pub async fn mark_run_started(pool: &PgPool, source_id: &str) -> sqlx::Result<()> { let key = format!("last_run_completed:{source_id}"); sqlx::query( "INSERT INTO crawler_state (key, value, updated_at) \ VALUES ($1, $2, now()) \ ON CONFLICT (key) DO UPDATE \ SET value = EXCLUDED.value, updated_at = now()", ) .bind(&key) .bind(serde_json::json!({ "completed": false, "at": Utc::now().to_rfc3339(), })) .execute(pool) .await?; Ok(()) } /// Mark a metadata pass as completed cleanly for `source_id`. Called /// from the same place a run decides it reached end-of-walk or hit the /// intentional stop. The next tick reads `true` and applies the normal /// stop condition. pub async fn mark_run_completed(pool: &PgPool, source_id: &str) -> sqlx::Result<()> { let key = format!("last_run_completed:{source_id}"); sqlx::query( "INSERT INTO crawler_state (key, value, updated_at) \ VALUES ($1, $2, now()) \ ON CONFLICT (key) DO UPDATE \ SET value = EXCLUDED.value, updated_at = now()", ) .bind(&key) .bind(serde_json::json!({ "completed": true, "at": Utc::now().to_rfc3339(), })) .execute(pool) .await?; Ok(()) } /// List mangas whose `cover_image_path IS NULL` but a live /// `manga_sources` row still attaches them to a source. The bounded /// result feeds the cover-backfill pass in [`crate::crawler::pipeline`]: /// each entry is one (manga, freshest source row) pair where a cover /// re-download is in order. /// /// Per-manga deduplication uses `DISTINCT ON (m.id)` keyed on the row /// with the newest `last_seen_at`, so a manga that's surfaced by /// multiple sources only produces one row (the freshest). Sort is /// stable for tests. pub async fn list_missing_covers( pool: &PgPool, max: i64, ) -> sqlx::Result> { let rows: Vec<(Uuid, String, String)> = sqlx::query_as( r#" SELECT DISTINCT ON (m.id) m.id, ms.source_manga_key, ms.source_url FROM mangas m JOIN manga_sources ms ON ms.manga_id = m.id WHERE m.cover_image_path IS NULL AND ms.dropped_at IS NULL ORDER BY m.id, ms.last_seen_at DESC LIMIT $1 "#, ) .bind(max) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(manga_id, source_manga_key, source_url)| MissingCoverEntry { manga_id, source_manga_key, source_url, }) .collect()) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MissingCoverEntry { pub manga_id: Uuid, pub source_manga_key: String, pub source_url: String, } /// Read the recovery flag for `source_id`. A missing row OR an /// unparseable value reads as `true` ("clean") — the former covers the /// first-ever run on a virgin DB (no recovery needed), the latter /// covers forward-compat against future schema changes; both fail-safe /// toward not making an operator pay for an unnecessary full sweep. pub async fn last_run_completed_cleanly( pool: &PgPool, source_id: &str, ) -> sqlx::Result { let key = format!("last_run_completed:{source_id}"); let row: Option = sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = $1") .bind(&key) .fetch_optional(pool) .await?; Ok(row .and_then(|v| v.get("completed").and_then(|b| b.as_bool())) .unwrap_or(true)) } /// Timestamp of the most recent metadata-pass cron tick, or `None` if the /// daemon has never ticked (fresh DB / cron disabled). Stored by the daemon /// under `crawler_state['last_metadata_tick_at']` as `{"at": }`. /// Used by the health check that flags a stale/stuck cron. pub async fn last_metadata_tick_at( pool: &PgPool, ) -> sqlx::Result>> { let row: Option = sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = 'last_metadata_tick_at'") .fetch_optional(pool) .await?; Ok(row.and_then(|v| { v.get("at") .and_then(|s| s.as_str()) .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) .map(|dt| dt.with_timezone(&Utc)) })) } // --------------------------------------------------------------------------- // Reconcile: find source-list mangas missing from the DB. // --------------------------------------------------------------------------- /// All `source_manga_key`s we have a `manga_sources` row for under /// `source_id`. Intentionally **not** filtered by `dropped_at`: a /// soft-dropped row still counts as "present" so reconcile only enqueues /// mangas it has truly never seen (strict `NOT EXISTS`). pub async fn existing_source_keys( pool: &PgPool, source_id: &str, ) -> sqlx::Result> { let rows: Vec = sqlx::query_scalar("SELECT source_manga_key FROM manga_sources WHERE source_id = $1") .bind(source_id) .fetch_all(pool) .await?; Ok(rows.into_iter().collect()) } /// `source_manga_key`s that already have a `SyncManga` job in a state that /// should block re-enqueue: `pending`/`running` (in flight) **and** `dead` /// (leave-dead — a gone manga is not retried by a later reconcile). `done` /// and `failed` (mid-backoff) do not block. pub async fn sync_manga_keys_with_blocking_job( pool: &PgPool, source_id: &str, ) -> sqlx::Result> { let rows: Vec = sqlx::query_scalar( "SELECT DISTINCT payload->>'source_manga_key' \ FROM crawler_jobs \ WHERE payload->>'kind' = 'sync_manga' \ AND payload->>'source_id' = $1 \ AND state IN ('pending', 'running', 'dead') \ AND payload->>'source_manga_key' IS NOT NULL", ) .bind(source_id) .fetch_all(pool) .await?; Ok(rows.into_iter().collect()) } // --------------------------------------------------------------------------- // Dead-letter jobs: admin observability + requeue. // --------------------------------------------------------------------------- /// A `dead` crawler job joined to its chapter/manga context for the admin /// dead-letter view. Chapter columns are `Option` because the join is /// best-effort (the chapter may have been removed since the job died, or /// the job may be a non-chapter kind). #[derive(Debug, Clone, Serialize, FromRow)] pub struct DeadJob { pub id: Uuid, pub kind: String, pub chapter_id: Option, pub manga_id: Option, pub manga_title: Option, pub chapter_number: Option, /// Title carried in the job payload. For `sync_manga` jobs whose manga /// was never upserted there is no `manga_id`/`manga_title`, so the UI /// falls back to this. pub payload_title: Option, /// Source detail URL carried in the payload (currently `sync_manga`). pub source_url: Option, /// Source-native key carried in the payload (currently `sync_manga`). pub source_key: Option, pub attempts: i32, pub max_attempts: i32, pub last_error: Option, pub updated_at: DateTime, } /// Paginated list of `dead` jobs, newest-failed first, joined to chapter + /// manga context. `search` filters on manga title (case-insensitive /// substring). Returns the page slice plus the unfiltered-by-page total. pub async fn list_dead_jobs( pool: &PgPool, search: Option<&str>, limit: i64, offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search .map(|s| format!("%{}%", s.trim())) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( r#" SELECT cj.id, cj.payload->>'kind' AS kind, (cj.payload->>'chapter_id')::uuid AS chapter_id, c.manga_id AS manga_id, m.title AS manga_title, c.number AS chapter_number, cj.payload->>'title' AS payload_title, cj.payload->>'url' AS source_url, cj.payload->>'source_manga_key' AS source_key, cj.attempts, cj.max_attempts, cj.last_error, cj.updated_at FROM crawler_jobs cj LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state = 'dead' AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1) ORDER BY cj.updated_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(&search_pat) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let total: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM crawler_jobs cj LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state = 'dead' AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1) "#, ) .bind(&search_pat) .fetch_one(pool) .await?; Ok((items, total)) } /// An in-flight chapter-content job (`pending` or `running`) joined to its /// chapter + manga, for the "queued chapters" admin view. #[derive(Debug, Clone, Serialize, FromRow)] pub struct ActiveJob { pub id: Uuid, pub chapter_id: Option, pub manga_id: Option, pub manga_title: Option, pub chapter_number: Option, /// `"pending"` or `"running"`. pub state: String, pub attempts: i32, pub max_attempts: i32, pub updated_at: DateTime, } /// Paginated list of `pending`/`running` chapter-content jobs (which /// chapters of which mangas are queued or being crawled). Running first, /// then by scheduled order. `search` filters on manga title. pub async fn list_active_jobs( pool: &PgPool, search: Option<&str>, limit: i64, offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search .map(|s| format!("%{}%", s.trim())) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( r#" SELECT cj.id, (cj.payload->>'chapter_id')::uuid AS chapter_id, c.manga_id AS manga_id, m.title AS manga_title, c.number AS chapter_number, cj.state, cj.attempts, cj.max_attempts, cj.updated_at FROM crawler_jobs cj LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state IN ('pending','running') AND cj.payload->>'kind' = 'sync_chapter_content' AND ($1::text IS NULL OR m.title ILIKE $1) ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at LIMIT $2 OFFSET $3 "#, ) .bind(&search_pat) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let total: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM crawler_jobs cj LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state IN ('pending','running') AND cj.payload->>'kind' = 'sync_chapter_content' AND ($1::text IS NULL OR m.title ILIKE $1) "#, ) .bind(&search_pat) .fetch_one(pool) .await?; Ok((items, total)) } // --------------------------------------------------------------------------- // Job history: unified, searchable, filterable view over the queue table. // --------------------------------------------------------------------------- /// A `crawler_jobs` row resolved to human context for the admin history /// table. Works across all job kinds: the target columns are best-effort /// `Option`s resolved through whichever payload reference the kind carries /// (`chapter_id`, `manga_id`, or `page_id` via the page breadcrumb), so a /// `sync_manga` job (which has neither yet) simply leaves them `None` and /// falls back to `source_key`. #[derive(Debug, Clone, Serialize, FromRow)] pub struct JobHistoryRow { pub id: Uuid, /// `pending` | `running` | `done` | `dead` (the live queue states). pub state: String, /// `sync_manga` | `sync_chapter_list` | `sync_chapter_content` | `analyze_page`. pub kind: Option, pub manga_id: Option, pub manga_title: Option, pub chapter_id: Option, pub chapter_number: Option, /// Set only for `analyze_page` (resolved through the page breadcrumb). pub page_number: Option, /// Source-side key, the only target a `sync_manga` job carries. pub source_key: Option, /// Title carried in the payload — the display fallback for `sync_manga` /// jobs whose manga was never upserted (no `manga_title`). pub payload_title: Option, /// Source detail URL carried in the payload (currently `sync_manga`). pub source_url: Option, pub attempts: i32, pub max_attempts: i32, pub last_error: Option, pub updated_at: DateTime, /// How long the job's work took, when a timing was recorded: the latest /// `chapter` crawl-metric for a chapter job, else the page's analysis /// duration for an `analyze_page` job. `None` for kinds we don't time or /// jobs not yet completed. pub duration_ms: Option, } /// Filters for [`list_job_history`]. All optional; `None` widens the scope. #[derive(Debug, Default, Clone)] pub struct JobHistoryFilter<'a> { /// Exact queue state (`done`, `dead`, `running`, `pending`). pub state: Option<&'a str>, /// Exact payload `kind`. pub kind: Option<&'a str>, /// Case-insensitive manga-title substring. pub search: Option<&'a str>, } /// Paginated, newest-first view of the job queue across every state and /// kind — the searchable/filterable history surface. Joins each job to its /// manga/chapter/page context (best-effort) so the table can label rows. /// Returns the page slice plus the filtered total for pagination. /// /// History depth is bounded by the done-job reaper (`reap_done`): completed /// jobs older than the retention window are gone. Terminal `dead` jobs /// persist until requeued. pub async fn list_job_history( pool: &PgPool, filter: JobHistoryFilter<'_>, limit: i64, offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = filter .search .map(|s| format!("%{}%", s.trim())) .filter(|p| p.len() > 2); // The same FROM/JOIN/WHERE drives both the page slice and the count, so // they stay in lockstep. `pg` resolves analyze_page → page → chapter; // `COALESCE` lets one set of joins serve every kind. let items: Vec = sqlx::query_as( r#" SELECT cj.id, cj.state, cj.payload->>'kind' AS kind, COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) AS manga_id, m.title AS manga_title, COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) AS chapter_id, c.number AS chapter_number, pg.page_number AS page_number, cj.payload->>'source_manga_key' AS source_key, cj.payload->>'title' AS payload_title, cj.payload->>'url' AS source_url, cj.attempts, cj.max_attempts, cj.last_error, cj.updated_at, COALESCE(cm.duration_ms, pa.duration_ms) AS duration_ms FROM crawler_jobs cj LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) LEFT JOIN page_analysis pa ON pa.page_id = (cj.payload->>'page_id')::uuid LEFT JOIN LATERAL ( SELECT duration_ms FROM crawl_metrics WHERE op = 'chapter' AND chapter_id = (cj.payload->>'chapter_id')::uuid ORDER BY finished_at DESC LIMIT 1 ) cm ON true WHERE ($1::text IS NULL OR cj.state = $1) AND ($2::text IS NULL OR cj.payload->>'kind' = $2) AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3) ORDER BY cj.updated_at DESC LIMIT $4 OFFSET $5 "#, ) .bind(filter.state) .bind(filter.kind) .bind(&search_pat) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let total: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM crawler_jobs cj LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) WHERE ($1::text IS NULL OR cj.state = $1) AND ($2::text IS NULL OR cj.payload->>'kind' = $2) AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3) "#, ) .bind(filter.state) .bind(filter.kind) .bind(&search_pat) .fetch_one(pool) .await?; Ok((items, total)) } /// A manga whose cover is still missing (queued for cover fetch). #[derive(Debug, Clone, Serialize, FromRow)] pub struct MissingCoverRow { pub manga_id: Uuid, pub manga_title: String, } /// Count mangas with no cover yet but a live source row — the cover /// backlog the metadata pass + backfill drain. pub async fn count_missing_covers(pool: &PgPool) -> sqlx::Result { sqlx::query_scalar( r#" SELECT COUNT(*) FROM mangas m WHERE m.cover_image_path IS NULL AND EXISTS ( SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL ) "#, ) .fetch_one(pool) .await } /// Paginated list of mangas queued for a cover fetch (no cover yet + a live /// source), with titles. `search` filters on title. Freshest source first. pub async fn list_missing_cover_mangas( pool: &PgPool, search: Option<&str>, limit: i64, offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search .map(|s| format!("%{}%", s.trim())) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( r#" SELECT m.id AS manga_id, m.title AS manga_title FROM mangas m WHERE m.cover_image_path IS NULL AND EXISTS ( SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL ) AND ($1::text IS NULL OR m.title ILIKE $1) ORDER BY m.updated_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(&search_pat) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let total: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mangas m WHERE m.cover_image_path IS NULL AND EXISTS ( SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL ) AND ($1::text IS NULL OR m.title ILIKE $1) "#, ) .bind(&search_pat) .fetch_one(pool) .await?; Ok((items, total)) } /// Scope of a dead-job requeue. #[derive(Debug, Clone)] pub enum RequeueScope { /// Every dead job. All, /// Dead jobs whose chapter belongs to this manga. Manga(Uuid), /// Dead jobs for a single chapter. Chapter(Uuid), /// A single dead job by its id. Job(Uuid), } /// Requeue dead jobs back to `pending` with a fresh attempt budget. This is /// an explicit operator override, so it bypasses the dead-letter quarantine /// the enqueue helpers honour (we act directly on the row). Returns the /// number of rows requeued. /// /// Two invariants protect the partial unique dedup index /// `crawler_jobs_chapter_content_dedup_idx` (one `pending|running` /// sync_chapter_content job per chapter): /// 1. A chapter that already has a live (`pending|running`) job is /// skipped entirely (`NO_LIVE_DUP`). /// 2. When a chapter has *multiple* dead jobs, only the newest is /// revived (`DISTINCT ON` the chapter key) — without this, flipping /// two dead rows for the same chapter to `pending` in one statement /// would violate the index and abort the whole requeue. Non-chapter /// jobs fall back to their row id so each stays distinct. pub async fn requeue_dead_jobs(pool: &PgPool, scope: RequeueScope) -> sqlx::Result { // One full-shape SQL string per scope. Previously the scope // predicate was spliced via `format!()` from a `&'static str` // match — structurally safe today but fragile against a later // refactor accidentally interpolating a non-literal. Four fixed // queries cost a few duplicated lines but are immune to that // class of bug, and each can be reviewed independently. The CTE // body (DISTINCT ON dedup + NOT EXISTS guard) is identical // everywhere so the duplication is mechanical, not semantic. let q = match scope { RequeueScope::All => sqlx::query(REQUEUE_DEAD_SQL_ALL), RequeueScope::Manga(id) => sqlx::query(REQUEUE_DEAD_SQL_MANGA).bind(id), RequeueScope::Chapter(id) => sqlx::query(REQUEUE_DEAD_SQL_CHAPTER).bind(id), RequeueScope::Job(id) => sqlx::query(REQUEUE_DEAD_SQL_JOB).bind(id), }; Ok(q.execute(pool).await?.rows_affected()) } /// Common shell of the requeue CTE. Each scope variant inlines the /// shell and substitutes its own WHERE clause; the duplication is the /// price of avoiding runtime SQL string assembly. The dedup guarantees /// documented on [`requeue_dead_jobs`] live in the `DISTINCT ON` and /// the `NOT EXISTS` block — keep those identical across the four /// constants below when editing. const REQUEUE_DEAD_SQL_ALL: &str = r#" WITH pick AS ( SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id FROM crawler_jobs cj WHERE cj.state = 'dead' AND NOT EXISTS ( SELECT 1 FROM crawler_jobs live WHERE live.payload->>'kind' = 'sync_chapter_content' AND live.payload->>'chapter_id' = cj.payload->>'chapter_id' AND live.state IN ('pending','running') ) ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC ) UPDATE crawler_jobs SET state = 'pending', attempts = 0, leased_until = NULL, last_error = NULL, scheduled_at = now(), updated_at = now() FROM pick WHERE crawler_jobs.id = pick.id "#; const REQUEUE_DEAD_SQL_MANGA: &str = r#" WITH pick AS ( SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id FROM crawler_jobs cj WHERE cj.state = 'dead' AND (cj.payload->>'chapter_id')::uuid IN (SELECT id FROM chapters WHERE manga_id = $1) AND NOT EXISTS ( SELECT 1 FROM crawler_jobs live WHERE live.payload->>'kind' = 'sync_chapter_content' AND live.payload->>'chapter_id' = cj.payload->>'chapter_id' AND live.state IN ('pending','running') ) ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC ) UPDATE crawler_jobs SET state = 'pending', attempts = 0, leased_until = NULL, last_error = NULL, scheduled_at = now(), updated_at = now() FROM pick WHERE crawler_jobs.id = pick.id "#; const REQUEUE_DEAD_SQL_CHAPTER: &str = r#" WITH pick AS ( SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id FROM crawler_jobs cj WHERE cj.state = 'dead' AND (cj.payload->>'chapter_id')::uuid = $1 AND NOT EXISTS ( SELECT 1 FROM crawler_jobs live WHERE live.payload->>'kind' = 'sync_chapter_content' AND live.payload->>'chapter_id' = cj.payload->>'chapter_id' AND live.state IN ('pending','running') ) ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC ) UPDATE crawler_jobs SET state = 'pending', attempts = 0, leased_until = NULL, last_error = NULL, scheduled_at = now(), updated_at = now() FROM pick WHERE crawler_jobs.id = pick.id "#; const REQUEUE_DEAD_SQL_JOB: &str = r#" WITH pick AS ( SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id FROM crawler_jobs cj WHERE cj.state = 'dead' AND cj.id = $1 AND NOT EXISTS ( SELECT 1 FROM crawler_jobs live WHERE live.payload->>'kind' = 'sync_chapter_content' AND live.payload->>'chapter_id' = cj.payload->>'chapter_id' AND live.state IN ('pending','running') ) ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC ) UPDATE crawler_jobs SET state = 'pending', attempts = 0, leased_until = NULL, last_error = NULL, scheduled_at = now(), updated_at = now() FROM pick WHERE crawler_jobs.id = pick.id "#; /// `crawler_state` key under which the runtime session value (an admin /// pushed PHPSESSID) is persisted. Survives a backend restart so a /// mid-day refresh isn't lost. const STATE_KEY_RUNTIME_SESSION: &str = "runtime_session"; /// Read the persisted runtime PHPSESSID (if any). The payload shape is /// `{ "phpsessid": "" }`; anything else returns `None`. pub async fn runtime_session_load(pool: &PgPool) -> sqlx::Result> { let row: Option = sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = $1") .bind(STATE_KEY_RUNTIME_SESSION) .fetch_optional(pool) .await?; Ok(row.and_then(|v| { v.get("phpsessid") .and_then(|s| s.as_str()) .map(|s| s.to_string()) })) } /// Persist a fresh runtime PHPSESSID, replacing any previous value. pub async fn runtime_session_persist(pool: &PgPool, sid: &str) -> sqlx::Result<()> { sqlx::query( "INSERT INTO crawler_state (key, value, updated_at) \ VALUES ($1, $2, now()) \ ON CONFLICT (key) DO UPDATE \ SET value = EXCLUDED.value, updated_at = now()", ) .bind(STATE_KEY_RUNTIME_SESSION) .bind(serde_json::json!({ "phpsessid": sid })) .execute(pool) .await?; Ok(()) } /// Count crawler jobs grouped by state — drives the dashboard queue /// gauges. Returns `(pending, running, dead)`. pub async fn job_state_counts(pool: &PgPool) -> sqlx::Result<(i64, i64, i64)> { let rows: Vec<(String, i64)> = sqlx::query_as("SELECT state, COUNT(*) FROM crawler_jobs GROUP BY state") .fetch_all(pool) .await?; let mut pending = 0; let mut running = 0; let mut dead = 0; for (state, n) in rows { match state.as_str() { "pending" => pending = n, "running" => running = n, "dead" => dead = n, _ => {} } } Ok((pending, running, dead)) }