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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user