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:
@@ -194,6 +194,41 @@ impl CaseStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop markers whose last activity is older than `cutoff` **and**
|
||||
/// that the server has already confirmed (`synced_to_server = true`).
|
||||
/// Pending/unsynced markers are preserved — they guard uploads that
|
||||
/// have not yet reached the server, and their loss would silently
|
||||
/// erase patient recordings from the client's awareness.
|
||||
///
|
||||
/// Returns the number of markers removed. Intended as an
|
||||
/// app-startup pass so the data-minimization rule holds: metadata
|
||||
/// ages out after a few days even without an explicit UI action.
|
||||
pub async fn cleanup_stale(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<usize, CaseStoreError> {
|
||||
let cutoff_str = format_utc(cutoff)?;
|
||||
let mut state = self.state.lock().await;
|
||||
let to_remove: Vec<Uuid> = state
|
||||
.iter()
|
||||
.filter(|(_, m)| m.synced_to_server && m.last_activity_at < cutoff_str)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
let count = to_remove.len();
|
||||
for id in &to_remove {
|
||||
let path = self.dir.join(format!("{id}.json"));
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(_) => tracing::info!(case_id = %id, path = %path.display(), "stale marker removed"),
|
||||
Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"),
|
||||
}
|
||||
state.remove(id);
|
||||
}
|
||||
if count > 0 {
|
||||
self.broadcast(&state);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Remove every marker from memory and disk. Used on config change
|
||||
/// (new API key = potentially different user).
|
||||
pub async fn clear_all(&self) -> Result<(), CaseStoreError> {
|
||||
@@ -546,6 +581,72 @@ mod tests {
|
||||
assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_stale_removes_old_synced_markers() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let old_synced = Uuid::new_v4();
|
||||
let young_synced = Uuid::new_v4();
|
||||
let old_unsynced = Uuid::new_v4();
|
||||
|
||||
// old_synced: created 4 days ago, then merged in → synced=true, old
|
||||
store.create_local(old_synced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||
case_id: old_synced.to_string(),
|
||||
oneliner: Some("old".into()),
|
||||
created_at: "2026-04-14T10:00:00Z".into(),
|
||||
last_recording_at: Some("2026-04-14T10:00:00Z".into()),
|
||||
updated_at: Some("2026-04-14T10:00:00Z".into()),
|
||||
}]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// young_synced: created today, synced
|
||||
store.create_local(young_synced, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||
case_id: young_synced.to_string(),
|
||||
oneliner: Some("young".into()),
|
||||
created_at: "2026-04-18T10:00:00Z".into(),
|
||||
last_recording_at: Some("2026-04-18T10:00:00Z".into()),
|
||||
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
||||
}]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// old_unsynced: created 4 days ago, NEVER merged in → pending upload
|
||||
store.create_local(old_unsynced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
||||
|
||||
// Cutoff: 72h before the "today" markers, i.e. 2026-04-15.
|
||||
let cutoff = t("2026-04-15T10:00:00Z");
|
||||
let removed = store.cleanup_stale(cutoff).await.unwrap();
|
||||
|
||||
assert_eq!(removed, 1, "only old+synced must be removed");
|
||||
let list = store.list().await;
|
||||
let ids: Vec<Uuid> = list.iter().map(|m| m.case_id).collect();
|
||||
assert!(ids.contains(&young_synced), "young synced must stay");
|
||||
assert!(
|
||||
ids.contains(&old_unsynced),
|
||||
"old unsynced must stay (pending upload)"
|
||||
);
|
||||
assert!(!ids.contains(&old_synced), "old synced must be gone");
|
||||
|
||||
// Disk: old_synced's file deleted.
|
||||
let old_path = tmp.path().join(format!("{old_synced}.json"));
|
||||
assert!(!old_path.exists(), "marker file for old_synced must be deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_stale_is_noop_when_nothing_old() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
let removed = store.cleanup_stale(t("2026-01-01T00:00:00Z")).await.unwrap();
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(store.list().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sorted_newest_first() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user