Add last_failure to worker state

The `WorkerSnapshot` now includes `last_failure`, which tracks the time
of the last recorded error. This allows the UI to display a more
accurate status, especially when a failure occurs after a recent
success.

The `pick_footer_status` function has been updated to prioritize
`last_failure`. If a failure occurred more recently than the last
success, the footer will show `Offline`, regardless of the success age.
This addresses scenarios where the server might have temporarily failed,
and the UI should reflect that immediately.

The `run_loop` function now updates `last_failure` upon transient or
terminal upload errors and poll failures. This ensures that the new
state information is correctly propagated.
This commit is contained in:
2026-04-18 13:45:21 +02:00
parent 305d739f56
commit 79e91246c6
4 changed files with 144 additions and 47 deletions
+26 -6
View File
@@ -65,8 +65,13 @@ pub struct WorkerSnapshot {
/// `Uploading`.
pub queue_len: usize,
/// Set to `Some(now)` after any successful upload or poll (200/304).
/// `None` before the first success. Drives Online/Offline heuristics.
/// `None` before the first success. Drives Online/Offline heuristics
/// together with `last_failure`.
pub last_success: Option<Instant>,
/// Set to `Some(now)` after any failed upload or poll. Compared to
/// `last_success` in the UI: if `last_failure` is younger, the
/// footer shows Offline regardless of the success's age.
pub last_failure: Option<Instant>,
}
impl Default for WorkerSnapshot {
@@ -75,6 +80,7 @@ impl Default for WorkerSnapshot {
state: WorkerState::Idle,
queue_len: 0,
last_success: None,
last_failure: None,
}
}
}
@@ -197,12 +203,17 @@ async fn run_loop(
let mut backoff = INITIAL_BACKOFF;
let mut last_success: Option<Instant> = None;
let mut last_failure: Option<Instant> = None;
let publish = |state: WorkerState, queue_len: usize, last: Option<Instant>| {
let publish = |state: WorkerState,
queue_len: usize,
last_ok: Option<Instant>,
last_fail: Option<Instant>| {
let snap = WorkerSnapshot {
state,
queue_len,
last_success: last,
last_success: last_ok,
last_failure: last_fail,
};
let _ = snapshot_tx.send(snap);
};
@@ -216,7 +227,7 @@ async fn run_loop(
let next = files.into_iter().next();
if let Some(upload) = next {
publish(WorkerState::Uploading, queue_len, last_success);
publish(WorkerState::Uploading, queue_len, last_success, last_failure);
let _ = events_tx.send(UploadEvent::Started(upload.case_id));
match post_upload(&http, &config, &upload).await {
UploadOutcome::Succeeded(status) => {
@@ -235,17 +246,24 @@ async fn run_loop(
}
UploadOutcome::Transient(reason) => {
warn!(case_id = %upload.case_id, reason = %reason, "upload failed transiently");
last_failure = Some(Instant::now());
let _ = events_tx.send(UploadEvent::Failed {
case_id: upload.case_id,
reason,
will_retry: true,
});
// Flip back to Idle before the backoff sleep so the UI
// doesn't misreport "Uploading" during a passive wait.
// The footer derives "Offline" from `last_failure` being
// younger than `last_success`.
publish(WorkerState::Idle, queue_len, last_success, last_failure);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
UploadOutcome::Terminal(reason) => {
warn!(case_id = %upload.case_id, reason = %reason, "upload failed terminally — deleting files");
delete_pair(&upload.file).await;
last_failure = Some(Instant::now());
backoff = INITIAL_BACKOFF;
let _ = events_tx.send(UploadEvent::Failed {
case_id: upload.case_id,
@@ -255,7 +273,7 @@ async fn run_loop(
}
}
} else {
publish(WorkerState::Polling, 0, last_success);
publish(WorkerState::Polling, 0, last_success, last_failure);
match poll_once(&http, &config, &store, &cache, &mut etag).await {
Ok(PollOutcome::Success) | Ok(PollOutcome::NotModified) => {
let now = Instant::now();
@@ -264,13 +282,15 @@ async fn run_loop(
}
Ok(PollOutcome::TransientHttp(code)) => {
warn!(code, "poll returned transient HTTP error");
last_failure = Some(Instant::now());
}
Err(e) => {
warn!(error = %e, "poll error");
last_failure = Some(Instant::now());
}
}
publish(WorkerState::Idle, 0, last_success);
publish(WorkerState::Idle, 0, last_success, last_failure);
// Wait for next tick OR kick. Cancel-safe on both arms.
tokio::select! {