feat: Add startup cleanup for stale markers and orphans

This commit introduces a new startup cleanup routine for the Doctate
client.
The cleanup process reaps stale markers and orphaned audio files to
adhere to data minimization principles and maintain client efficiency.

Specifically, it performs the following actions:

- Removes markers that are older than 72 hours and have been confirmed
  by
  the server. This ensures that old, irrelevant metadata is purged.
- Cleans up orphaned audio files (e.g., `.m4a` without corresponding
  `.meta.json` or vice-versa) that are older than 24 hours. This
  prevents the accumulation of audio data that will never be uploaded,
  as such files are silently ignored by the uploader.
- Removes any temporary files (`.tmp`) left over from interrupted
  writes,
  as these are considered invalid at startup.

The `filetime` crate is added as a dependency to facilitate setting
modification times for testing purposes.
This commit is contained in:
2026-04-18 12:22:36 +02:00
parent bb29671733
commit cab71e6a26
7 changed files with 374 additions and 3 deletions
+27 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use doctate_client_core::{
CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache,
cleanup_orphan_audio, CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache,
};
use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
@@ -86,6 +86,32 @@ impl DoctateApp {
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
let http_client = Arc::new(reqwest::Client::new());
// Startup cleanup — runs once, before the poller and uploader
// spawn. Keeps the client a lean ephemeral microphone:
// - markers older than 72 h (and server-confirmed) drop out,
// - orphan audio older than 24 h is reaped (patient-data
// minimization; a file that never found its sidecar will
// never be uploaded).
{
let store = case_store.clone();
let pending = pending_dir.clone();
runtime.block_on(async move {
let marker_cutoff =
time::OffsetDateTime::now_utc() - time::Duration::hours(72);
match store.cleanup_stale(marker_cutoff).await {
Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"),
Ok(_) => {}
Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"),
}
let audio_cutoff = std::time::SystemTime::now()
- std::time::Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(&pending, audio_cutoff).await;
if n > 0 {
info!(count = n, "startup cleanup: orphan audio removed");
}
});
}
let (state, uploader, upload_events, poller) = if let Some(cfg) = &config {
let (up, rx, pol) = spawn_background(
&runtime,
+23 -2
View File
@@ -194,6 +194,14 @@ async fn upload_with_retry(
return;
}
Err(ErrorKind::Terminal(reason)) => {
// Terminal = configuration or operator error (wrong API
// key, payload too big, etc.). No amount of retries will
// help, and keeping sensitive audio on the client "just
// in case" would violate the data-minimization rule
// (clients are ephemeral microphones, not archives).
// Delete both artefacts and surface the failure to the UI.
let _ = tokio::fs::remove_file(&upload.file).await;
let _ = tokio::fs::remove_file(&sidecar_path_for(&upload.file)).await;
let _ = events_tx.send(UploadEvent::Failed {
case_id: upload.case_id,
reason,
@@ -411,8 +419,14 @@ mod tests {
assert!(matches!(rx.recv().await, Some(UploadEvent::Succeeded { .. })));
}
/// Terminal failures (401, 413, ...) indicate a config or operator
/// problem that retries cannot fix. The sensitive audio file must
/// be deleted immediately — keeping it locally "in case the user
/// fixes the key" would leak patient data indefinitely for a
/// situation that in practice is rarely recoverable without manual
/// intervention anyway.
#[tokio::test]
async fn terminal_401_keeps_files_and_does_not_retry() {
async fn terminal_401_deletes_files_and_does_not_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wpath(UPLOAD_PATH))
@@ -434,7 +448,14 @@ mod tests {
let (tx, mut rx) = mpsc::unbounded_channel();
upload_with_retry(&client, &config, upload.clone(), &tx).await;
assert!(upload.file.exists(), "m4a must be preserved on terminal failure");
assert!(
!upload.file.exists(),
"m4a must be deleted on terminal failure"
);
assert!(
!sidecar_path_for(&upload.file).exists(),
"sidecar must be deleted on terminal failure"
);
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
let Some(UploadEvent::Failed { will_retry, .. }) = rx.recv().await else {