0c66fc6010
Move three directories into their target topology: - doctate-common/ -> common/ - client-desktop/ -> clients/desktop/ - watch/wearos/ -> clients/wearos/ Update doctate-common path-dependency in 3 Cargo.toml files (server, clients/desktop, experiments) and the workspace members list in the root Cargo.toml. Crate names unchanged (doctate-server, doctate-common, doctate-desktop). Workspace still in single-Cargo.lock form; isolation into standalone workspaces follows in stage 3. All 508 tests pass, experiments standalone-workspace also resolves the new path.
220 lines
8.6 KiB
Rust
220 lines
8.6 KiB
Rust
//! Orphan reaping for the client's pending-upload directory.
|
|
//!
|
|
//! The uploader happy-path deletes `.m4a` + `.meta.json` pairs after
|
|
//! ACK. A crash between recorder-stop and sidecar-write, or a crash
|
|
//! between ACK and the two `remove_file` calls, can leave **orphans**
|
|
//! behind:
|
|
//!
|
|
//! - `*.m4a` without `*.meta.json` (or vice versa): the uploader's
|
|
//! `scan_pending_dir` silently skips these on next launch, so they
|
|
//! would accumulate indefinitely without explicit cleanup.
|
|
//! - `*.json.tmp` / `*.m4a.tmp` leftovers from interrupted atomic
|
|
//! writes: pure garbage, always safe to remove.
|
|
//!
|
|
//! Orphan audio is sensitive — clients must not hoard patient
|
|
//! recordings that never made it to the server. This module reaps them
|
|
//! on a caller-supplied age threshold. The caller passes an `age_cutoff:
|
|
//! SystemTime`; everything whose mtime is strictly older is deleted.
|
|
//! `.tmp` leftovers bypass the cutoff (they are never legitimate work
|
|
//! in progress at app startup — the lock file guarantees exclusivity).
|
|
|
|
use std::path::Path;
|
|
use std::time::SystemTime;
|
|
|
|
use tracing::{info, warn};
|
|
|
|
/// Reap orphan artefacts in `pending_dir`. Returns the number of files
|
|
/// deleted. Missing directory is treated as "nothing to do".
|
|
pub async fn cleanup_orphan_audio(pending_dir: &Path, age_cutoff: SystemTime) -> usize {
|
|
let Ok(mut entries) = tokio::fs::read_dir(pending_dir).await else {
|
|
return 0;
|
|
};
|
|
|
|
// First pass: collect filenames so we can answer "does the sibling
|
|
// exist?" without doing N separate stat calls.
|
|
let mut names: Vec<String> = Vec::new();
|
|
while let Ok(Some(e)) = entries.next_entry().await {
|
|
if let Some(n) = e.file_name().to_str() {
|
|
names.push(n.to_owned());
|
|
}
|
|
}
|
|
let has_name = |n: &str| names.iter().any(|x| x == n);
|
|
|
|
let mut deleted = 0usize;
|
|
for name in &names {
|
|
let path = pending_dir.join(name);
|
|
|
|
// Atomic-write leftovers: always delete, no age check. A running
|
|
// process would hold the single-instance lock, so `.tmp` at
|
|
// startup is by definition stale.
|
|
if name.ends_with(".m4a.tmp") || name.ends_with(".meta.json.tmp") {
|
|
if let Err(e) = tokio::fs::remove_file(&path).await {
|
|
warn!(path = %path.display(), error = %e, "failed to remove tmp leftover");
|
|
} else {
|
|
info!(path = %path.display(), "tmp leftover removed");
|
|
deleted += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Candidate: orphan `.m4a` — only delete if older than cutoff.
|
|
if let Some(stem) = name.strip_suffix(".m4a") {
|
|
let sibling = format!("{stem}.meta.json");
|
|
if has_name(&sibling) {
|
|
continue; // paired, leave for the uploader
|
|
}
|
|
if mtime_older_than(&path, age_cutoff).await {
|
|
if let Err(e) = tokio::fs::remove_file(&path).await {
|
|
warn!(path = %path.display(), error = %e, "failed to remove orphan m4a");
|
|
} else {
|
|
info!(path = %path.display(), "orphan m4a removed (no sidecar)");
|
|
deleted += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Candidate: orphan sidecar — only delete if older than cutoff.
|
|
if let Some(stem) = name.strip_suffix(".meta.json") {
|
|
let sibling = format!("{stem}.m4a");
|
|
if has_name(&sibling) {
|
|
continue;
|
|
}
|
|
if mtime_older_than(&path, age_cutoff).await {
|
|
if let Err(e) = tokio::fs::remove_file(&path).await {
|
|
warn!(path = %path.display(), error = %e, "failed to remove orphan sidecar");
|
|
} else {
|
|
info!(path = %path.display(), "orphan sidecar removed (no m4a)");
|
|
deleted += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
deleted
|
|
}
|
|
|
|
async fn mtime_older_than(path: &Path, cutoff: SystemTime) -> bool {
|
|
match tokio::fs::metadata(path).await {
|
|
Ok(meta) => match meta.modified() {
|
|
Ok(mtime) => mtime < cutoff,
|
|
Err(_) => false,
|
|
},
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
async fn touch_with_mtime(path: &Path, mtime: SystemTime) {
|
|
tokio::fs::write(path, b"x").await.unwrap();
|
|
filetime::set_file_mtime(path, filetime::FileTime::from_system_time(mtime)).unwrap();
|
|
}
|
|
|
|
fn now() -> SystemTime {
|
|
SystemTime::now()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_directory_returns_zero() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let n = cleanup_orphan_audio(&tmp.path().join("nope"), now()).await;
|
|
assert_eq!(n, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paired_files_are_preserved() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let stem = "uuid_2026-04-18T08-00-00Z";
|
|
touch_with_mtime(&tmp.path().join(format!("{stem}.m4a")), now()).await;
|
|
touch_with_mtime(&tmp.path().join(format!("{stem}.meta.json")), now()).await;
|
|
// Cutoff in the future → any orphan would be deleted — but
|
|
// these are paired, must survive.
|
|
let cutoff = now() + Duration::from_secs(3600);
|
|
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
|
assert_eq!(n, 0);
|
|
assert!(tmp.path().join(format!("{stem}.m4a")).exists());
|
|
assert!(tmp.path().join(format!("{stem}.meta.json")).exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn young_orphan_m4a_is_preserved() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let m4a = tmp.path().join("uuid_young.m4a");
|
|
touch_with_mtime(&m4a, now()).await;
|
|
// cutoff = 1h ago → file (mtime=now) is younger → keep
|
|
let cutoff = now() - Duration::from_secs(3600);
|
|
assert_eq!(cleanup_orphan_audio(tmp.path(), cutoff).await, 0);
|
|
assert!(m4a.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn old_orphan_m4a_is_deleted() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let m4a = tmp.path().join("uuid_old.m4a");
|
|
touch_with_mtime(&m4a, now() - Duration::from_secs(48 * 3600)).await;
|
|
let cutoff = now() - Duration::from_secs(24 * 3600);
|
|
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
|
assert_eq!(n, 1);
|
|
assert!(!m4a.exists(), "old orphan m4a must be gone");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn old_orphan_sidecar_is_deleted() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let sc = tmp.path().join("uuid_orphan.meta.json");
|
|
touch_with_mtime(&sc, now() - Duration::from_secs(48 * 3600)).await;
|
|
let cutoff = now() - Duration::from_secs(24 * 3600);
|
|
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
|
assert_eq!(n, 1);
|
|
assert!(!sc.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tmp_leftovers_always_removed_regardless_of_age() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let young_tmp = tmp.path().join("fresh.meta.json.tmp");
|
|
let m4a_tmp = tmp.path().join("interrupted.m4a.tmp");
|
|
touch_with_mtime(&young_tmp, now()).await;
|
|
touch_with_mtime(&m4a_tmp, now()).await;
|
|
// Cutoff in the far past → age check would preserve everything.
|
|
// But tmp leftovers bypass age check.
|
|
let n = cleanup_orphan_audio(tmp.path(), now() - Duration::from_secs(10 * 86400)).await;
|
|
assert_eq!(n, 2);
|
|
assert!(!young_tmp.exists());
|
|
assert!(!m4a_tmp.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mix_of_pairs_orphans_and_tmp() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let keep_stem = "uuid_keep_2026-04-18";
|
|
let orphan_stem = "uuid_orphan_old";
|
|
|
|
// Paired (keep)
|
|
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.m4a")), now()).await;
|
|
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.meta.json")), now()).await;
|
|
// Old orphan m4a (delete)
|
|
touch_with_mtime(
|
|
&tmp.path().join(format!("{orphan_stem}.m4a")),
|
|
now() - Duration::from_secs(48 * 3600),
|
|
)
|
|
.await;
|
|
// tmp leftover (delete)
|
|
touch_with_mtime(&tmp.path().join("crash.m4a.tmp"), now()).await;
|
|
|
|
let cutoff = now() - Duration::from_secs(24 * 3600);
|
|
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
|
assert_eq!(n, 2, "orphan + tmp must be removed, pair must stay");
|
|
assert!(tmp.path().join(format!("{keep_stem}.m4a")).exists());
|
|
assert!(tmp.path().join(format!("{keep_stem}.meta.json")).exists());
|
|
assert!(!tmp.path().join(format!("{orphan_stem}.m4a")).exists());
|
|
assert!(!tmp.path().join("crash.m4a.tmp").exists());
|
|
}
|
|
}
|