fix: reap expired sessions with a periodic background sweep
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 <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.128.9"
|
version = "0.128.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.9"
|
version = "0.128.10"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -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<AppHandle> {
|
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
||||||
let db = PgPoolOptions::new()
|
let db = PgPoolOptions::new()
|
||||||
.max_connections(config.db.max_connections)
|
.max_connections(config.db.max_connections)
|
||||||
@@ -337,6 +342,27 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
|||||||
tracing::info!("analysis worker disabled");
|
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 auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -65,3 +65,15 @@ pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> AppResult
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|||||||
52
backend/tests/repo_session.rs
Normal file
52
backend/tests/repo_session.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.9",
|
"version": "0.128.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user