Files
PiCloud/crates/manager-core/src/gc.rs
MechaCat02 5eb596611c chore(v1.1.8): GC sweep for app-user sessions + tokens
Extend the existing weekly retention sweeper with a new
spawn_app_user_token_gc function that prunes four tables in one
tick:

  * app_user_sessions — expired (sliding window or absolute cap) or
    explicitly revoked
  * app_user_email_verifications — consumed or expired
  * app_user_password_resets — consumed or expired
  * app_user_invitations — accepted or expired

Each underlying repo's gc(batch_size) uses FOR UPDATE SKIP LOCKED so
concurrent sweepers don't fight (cluster mode v1.3+ ready). No
configurable retention — rows die when their TTL or terminal state
hits, not on an arbitrary clock.

Wired into picloud's build_app alongside spawn_dead_letter_gc and
spawn_abandoned_gc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:02:21 +02:00

213 lines
6.6 KiB
Rust

//! Weekly retention sweepers for `dead_letters` + `abandoned_executions`.
//!
//! Both use the `FOR UPDATE SKIP LOCKED` claim pattern so concurrent
//! sweepers (cluster mode v1.3+) don't fight each other. Defaults
//! match design notes §3 / §4: 30 days for DL, 7 days for abandoned.
//! Both env-overridable via `PICLOUD_DEAD_LETTER_RETENTION_DAYS` and
//! `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS` (loaded by
//! `TriggerConfig::from_env`).
//!
//! Spawned from `build_app` alongside `spawn_session_pruner`.
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use crate::abandoned_repo::AbandonedRepo;
use crate::app_user_invitation_repo::AppUserInvitationRepo;
use crate::app_user_password_reset_repo::AppUserPasswordResetRepo;
use crate::app_user_session_repo::AppUserSessionRepository;
use crate::app_user_verification_repo::AppUserVerificationRepo;
use crate::dead_letter_repo::DeadLetterRepo;
/// Weekly sweep cadence — matches `spawn_session_pruner` shape.
const SWEEP_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// Per-tick batch cap so we don't try to delete millions of rows in
/// one transaction. The loop keeps deleting batches until a tick
/// returns 0 rows affected.
const SWEEP_BATCH: i64 = 5_000;
pub fn spawn_dead_letter_gc(repo: Arc<dyn DeadLetterRepo>, retention_days: u32) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
// Skip the immediate first fire — don't sweep at process start.
ticker.tick().await;
loop {
ticker.tick().await;
sweep_dead_letters(&*repo, retention_days).await;
}
});
}
pub fn spawn_abandoned_gc(repo: Arc<dyn AbandonedRepo>, retention_days: u32) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
sweep_abandoned(&*repo, retention_days).await;
}
});
}
async fn sweep_dead_letters(repo: &dyn DeadLetterRepo, retention_days: u32) {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
let mut total: u64 = 0;
loop {
match repo.gc(cutoff, SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "dead_letters GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "dead_letters GC swept");
}
}
async fn sweep_abandoned(repo: &dyn AbandonedRepo, retention_days: u32) {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
let mut total: u64 = 0;
loop {
match repo.gc(cutoff, SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "abandoned_executions GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "abandoned_executions GC swept");
}
}
/// v1.1.8: weekly sweep that prunes every app-user token table —
/// expired or revoked sessions, consumed or expired verifications,
/// password resets, and accepted or expired invitations. Each
/// underlying repo's `gc(batch_size)` uses FOR UPDATE SKIP LOCKED so
/// concurrent sweepers don't fight, matching the dead-letter +
/// abandoned-executions pattern.
pub fn spawn_app_user_token_gc(
sessions: Arc<dyn AppUserSessionRepository>,
verifications: Arc<dyn AppUserVerificationRepo>,
password_resets: Arc<dyn AppUserPasswordResetRepo>,
invitations: Arc<dyn AppUserInvitationRepo>,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
sweep_app_user_sessions(&*sessions).await;
sweep_app_user_verifications(&*verifications).await;
sweep_app_user_password_resets(&*password_resets).await;
sweep_app_user_invitations(&*invitations).await;
}
});
}
async fn sweep_app_user_sessions(repo: &dyn AppUserSessionRepository) {
let mut total: u64 = 0;
loop {
match repo.gc(SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "app_user_sessions GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "app_user_sessions GC swept");
}
}
async fn sweep_app_user_verifications(repo: &dyn AppUserVerificationRepo) {
let mut total: u64 = 0;
loop {
match repo.gc(SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "app_user_email_verifications GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "app_user_email_verifications GC swept");
}
}
async fn sweep_app_user_password_resets(repo: &dyn AppUserPasswordResetRepo) {
let mut total: u64 = 0;
loop {
match repo.gc(SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "app_user_password_resets GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "app_user_password_resets GC swept");
}
}
async fn sweep_app_user_invitations(repo: &dyn AppUserInvitationRepo) {
let mut total: u64 = 0;
loop {
match repo.gc(SWEEP_BATCH).await {
Ok(0) => break,
Ok(n) => {
total += n;
if n < SWEEP_BATCH as u64 {
break;
}
}
Err(e) => {
tracing::warn!(?e, "app_user_invitations GC sweep errored");
break;
}
}
}
if total > 0 {
tracing::info!(swept = total, "app_user_invitations GC swept");
}
}