From 9148e23da88ce7a089cf4cd273ffbb2f1e64cc9d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 20:19:41 +0200 Subject: [PATCH] fix: reap expired sessions with a periodic background sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessions table only grew — find_active ignored expired rows but nothing deleted them, so lapsed sessions accumulated indefinitely. - repo::session::delete_expired: indexed DELETE (sessions_expires_idx) returning the reaped count. - app::build spawns a detached hourly reaper (SESSION_GC_INTERVAL) that calls it, independent of crawler/analysis config. Test: delete_expired_removes_only_lapsed_sessions. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/app.rs | 26 ++++++++++++++++++ backend/src/repo/session.rs | 12 ++++++++ backend/tests/repo_session.rs | 52 +++++++++++++++++++++++++++++++++++ frontend/package.json | 2 +- 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 backend/tests/repo_session.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 54b61a9..2e14d4d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.9" +version = "0.128.10" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d031638..948b0bb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.9" +version = "0.128.10" edition = "2021" default-run = "mangalord" diff --git a/backend/src/app.rs b/backend/src/app.rs index 574176c..6530e33 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -277,6 +277,11 @@ impl DaemonReloader for Supervisors { } } +/// How often the background reaper sweeps expired sessions. Hourly is ample: +/// the sweep is a single indexed DELETE and expired rows are already invisible +/// to auth, so this is purely storage hygiene. +const SESSION_GC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600); + pub async fn build(config: Config) -> anyhow::Result { let db = PgPoolOptions::new() .max_connections(config.db.max_connections) @@ -337,6 +342,27 @@ pub async fn build(config: Config) -> anyhow::Result { tracing::info!("analysis worker disabled"); } + // Periodic reaper for lapsed sessions. `find_active` already ignores + // expired rows, so this only reclaims storage — without it the table grows + // unbounded as sessions lapse. Detached and best-effort: a failed sweep is + // logged and retried next tick. Runs regardless of crawler/analysis config + // since sessions exist in every deployment. + { + let db = db.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(SESSION_GC_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticker.tick().await; + match repo::session::delete_expired(&db).await { + Ok(0) => {} + Ok(n) => tracing::info!(reaped = n, "session gc: removed expired sessions"), + Err(e) => tracing::warn!(?e, "session gc sweep failed; retrying next tick"), + } + } + }); + } + let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit)); let state = AppState { db, diff --git a/backend/src/repo/session.rs b/backend/src/repo/session.rs index 9555cec..7ac15a6 100644 --- a/backend/src/repo/session.rs +++ b/backend/src/repo/session.rs @@ -65,3 +65,15 @@ pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> AppResult .await?; Ok(()) } + +/// Delete every session whose `expires_at` has passed, returning the number +/// reaped. `find_active` already refuses expired sessions, so this only +/// reclaims storage — running it on any schedule (or not at all) is safe. +/// Backed by `sessions_expires_idx` (0002). Called by the periodic reaper in +/// `app::build`. +pub async fn delete_expired(pool: &PgPool) -> AppResult { + let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()") + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/backend/tests/repo_session.rs b/backend/tests/repo_session.rs new file mode 100644 index 0000000..3448a70 --- /dev/null +++ b/backend/tests/repo_session.rs @@ -0,0 +1,52 @@ +mod common; + +use chrono::{Duration, Utc}; +use sqlx::PgPool; + +use mangalord::repo; + +#[sqlx::test(migrations = "./migrations")] +async fn delete_expired_removes_only_lapsed_sessions(pool: PgPool) { + let user = repo::user::create(&pool, "reaper-subject", "x").await.unwrap(); + + // One session already lapsed, one still valid. + let expired = repo::session::create( + &pool, + user.id, + b"expired-token-hash-00000000000000", + Utc::now() - Duration::hours(1), + ) + .await + .unwrap(); + let active = repo::session::create( + &pool, + user.id, + b"active-token-hash-000000000000000", + Utc::now() + Duration::hours(1), + ) + .await + .unwrap(); + + let removed = repo::session::delete_expired(&pool).await.unwrap(); + assert_eq!(removed, 1, "exactly the one lapsed session is reaped"); + + // The active session is untouched and still resolvable; the expired one is + // gone from the table entirely (find_active already ignored it, but now the + // row is reclaimed too). + assert!( + repo::session::find_active(&pool, b"active-token-hash-000000000000000") + .await + .unwrap() + .is_some(), + "active session survives the sweep" + ); + let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM sessions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 1); + + // Sweeping again is a no-op now that nothing is expired. + assert_eq!(repo::session::delete_expired(&pool).await.unwrap(), 0); + let _ = (expired, active); +} diff --git a/frontend/package.json b/frontend/package.json index daa3bcc..8049c19 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.9", + "version": "0.128.10", "private": true, "type": "module", "scripts": {