f358da215d
New module retention::sweep_user_retention is invoked at the top of handle_my_cases, using the current user's retention policy. Per case: step 1 auto-closes when the newest .m4a mtime is older than auto_close_days; step 2 auto-purges when closed_at is older than auto_delete_days. Order matters - a case auto-closed in the same sweep has closed_at ~= now and naturally survives the purge check, preserving the full grace period after a vacation. Race guard: purge re-reads the close marker immediately before remove_dir_all so a concurrent upload that reopens the case cancels the purge. Every automatic action emits an info! line with reason and threshold; failures warn but never abort the sweep. Seven integration tests cover both threshold paths, the disable-via-0 escape hatch, the newest-recording rule, the vacation scenario, and the open-case skip. Tests age the comparison objects (mtime via filetime, closed_at as handwritten RFC3339) instead of faking "now" - no clock abstraction needed.
195 lines
6.2 KiB
Rust
195 lines
6.2 KiB
Rust
//! Lazy retention sweep: runs at the top of `scan_user_cases` for the
|
|
//! signed-in user. Auto-closes stale cases and auto-purges old closed
|
|
//! cases — no background scheduler, no cron. The natural "someone
|
|
//! opened the case list" trigger is sufficient for doctate's single-
|
|
//! digit-user scale.
|
|
//!
|
|
//! Logging is deliberate per `feedback_log_autonomous_server_actions`:
|
|
//! every automatic action — and every race-guarded skip — emits an
|
|
//! `info!` line with the reason and the applied thresholds.
|
|
//!
|
|
//! Sweep order per case: auto-close first, auto-purge second. A case
|
|
//! that was just auto-closed has `closed_at ≈ now` and naturally
|
|
//! survives the purge check — giving the user the full `auto_delete_days`
|
|
//! grace period even after a long absence.
|
|
|
|
use std::path::Path;
|
|
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use tokio::fs;
|
|
use tracing::{info, warn};
|
|
|
|
use doctate_common::timestamp::now_rfc3339;
|
|
|
|
use crate::config::RetentionUserSettings;
|
|
use crate::events::{self, CaseEventKind, EventSender};
|
|
use crate::paths::{CloseMarker, is_closed, read_close_marker, write_close_marker};
|
|
|
|
/// Scan `user_root` once, applying auto-close and auto-purge under the
|
|
/// user's retention policy. Idempotent and best-effort: every I/O
|
|
/// failure is logged but never blocks the remaining cases or the
|
|
/// enclosing list view.
|
|
pub async fn sweep_user_retention(
|
|
user_root: &Path,
|
|
slug: &str,
|
|
settings: &RetentionUserSettings,
|
|
events_tx: &EventSender,
|
|
) {
|
|
if settings.auto_close_days == 0 && settings.auto_delete_days == 0 {
|
|
return;
|
|
}
|
|
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
let mut entries = match fs::read_dir(user_root).await {
|
|
Ok(r) => r,
|
|
// User dir may not exist yet (new user, no uploads). Nothing to sweep.
|
|
Err(_) => return,
|
|
};
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let case_path = entry.path();
|
|
if !case_path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
if settings.auto_close_days > 0 && !is_closed(&case_path).await {
|
|
try_auto_close(&case_path, slug, settings.auto_close_days, now, events_tx).await;
|
|
}
|
|
if settings.auto_delete_days > 0 && is_closed(&case_path).await {
|
|
try_auto_purge(&case_path, slug, settings.auto_delete_days, now, events_tx).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn try_auto_close(
|
|
case_path: &Path,
|
|
slug: &str,
|
|
threshold_days: u32,
|
|
now: OffsetDateTime,
|
|
events_tx: &EventSender,
|
|
) {
|
|
let Some(latest_m4a_mtime) = newest_m4a_mtime(case_path).await else {
|
|
// No .m4a yet → not a candidate.
|
|
return;
|
|
};
|
|
let age_days = (now - latest_m4a_mtime).whole_days();
|
|
if age_days <= threshold_days as i64 {
|
|
return;
|
|
}
|
|
|
|
let marker = CloseMarker {
|
|
closed_at: now_rfc3339(),
|
|
};
|
|
let case_id = events::case_id_of(case_path);
|
|
match write_close_marker(case_path, &marker).await {
|
|
Ok(_) => {
|
|
info!(
|
|
slug = %slug,
|
|
case_id = %case_id,
|
|
last_recording_age_days = age_days,
|
|
threshold_days = threshold_days,
|
|
"auto-closed case: last recording older than threshold"
|
|
);
|
|
events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed);
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
slug = %slug,
|
|
case_id = %case_id,
|
|
error = %e,
|
|
"auto-close failed to write marker"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn try_auto_purge(
|
|
case_path: &Path,
|
|
slug: &str,
|
|
threshold_days: u32,
|
|
now: OffsetDateTime,
|
|
events_tx: &EventSender,
|
|
) {
|
|
let Some(marker) = read_close_marker(case_path).await else {
|
|
// Malformed marker → do not purge. Admin can manually fix.
|
|
return;
|
|
};
|
|
let Ok(closed_at) = OffsetDateTime::parse(&marker.closed_at, &Rfc3339) else {
|
|
warn!(
|
|
slug = %slug,
|
|
case_dir = ?case_path,
|
|
closed_at = %marker.closed_at,
|
|
"auto-purge: cannot parse closed_at; skipping"
|
|
);
|
|
return;
|
|
};
|
|
let age_days = (now - closed_at).whole_days();
|
|
if age_days <= threshold_days as i64 {
|
|
return;
|
|
}
|
|
|
|
// Race-guard: re-read the marker immediately before `remove_dir_all`.
|
|
// If a concurrent upload has cleared the marker, the case has been
|
|
// reopened and must not be purged.
|
|
if read_close_marker(case_path).await.is_none() {
|
|
info!(
|
|
slug = %slug,
|
|
case_dir = ?case_path,
|
|
reason = "reopened during purge",
|
|
"auto-purge skipped"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let case_id = events::case_id_of(case_path);
|
|
match fs::remove_dir_all(case_path).await {
|
|
Ok(_) => {
|
|
info!(
|
|
slug = %slug,
|
|
case_id = %case_id,
|
|
closed_days_ago = age_days,
|
|
threshold_days = threshold_days,
|
|
"auto-purged case: closed marker older than threshold"
|
|
);
|
|
events::emit(events_tx, slug, case_id, CaseEventKind::CasePurged);
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
slug = %slug,
|
|
case_id = %case_id,
|
|
error = %e,
|
|
"auto-purge failed to remove dir"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Newest `.m4a` mtime in `case_dir`, as a UTC `OffsetDateTime`.
|
|
/// `None` when no `.m4a` is present. `.m4a.failed` counts too — a
|
|
/// recording that failed ffmpeg is still user activity.
|
|
async fn newest_m4a_mtime(case_dir: &Path) -> Option<OffsetDateTime> {
|
|
let mut entries = fs::read_dir(case_dir).await.ok()?;
|
|
let mut newest: Option<OffsetDateTime> = None;
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let name = entry.file_name();
|
|
let Some(s) = name.to_str() else { continue };
|
|
if !s.ends_with(".m4a") && !s.ends_with(".m4a.failed") {
|
|
continue;
|
|
}
|
|
let Ok(meta) = entry.metadata().await else {
|
|
continue;
|
|
};
|
|
let Ok(mtime) = meta.modified() else {
|
|
continue;
|
|
};
|
|
let ts = OffsetDateTime::from(mtime);
|
|
newest = Some(match newest {
|
|
Some(cur) if cur > ts => cur,
|
|
_ => ts,
|
|
});
|
|
}
|
|
newest
|
|
}
|