Refactor upload and polling logic
This commit consolidates the upload and polling functionalities into a single `ServerSync` worker. The `oneliner_poller` and `uploader` modules have been removed, and their responsibilities are now handled by `server_sync`. The `ServerSync` worker manages both uploading pending recordings and polling the `/api/oneliners` endpoint. Uploads take precedence, and polling only occurs when the pending upload directory is empty. The worker now uses a unified HTTP client and a single `tokio::select!` loop for managing these tasks. Key changes include: - Removal of `oneliner_poller.rs` and `uploader.rs`. - Introduction of `server_sync.rs` and `upload.rs` in `doctate-client-core`. - `ServerSync` now handles upload events (`UploadEvent`) and provides a `WorkerSnapshot` for UI feedback. - Upload logic has been refactored to handle transient and terminal errors more robustly, including file deletion for terminal failures. - Polling logic is integrated into the `ServerSync` loop, utilizing the existing `SnapshotCache` and `CaseStore`. - The `App` component in `client-desktop` has been updated to use `ServerSync` instead of the separate poller and uploader. - Documentation comments have been updated to reflect the new structure.
This commit is contained in:
+166
-143
@@ -1,12 +1,21 @@
|
||||
//! The egui application: config panel, record/stop controls, case list,
|
||||
//! state transitions driven by recorder + uploader + poller channels.
|
||||
//! The egui application: config panel, record/stop controls, case list.
|
||||
//!
|
||||
//! State transitions are driven by:
|
||||
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
|
||||
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
|
||||
//! - upload events (terminal failure → AppState::Error)
|
||||
//!
|
||||
//! Connectivity and upload-progress indicators live in the footer; they
|
||||
//! never block the main state machine. See
|
||||
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_client_core::{
|
||||
cleanup_orphan_audio, CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache,
|
||||
cleanup_orphan_audio, write_sidecar, CaseMarker, CaseStore, PendingUpload, ServerSync,
|
||||
SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
|
||||
};
|
||||
use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem};
|
||||
use eframe::egui;
|
||||
@@ -17,8 +26,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::recorder::{Recorder, RecorderEvent};
|
||||
use crate::state::AppState;
|
||||
use crate::uploader::{write_sidecar, PendingUpload, UploadEvent, Uploader};
|
||||
use crate::state::{pick_footer_status, AppState, FooterStatus};
|
||||
|
||||
pub struct DoctateApp {
|
||||
runtime: Arc<Runtime>,
|
||||
@@ -37,10 +45,15 @@ pub struct DoctateApp {
|
||||
recorder: Option<Recorder>,
|
||||
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
|
||||
|
||||
uploader: Option<Uploader>,
|
||||
server_sync: Option<ServerSync>,
|
||||
worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
|
||||
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
|
||||
|
||||
poller: Option<OnelinerPoller>,
|
||||
/// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
|
||||
/// for `AppState::Idle` on Stop (so the UI stays responsive), but we
|
||||
/// still need `case_id` + `recorded_at` when the recorder emits
|
||||
/// `Finished`. `None` when no flush is in progress.
|
||||
finalizing: Option<(Uuid, String)>,
|
||||
}
|
||||
|
||||
enum UiAction {
|
||||
@@ -77,8 +90,6 @@ impl DoctateApp {
|
||||
.map(|c| c.api_key.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Open the case store before any background task — the store
|
||||
// owns the watch::Sender that drives the UI list.
|
||||
let (store, snapshot_rx) = runtime
|
||||
.block_on(async { CaseStore::open(cases_dir).await })
|
||||
.expect("open case store");
|
||||
@@ -86,25 +97,19 @@ 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).
|
||||
// Startup cleanup — one-shot, before the worker spawns.
|
||||
{
|
||||
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);
|
||||
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 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");
|
||||
@@ -112,8 +117,8 @@ impl DoctateApp {
|
||||
});
|
||||
}
|
||||
|
||||
let (state, uploader, upload_events, poller) = if let Some(cfg) = &config {
|
||||
let (up, rx, pol) = spawn_background(
|
||||
let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
|
||||
let (sync, events, rx) = spawn_worker(
|
||||
&runtime,
|
||||
cfg.clone(),
|
||||
pending_dir.clone(),
|
||||
@@ -121,7 +126,7 @@ impl DoctateApp {
|
||||
case_store.clone(),
|
||||
snapshot_cache.clone(),
|
||||
);
|
||||
(AppState::Idle, Some(up), Some(rx), Some(pol))
|
||||
(AppState::Idle, Some(sync), Some(rx), Some(events))
|
||||
} else {
|
||||
(AppState::NotConfigured, None, None, None)
|
||||
};
|
||||
@@ -139,9 +144,10 @@ impl DoctateApp {
|
||||
snapshot_cache,
|
||||
recorder: None,
|
||||
recorder_events: None,
|
||||
uploader,
|
||||
server_sync,
|
||||
worker_rx,
|
||||
upload_events,
|
||||
poller,
|
||||
finalizing: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +173,6 @@ impl DoctateApp {
|
||||
}
|
||||
info!("config saved");
|
||||
|
||||
// New API key = potentially different user. Wipe local state so
|
||||
// we don't leak old user's cases into the new user's view.
|
||||
let store = self.case_store.clone();
|
||||
let cache = self.snapshot_cache.clone();
|
||||
self.runtime.block_on(async move {
|
||||
@@ -180,13 +184,12 @@ impl DoctateApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Drop old background tasks explicitly so their AbortHandle /
|
||||
// channel-close fires before the new ones spawn.
|
||||
self.poller = None;
|
||||
self.uploader = None;
|
||||
// Drop old worker explicitly so its abort fires before new spawn.
|
||||
self.server_sync = None;
|
||||
self.worker_rx = None;
|
||||
self.upload_events = None;
|
||||
|
||||
let (up, rx, pol) = spawn_background(
|
||||
let (sync, events, rx) = spawn_worker(
|
||||
&self.runtime,
|
||||
cfg.clone(),
|
||||
self.pending_dir.clone(),
|
||||
@@ -194,11 +197,12 @@ impl DoctateApp {
|
||||
self.case_store.clone(),
|
||||
self.snapshot_cache.clone(),
|
||||
);
|
||||
self.uploader = Some(up);
|
||||
self.upload_events = Some(rx);
|
||||
self.poller = Some(pol);
|
||||
self.server_sync = Some(sync);
|
||||
self.worker_rx = Some(rx);
|
||||
self.upload_events = Some(events);
|
||||
self.config = Some(cfg);
|
||||
self.state = AppState::Idle;
|
||||
self.finalizing = None;
|
||||
}
|
||||
|
||||
fn on_start_new(&mut self) {
|
||||
@@ -210,7 +214,9 @@ impl DoctateApp {
|
||||
}
|
||||
|
||||
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
|
||||
if !matches!(self.state, AppState::Idle) {
|
||||
// Two-part guard: user-visible state AND no lingering recorder
|
||||
// from a previous stop that is still flushing.
|
||||
if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
|
||||
@@ -225,8 +231,7 @@ impl DoctateApp {
|
||||
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
|
||||
|
||||
// Persist the case marker before recording so the UI list
|
||||
// reflects the new case immediately (even if the recorder fails
|
||||
// to start, the marker stays — harmless and reproducible).
|
||||
// reflects the new case immediately.
|
||||
let store = self.case_store.clone();
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
self.runtime.block_on(async move {
|
||||
@@ -273,10 +278,10 @@ impl DoctateApp {
|
||||
if let Some(rec) = self.recorder.take() {
|
||||
rec.stop();
|
||||
}
|
||||
self.state = AppState::FinalizingRecording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
};
|
||||
// UI is ready for the next recording immediately — the finalizing
|
||||
// ffmpeg flush shows up as a footer spinner, not a blocking state.
|
||||
self.finalizing = Some((case_id, recorded_at));
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
|
||||
fn on_open_web(&self, case_id: Uuid) {
|
||||
@@ -309,6 +314,7 @@ impl DoctateApp {
|
||||
}
|
||||
RecorderEvent::Failed { error: err } => {
|
||||
error!(error = %err, "recorder failed");
|
||||
self.finalizing = None;
|
||||
self.state = AppState::Error {
|
||||
message: format!("Aufnahme: {err}"),
|
||||
};
|
||||
@@ -320,25 +326,27 @@ impl DoctateApp {
|
||||
}
|
||||
|
||||
fn handle_recording_finished(&mut self, output: PathBuf) {
|
||||
let (case_id, recorded_at) = match &self.state {
|
||||
AppState::FinalizingRecording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
} => (*case_id, recorded_at.clone()),
|
||||
AppState::Recording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
..
|
||||
} => (*case_id, recorded_at.clone()),
|
||||
other => {
|
||||
warn!(state = ?other, "recorder finished in unexpected state");
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
return;
|
||||
}
|
||||
// Prefer the explicit finalizing context (Stop-flow). Fall back
|
||||
// to AppState::Recording (recorder terminated before Stop — rare
|
||||
// but possible).
|
||||
let (case_id, recorded_at) = if let Some((id, rec)) = self.finalizing.take() {
|
||||
(id, rec)
|
||||
} else if let AppState::Recording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
..
|
||||
} = &self.state
|
||||
{
|
||||
let pair = (*case_id, recorded_at.clone());
|
||||
self.state = AppState::Idle;
|
||||
pair
|
||||
} else {
|
||||
warn!(state = ?self.state, "recorder finished in unexpected state");
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
return;
|
||||
};
|
||||
|
||||
// Bump activity on the marker so the case rises in the list.
|
||||
let store = self.case_store.clone();
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
self.runtime.block_on(async move {
|
||||
@@ -357,24 +365,19 @@ impl DoctateApp {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Sidecar schreiben: {e}"),
|
||||
};
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(uploader) = &self.uploader else {
|
||||
self.state = AppState::Error {
|
||||
message: "Upload-Worker nicht verfügbar".into(),
|
||||
};
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = uploader.submit(upload) {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Upload-Submit: {e}"),
|
||||
};
|
||||
return;
|
||||
// Kick the worker so it picks up the fresh file without waiting
|
||||
// for the next poll-tick.
|
||||
if let Some(sync) = &self.server_sync {
|
||||
sync.notify();
|
||||
} else {
|
||||
warn!("no server_sync active — upload stays on disk until next launch");
|
||||
}
|
||||
|
||||
self.state = AppState::Uploading { case_id };
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
}
|
||||
@@ -394,11 +397,6 @@ impl DoctateApp {
|
||||
}
|
||||
UploadEvent::Succeeded { case_id, status } => {
|
||||
info!(case_id = %case_id, status = ?status, "upload succeeded");
|
||||
if let AppState::Uploading { case_id: current } = &self.state
|
||||
&& *current == case_id
|
||||
{
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
}
|
||||
UploadEvent::Failed {
|
||||
case_id,
|
||||
@@ -406,10 +404,8 @@ impl DoctateApp {
|
||||
will_retry,
|
||||
} => {
|
||||
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
|
||||
if !will_retry
|
||||
&& let AppState::Uploading { case_id: current } = &self.state
|
||||
&& *current == case_id
|
||||
{
|
||||
if !will_retry {
|
||||
// Terminal = operator action needed. Block UI.
|
||||
self.state = AppState::Error { message: reason };
|
||||
}
|
||||
}
|
||||
@@ -456,16 +452,6 @@ impl DoctateApp {
|
||||
}
|
||||
None
|
||||
}
|
||||
AppState::FinalizingRecording { .. } => {
|
||||
ui.spinner();
|
||||
ui.label("Speichere Aufnahme…");
|
||||
None
|
||||
}
|
||||
AppState::Uploading { .. } => {
|
||||
ui.spinner();
|
||||
ui.label("Upload läuft…");
|
||||
None
|
||||
}
|
||||
AppState::Error { message } => {
|
||||
ui.colored_label(egui::Color32::RED, message);
|
||||
ui.add_space(8.0);
|
||||
@@ -498,7 +484,9 @@ impl DoctateApp {
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
let idle = matches!(self.state, AppState::Idle);
|
||||
// Ready-for-new means the user-intent state is Idle AND no ffmpeg
|
||||
// instance is still flushing. Upload-queue depth does NOT gate.
|
||||
let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for marker in visible {
|
||||
@@ -530,30 +518,60 @@ impl DoctateApp {
|
||||
action
|
||||
}
|
||||
|
||||
fn render_staleness(&self, ui: &mut egui::Ui) {
|
||||
let Some(poller) = &self.poller else {
|
||||
fn render_footer_status(&self, ui: &mut egui::Ui) {
|
||||
let Some(worker_rx) = &self.worker_rx else {
|
||||
ui.colored_label(egui::Color32::GRAY, "—");
|
||||
return;
|
||||
};
|
||||
let Some(last) = poller.last_success() else {
|
||||
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
|
||||
return;
|
||||
};
|
||||
let age = last.elapsed();
|
||||
let secs = age.as_secs();
|
||||
let (color, text) = if secs < 30 {
|
||||
(egui::Color32::DARK_GREEN, format!("🟢 zuletzt: vor {secs} s"))
|
||||
} else if secs < 120 {
|
||||
(
|
||||
egui::Color32::from_rgb(200, 140, 0),
|
||||
format!("🟡 zuletzt: vor {secs} s"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::DARK_RED,
|
||||
format!("🔴 zuletzt: vor {} s", secs),
|
||||
)
|
||||
};
|
||||
ui.colored_label(color, text);
|
||||
let snap = worker_rx.borrow().clone();
|
||||
let last_age = snap.last_success.map(|i| i.elapsed());
|
||||
let threshold = self
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
|
||||
.unwrap_or(Duration::from_secs(30));
|
||||
|
||||
let status = pick_footer_status(
|
||||
self.finalizing.is_some(),
|
||||
snap.state,
|
||||
snap.queue_len,
|
||||
last_age,
|
||||
threshold,
|
||||
);
|
||||
|
||||
match status {
|
||||
FooterStatus::Finalizing => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Speichere Aufnahme…");
|
||||
});
|
||||
}
|
||||
FooterStatus::Uploading { count } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
if count <= 1 {
|
||||
ui.label("Uploading…");
|
||||
} else {
|
||||
ui.label(format!("Uploading ({count} offen)…"));
|
||||
}
|
||||
});
|
||||
}
|
||||
FooterStatus::Synchronizing => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Synchronisiere…");
|
||||
});
|
||||
}
|
||||
FooterStatus::NeverConnected => {
|
||||
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
|
||||
}
|
||||
FooterStatus::Offline => {
|
||||
ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
|
||||
}
|
||||
FooterStatus::Online => {
|
||||
ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: UiAction) {
|
||||
@@ -566,32 +584,41 @@ impl DoctateApp {
|
||||
UiAction::DismissError => self.state = AppState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_is_active(&self) -> bool {
|
||||
let Some(rx) = &self.worker_rx else {
|
||||
return false;
|
||||
};
|
||||
let snap = rx.borrow();
|
||||
snap.state != WorkerState::Idle || snap.queue_len > 0
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_background(
|
||||
fn spawn_worker(
|
||||
runtime: &Runtime,
|
||||
config: Config,
|
||||
pending_dir: PathBuf,
|
||||
http: Arc<reqwest::Client>,
|
||||
case_store: Arc<CaseStore>,
|
||||
snapshot_cache: Arc<SnapshotCache>,
|
||||
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>, OnelinerPoller) {
|
||||
) -> (
|
||||
ServerSync,
|
||||
mpsc::UnboundedReceiver<UploadEvent>,
|
||||
watch::Receiver<WorkerSnapshot>,
|
||||
) {
|
||||
let _guard = runtime.enter();
|
||||
let poll_interval = config.poll_interval();
|
||||
let window_hours = config.window_hours();
|
||||
let (uploader, rx) = Uploader::spawn(config.clone(), pending_dir);
|
||||
let poller = OnelinerPoller::spawn(
|
||||
http,
|
||||
PollerConfig {
|
||||
server_url: config.server_url,
|
||||
api_key: config.api_key,
|
||||
poll_interval,
|
||||
window_hours,
|
||||
},
|
||||
case_store,
|
||||
snapshot_cache,
|
||||
);
|
||||
(uploader, rx, poller)
|
||||
let sync_config = SyncConfig {
|
||||
server_url: config.server_url,
|
||||
api_key: config.api_key,
|
||||
poll_interval,
|
||||
window_hours,
|
||||
};
|
||||
let (sync, events_rx) =
|
||||
ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
|
||||
let snapshot_rx = sync.snapshot();
|
||||
(sync, events_rx, snapshot_rx)
|
||||
}
|
||||
|
||||
fn parse_rfc3339(s: &str) -> Option<time::OffsetDateTime> {
|
||||
@@ -605,10 +632,7 @@ fn extract_hhmm(ts: &str) -> String {
|
||||
match parse_rfc3339(ts) {
|
||||
Some(t) => {
|
||||
let local = t
|
||||
.to_offset(
|
||||
time::UtcOffset::current_local_offset()
|
||||
.unwrap_or(time::UtcOffset::UTC),
|
||||
);
|
||||
.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC));
|
||||
format!("{:02}:{:02}", local.hour(), local.minute())
|
||||
}
|
||||
None => ts.chars().take(16).collect(),
|
||||
@@ -634,7 +658,7 @@ impl eframe::App for DoctateApp {
|
||||
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
self.render_staleness(ui);
|
||||
self.render_footer_status(ui);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
@@ -647,7 +671,6 @@ impl eframe::App for DoctateApp {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
// Case list fills the remaining space.
|
||||
if !matches!(self.state, AppState::NotConfigured)
|
||||
&& let Some(a) = self.render_case_list(ui)
|
||||
{
|
||||
@@ -659,14 +682,14 @@ impl eframe::App for DoctateApp {
|
||||
self.handle_action(action);
|
||||
}
|
||||
|
||||
// Repaint periodically so the staleness indicator + watch
|
||||
// snapshots reach the UI without a user event.
|
||||
match self.state {
|
||||
AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)),
|
||||
AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => {
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
_ => ctx.request_repaint_after(Duration::from_secs(1)),
|
||||
}
|
||||
// Repaint cadence: fast while visibly active (spinner, REC-timer),
|
||||
// slow (1 Hz) in the quiet steady state.
|
||||
let active_spinner = self.finalizing.is_some() || self.worker_is_active();
|
||||
let interval = match self.state {
|
||||
AppState::Recording { .. } => Duration::from_millis(250),
|
||||
_ if active_spinner => Duration::from_millis(100),
|
||||
_ => Duration::from_secs(1),
|
||||
};
|
||||
ctx.request_repaint_after(interval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ mod config;
|
||||
mod paths;
|
||||
mod recorder;
|
||||
mod state;
|
||||
mod uploader;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+158
-15
@@ -1,8 +1,14 @@
|
||||
//! UI-visible application state. Transitions are driven by user actions
|
||||
//! (buttons) and async events from the recorder + uploader channels.
|
||||
//! UI-visible application state and the footer status derivation.
|
||||
//!
|
||||
//! The state machine here only reflects **user intent** — "ready to
|
||||
//! record", "recording in progress", "config error, please fix".
|
||||
//! Background activity (upload queue, poll cycle, finalize flush) lives
|
||||
//! in a separate observable layer (`FooterStatus`) derived via a pure
|
||||
//! function so it can be table-tested without egui.
|
||||
|
||||
use std::time::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_client_core::server_sync::WorkerState;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -15,16 +21,7 @@ pub enum AppState {
|
||||
Recording {
|
||||
case_id: Uuid,
|
||||
recorded_at: String,
|
||||
started_at: Instant,
|
||||
},
|
||||
/// User pressed Stop; waiting for ffmpeg to finalize the MOOV atom.
|
||||
FinalizingRecording {
|
||||
case_id: Uuid,
|
||||
recorded_at: String,
|
||||
},
|
||||
/// File handed to uploader; waiting for server ACK.
|
||||
Uploading {
|
||||
case_id: Uuid,
|
||||
started_at: std::time::Instant,
|
||||
},
|
||||
/// Terminal error; user must dismiss or fix config.
|
||||
Error {
|
||||
@@ -38,9 +35,155 @@ impl AppState {
|
||||
Self::NotConfigured => "Konfiguration fehlt",
|
||||
Self::Idle => "Bereit",
|
||||
Self::Recording { .. } => "Aufnahme läuft",
|
||||
Self::FinalizingRecording { .. } => "Speichere…",
|
||||
Self::Uploading { .. } => "Upload läuft",
|
||||
Self::Error { .. } => "Fehler",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived observable rendered in the footer. Priority order encoded
|
||||
/// in `pick_footer_status` — active work beats connectivity indicators.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FooterStatus {
|
||||
/// ffmpeg is flushing the MOOV atom. Spinner.
|
||||
Finalizing,
|
||||
/// Worker is uploading. Spinner. `count` = pending-dir depth.
|
||||
Uploading { count: usize },
|
||||
/// Worker is polling `/api/oneliners`. Spinner.
|
||||
Synchronizing,
|
||||
/// Never got a successful request yet — cold start or bad config.
|
||||
NeverConnected,
|
||||
/// Last success older than threshold. Red text.
|
||||
Offline,
|
||||
/// Last success fresh. Green text. The default quiet state.
|
||||
Online,
|
||||
}
|
||||
|
||||
/// Decide which footer status to show. Pure function — no side effects,
|
||||
/// fully table-testable. Inputs chosen to match what `DoctateApp` can
|
||||
/// observe cheaply per egui frame.
|
||||
pub fn pick_footer_status(
|
||||
finalizing: bool,
|
||||
worker_state: WorkerState,
|
||||
queue_len: usize,
|
||||
last_success_age: Option<Duration>,
|
||||
offline_threshold: Duration,
|
||||
) -> FooterStatus {
|
||||
if finalizing {
|
||||
return FooterStatus::Finalizing;
|
||||
}
|
||||
if queue_len > 0 || worker_state == WorkerState::Uploading {
|
||||
return FooterStatus::Uploading {
|
||||
count: queue_len.max(1),
|
||||
};
|
||||
}
|
||||
if worker_state == WorkerState::Polling {
|
||||
return FooterStatus::Synchronizing;
|
||||
}
|
||||
match last_success_age {
|
||||
None => FooterStatus::NeverConnected,
|
||||
Some(age) if age > offline_threshold => FooterStatus::Offline,
|
||||
Some(_) => FooterStatus::Online,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const THRESHOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
#[test]
|
||||
fn finalizing_wins_over_everything() {
|
||||
let s = pick_footer_status(
|
||||
true,
|
||||
WorkerState::Uploading,
|
||||
5,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Finalizing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploading_when_queue_non_empty() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
3,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploading_reports_at_least_one_even_if_scan_raced() {
|
||||
// Edge case: WorkerState says Uploading, but scan returned 0
|
||||
// (just finished a file and the next scan hasn't happened yet).
|
||||
// We still want to show "Uploading" to avoid a flicker back to
|
||||
// Online right before the next file starts.
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_when_polling_and_no_queue() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Polling,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Synchronizing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_connected_without_last_success() {
|
||||
let s = pick_footer_status(false, WorkerState::Idle, 0, None, THRESHOLD);
|
||||
assert_eq!(s, FooterStatus::NeverConnected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_when_last_success_is_stale() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(120)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_when_last_success_is_fresh() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(5)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
|
||||
// Exactly at threshold: still online (strict ">" in logic).
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(THRESHOLD),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
//! Async uploader with persistent on-disk queue and exponential-backoff
|
||||
//! retry. Recordings live in the pending directory as `{stem}.m4a` plus
|
||||
//! a sidecar `{stem}.meta.json`. The worker replays anything it finds
|
||||
//! there at startup, then processes new submissions as they arrive.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_common::ack::{AckResponse, AckStatus};
|
||||
use doctate_common::constants::{
|
||||
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
||||
UPLOAD_PATH,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(2);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A recording waiting to be uploaded. The `file` path is derived from
|
||||
/// the sidecar JSON's location, never from the JSON content itself —
|
||||
/// that way the sidecar stays valid even if the pending directory is
|
||||
/// renamed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingUpload {
|
||||
pub case_id: Uuid,
|
||||
pub recorded_at: String,
|
||||
#[serde(skip, default)]
|
||||
pub file: PathBuf,
|
||||
}
|
||||
|
||||
/// Lifecycle events emitted to the UI for a single upload attempt chain.
|
||||
#[derive(Debug)]
|
||||
pub enum UploadEvent {
|
||||
Started(Uuid),
|
||||
Succeeded {
|
||||
case_id: Uuid,
|
||||
status: AckStatus,
|
||||
},
|
||||
Failed {
|
||||
case_id: Uuid,
|
||||
reason: String,
|
||||
will_retry: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UploaderError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serialize sidecar: {0}")]
|
||||
Serialize(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Handle to the uploader's worker task. Drop does NOT shut down the
|
||||
/// worker; it stays alive as long as the `submit_tx` is kept.
|
||||
pub struct Uploader {
|
||||
submit_tx: mpsc::UnboundedSender<PendingUpload>,
|
||||
}
|
||||
|
||||
impl Uploader {
|
||||
/// Spawn the worker task. Must be called inside a Tokio runtime
|
||||
/// context (`Runtime::enter()` guard or `#[tokio::main]`).
|
||||
///
|
||||
/// Re-scans `pending_dir` at startup so leftover recordings from
|
||||
/// a prior run are replayed automatically.
|
||||
pub fn spawn(
|
||||
config: Config,
|
||||
pending_dir: PathBuf,
|
||||
) -> (Self, mpsc::UnboundedReceiver<UploadEvent>) {
|
||||
let (submit_tx, submit_rx) = mpsc::unbounded_channel();
|
||||
let (events_tx, events_rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(run_worker(config, pending_dir, submit_rx, events_tx));
|
||||
(Self { submit_tx }, events_rx)
|
||||
}
|
||||
|
||||
/// Submit a pending upload for processing. Non-blocking. Fails only
|
||||
/// if the worker has been dropped (should not happen in normal
|
||||
/// operation).
|
||||
pub fn submit(
|
||||
&self,
|
||||
upload: PendingUpload,
|
||||
) -> Result<(), mpsc::error::SendError<PendingUpload>> {
|
||||
self.submit_tx.send(upload)
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the sidecar JSON for a given m4a file — same directory,
|
||||
/// `{stem}.meta.json`.
|
||||
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
|
||||
let stem = m4a
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
|
||||
dir.join(format!("{stem}.meta.json"))
|
||||
}
|
||||
|
||||
/// Persist a pending upload's metadata next to its m4a file. Atomic:
|
||||
/// writes a `*.tmp` sibling and renames it into place, so a crash
|
||||
/// mid-write cannot leave a zero-byte or partial sidecar on disk.
|
||||
/// Without this guarantee, the `scan_pending_dir` recovery path would
|
||||
/// silently drop the upload as an unreadable orphan on next launch.
|
||||
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploaderError> {
|
||||
let sidecar = sidecar_path_for(&upload.file);
|
||||
if let Some(parent) = sidecar.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(upload)?;
|
||||
let tmp = sidecar.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &sidecar)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan `pending_dir` for m4a files with matching sidecars. Orphans
|
||||
/// (m4a without sidecar, or unreadable sidecar) are logged and skipped
|
||||
/// rather than propagated as errors — one broken file should not take
|
||||
/// down recovery of the rest.
|
||||
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
|
||||
let entries = match std::fs::read_dir(pending_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(e) => {
|
||||
warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
||||
continue;
|
||||
}
|
||||
match read_pending(&path) {
|
||||
Ok(upload) => out.push(upload),
|
||||
Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_pending(m4a: &Path) -> Result<PendingUpload, UploaderError> {
|
||||
let sidecar = sidecar_path_for(m4a);
|
||||
let json = std::fs::read_to_string(&sidecar)?;
|
||||
let mut upload: PendingUpload = serde_json::from_str(&json)?;
|
||||
upload.file = m4a.to_path_buf();
|
||||
Ok(upload)
|
||||
}
|
||||
|
||||
async fn run_worker(
|
||||
config: Config,
|
||||
pending_dir: PathBuf,
|
||||
mut submit_rx: mpsc::UnboundedReceiver<PendingUpload>,
|
||||
events_tx: mpsc::UnboundedSender<UploadEvent>,
|
||||
) {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let startup = scan_pending_dir(&pending_dir);
|
||||
if !startup.is_empty() {
|
||||
info!(count = startup.len(), "replaying pending uploads");
|
||||
}
|
||||
for pending in startup {
|
||||
upload_with_retry(&client, &config, pending, &events_tx).await;
|
||||
}
|
||||
|
||||
while let Some(pending) = submit_rx.recv().await {
|
||||
upload_with_retry(&client, &config, pending, &events_tx).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_with_retry(
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
upload: PendingUpload,
|
||||
events_tx: &mpsc::UnboundedSender<UploadEvent>,
|
||||
) {
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
loop {
|
||||
let _ = events_tx.send(UploadEvent::Started(upload.case_id));
|
||||
match attempt_upload(client, config, &upload).await {
|
||||
Ok(ack) => {
|
||||
let _ = tokio::fs::remove_file(&upload.file).await;
|
||||
let _ = tokio::fs::remove_file(&sidecar_path_for(&upload.file)).await;
|
||||
let _ = events_tx.send(UploadEvent::Succeeded {
|
||||
case_id: upload.case_id,
|
||||
status: ack.status,
|
||||
});
|
||||
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,
|
||||
will_retry: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(ErrorKind::Transient(reason)) => {
|
||||
let _ = events_tx.send(UploadEvent::Failed {
|
||||
case_id: upload.case_id,
|
||||
reason,
|
||||
will_retry: true,
|
||||
});
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ErrorKind {
|
||||
Transient(String),
|
||||
Terminal(String),
|
||||
}
|
||||
|
||||
async fn attempt_upload(
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
upload: &PendingUpload,
|
||||
) -> Result<AckResponse, ErrorKind> {
|
||||
let audio = tokio::fs::read(&upload.file)
|
||||
.await
|
||||
.map_err(|e| ErrorKind::Terminal(format!("read audio file: {e}")))?;
|
||||
|
||||
let file_name = upload
|
||||
.file
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "recording.m4a".to_string());
|
||||
|
||||
let audio_part = reqwest::multipart::Part::bytes(audio)
|
||||
.file_name(file_name)
|
||||
.mime_str(CONTENT_TYPE_AUDIO_MP4)
|
||||
.expect("static MIME string is valid");
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text(FIELD_CASE_ID, upload.case_id.to_string())
|
||||
.text(FIELD_RECORDED_AT, upload.recorded_at.clone())
|
||||
.part(FIELD_AUDIO, audio_part);
|
||||
|
||||
let url = format!("{}{}", config.server_url.trim_end_matches('/'), UPLOAD_PATH);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header(API_KEY_HEADER, &config.api_key)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ErrorKind::Transient(format!("network: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
resp.json::<AckResponse>()
|
||||
.await
|
||||
.map_err(|e| ErrorKind::Terminal(format!("parse ack: {e}")))
|
||||
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(ErrorKind::Transient(format!("HTTP {status}: {body}")))
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(ErrorKind::Terminal(format!("HTTP {status}: {body}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{header, method, path as wpath};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn sample_pending(dir: &Path) -> PendingUpload {
|
||||
let file = dir.join("sample.m4a");
|
||||
std::fs::write(&file, b"dummy audio").unwrap();
|
||||
PendingUpload {
|
||||
case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
||||
recorded_at: "2026-04-15T10:32:00Z".into(),
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
fn ack_body(status: AckStatus) -> AckResponse {
|
||||
AckResponse {
|
||||
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
|
||||
recorded_at: "2026-04-15T10:32:00Z".into(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_roundtrip_via_scan() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample_pending(tmp.path());
|
||||
write_sidecar(&upload).unwrap();
|
||||
let found = scan_pending_dir(tmp.path());
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].case_id, upload.case_id);
|
||||
assert_eq!(found[0].recorded_at, upload.recorded_at);
|
||||
assert_eq!(found[0].file, upload.file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_m4a_without_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
|
||||
let found = scan_pending_dir(tmp.path());
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_returns_empty_for_missing_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
assert!(scan_pending_dir(&missing).is_empty());
|
||||
}
|
||||
|
||||
/// Atomic sidecar write must leave no `.tmp` leftover on success —
|
||||
/// the recovery scan only accepts exact `.meta.json` files, so a
|
||||
/// stray temp file would be invisible but wastes inodes, and hints
|
||||
/// at a rename bug if it ever appears.
|
||||
#[test]
|
||||
fn write_sidecar_leaves_no_tmp_artefact() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample_pending(tmp.path());
|
||||
write_sidecar(&upload).unwrap();
|
||||
|
||||
let sidecar = sidecar_path_for(&upload.file);
|
||||
assert!(sidecar.exists(), "sidecar must be at final path");
|
||||
let tmp_path = sidecar.with_extension("json.tmp");
|
||||
assert!(!tmp_path.exists(), "tmp artefact must not remain");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_upload_deletes_files_and_emits_events() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wpath(UPLOAD_PATH))
|
||||
.and(header(API_KEY_HEADER, "test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample_pending(tmp.path());
|
||||
write_sidecar(&upload).unwrap();
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "test-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
upload_with_retry(&client, &config, upload.clone(), &tx).await;
|
||||
|
||||
assert!(!upload.file.exists(), "m4a should be deleted after ack");
|
||||
assert!(
|
||||
!sidecar_path_for(&upload.file).exists(),
|
||||
"sidecar should be deleted after ack"
|
||||
);
|
||||
|
||||
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
|
||||
let Some(UploadEvent::Succeeded { status, .. }) = rx.recv().await else {
|
||||
panic!("expected Succeeded event");
|
||||
};
|
||||
assert_eq!(status, AckStatus::Received);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn transient_500_retries_then_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wpath(UPLOAD_PATH))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wpath(UPLOAD_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample_pending(tmp.path());
|
||||
write_sidecar(&upload).unwrap();
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "test-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
upload_with_retry(&client, &config, upload.clone(), &tx).await;
|
||||
|
||||
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
|
||||
let Some(UploadEvent::Failed { will_retry, .. }) = rx.recv().await else {
|
||||
panic!("expected Failed event");
|
||||
};
|
||||
assert!(will_retry, "500 should be a transient failure");
|
||||
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
|
||||
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_deletes_files_and_does_not_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wpath(UPLOAD_PATH))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample_pending(tmp.path());
|
||||
write_sidecar(&upload).unwrap();
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "bad-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
upload_with_retry(&client, &config, upload.clone(), &tx).await;
|
||||
|
||||
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 {
|
||||
panic!("expected Failed event");
|
||||
};
|
||||
assert!(!will_retry, "401 is terminal; must not retry");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user