From 14831eb3550ec3d1fca15fafc380e2166db2c998 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 11:14:34 +0200 Subject: [PATCH 1/9] feat(restore): add ProgressTracker for download progress logging Co-Authored-By: Claude Opus 4.8 --- src/services/restore/downloader.rs | 66 ++++++++++++++++++ src/tests/services/mod.rs | 1 + .../services/restore_downloader_tests.rs | 68 +++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/tests/services/restore_downloader_tests.rs diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index ef10394..01fdfd4 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -6,6 +6,72 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::services::backup::logger::JobLogger; +/// Fallback progress cadence when the server sends no Content-Length. +pub(crate) const BYTE_STEP: u64 = 512 * 1024 * 1024; // 512 MB + +/// Tracks streamed download progress and emits milestone log lines. +/// Pure arithmetic, no I/O — unit-tested in +/// `src/tests/services/restore_downloader_tests.rs`. +pub(crate) struct ProgressTracker { + total: Option, + downloaded: u64, + next_pct: u64, + next_bytes: u64, +} + +impl ProgressTracker { + pub(crate) fn new(total: Option) -> Self { + Self { + total, + downloaded: 0, + next_pct: 10, + next_bytes: BYTE_STEP, + } + } + + /// Record `n` more downloaded bytes; return any milestone messages crossed + /// (normally 0 or 1; more only if one chunk spans several milestones). + pub(crate) fn advance(&mut self, n: usize) -> Vec { + self.downloaded += n as u64; + let mut msgs = Vec::new(); + + match self.total { + Some(total) if total > 0 => { + let pct = self.downloaded.saturating_mul(100) / total; + while pct >= self.next_pct && self.next_pct <= 100 { + msgs.push(format!( + "Download progress: {}% ({}/{} MB)", + self.next_pct, + mb(self.downloaded), + mb(total), + )); + self.next_pct += 10; + } + } + _ => { + while self.downloaded >= self.next_bytes { + msgs.push(format!("Downloaded {} MB", mb(self.downloaded))); + self.next_bytes += BYTE_STEP; + } + } + } + + msgs + } + + /// Human-readable total for the start log line. + pub(crate) fn fmt_total(total: Option) -> String { + match total { + Some(t) if t > 0 => format!("{} MB", mb(t)), + _ => "unknown size".to_string(), + } + } +} + +fn mb(bytes: u64) -> u64 { + bytes / 1024 / 1024 +} + impl RestoreService { pub async fn download_backup(&self, file_url: &str, tmp_path: &Path, logger: Arc) -> Result { logger.log("info", "Start downloading backup archive".to_string()); diff --git a/src/tests/services/mod.rs b/src/tests/services/mod.rs index 1780d61..8e03c98 100644 --- a/src/tests/services/mod.rs +++ b/src/tests/services/mod.rs @@ -1,2 +1,3 @@ mod api_models_tests; mod backup_uploader_tests; +mod restore_downloader_tests; diff --git a/src/tests/services/restore_downloader_tests.rs b/src/tests/services/restore_downloader_tests.rs new file mode 100644 index 0000000..11f2fce --- /dev/null +++ b/src/tests/services/restore_downloader_tests.rs @@ -0,0 +1,68 @@ +use crate::services::restore::downloader::{BYTE_STEP, ProgressTracker}; + +const MB: usize = 1024 * 1024; + +#[test] +fn percent_milestones_small_chunks() { + // total = 1000 MB, fed 100 MB at a time => one milestone per advance, 10 total. + let total = 1000u64 * 1024 * 1024; + let mut t = ProgressTracker::new(Some(total)); + + let mut all = Vec::new(); + for _ in 0..10 { + all.extend(t.advance(100 * MB)); + } + + assert_eq!(all.len(), 10); + assert!(all[0].starts_with("Download progress: 10%")); + assert_eq!(all[2], "Download progress: 30% (300/1000 MB)"); + assert!(all[9].starts_with("Download progress: 100%")); +} + +#[test] +fn percent_single_chunk_crosses_multiple() { + let total = 100u64 * 1024 * 1024; + let mut t = ProgressTracker::new(Some(total)); + + let msgs = t.advance(35 * MB); // jump to 35% + assert_eq!(msgs.len(), 3); + assert!(msgs[0].starts_with("Download progress: 10%")); + assert!(msgs[1].starts_with("Download progress: 20%")); + assert!(msgs[2].starts_with("Download progress: 30%")); +} + +#[test] +fn percent_caps_at_100() { + // downloaded exceeds total (e.g. compressed transfer): never log past 100%. + let total = 100u64 * 1024 * 1024; + let mut t = ProgressTracker::new(Some(total)); + + let msgs = t.advance(200 * MB); + assert_eq!(msgs.len(), 10); // 10..=100 + assert_eq!(*msgs.last().unwrap(), "Download progress: 100% (200/100 MB)"); + assert_eq!(msgs.iter().filter(|m| m.contains("110%")).count(), 0); +} + +#[test] +fn byte_mode_when_total_unknown() { + let mut t = ProgressTracker::new(None); + + assert!(t.advance(100 * MB).is_empty()); // below first 512 MB step + let msgs = t.advance(500 * MB); // crosses 512 MB at downloaded = 600 MB + assert_eq!(msgs, vec!["Downloaded 600 MB".to_string()]); + + let _ = BYTE_STEP; // ensure the const is exported +} + +#[test] +fn zero_total_no_progress_no_panic() { + let mut t = ProgressTracker::new(Some(0)); + assert!(t.advance(10 * MB).is_empty()); +} + +#[test] +fn fmt_total_variants() { + assert_eq!(ProgressTracker::fmt_total(Some(1024 * 1024 * 1024)), "1024 MB"); + assert_eq!(ProgressTracker::fmt_total(None), "unknown size"); + assert_eq!(ProgressTracker::fmt_total(Some(0)), "unknown size"); +} From 26092d4bc5ee686b49201a0280efa252173ea1d2 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 11:16:09 +0200 Subject: [PATCH 2/9] fix(restore): stream backup download to disk instead of buffering in RAM response.bytes() buffered the whole file in memory; >5GB downloads exhausted RAM and hung. Stream via bytes_stream() to a file in constant memory and log progress every 10%. Co-Authored-By: Claude Opus 4.8 --- src/services/restore/downloader.rs | 40 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index 01fdfd4..684f47e 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -1,9 +1,12 @@ use super::service::RestoreService; use anyhow::Result; +use futures::StreamExt; use reqwest::{Client, Url}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Instant; +use tokio::io::AsyncWriteExt; use crate::services::backup::logger::JobLogger; /// Fallback progress cadence when the server sends no Content-Length. @@ -105,11 +108,42 @@ impl RestoreService { let path = tmp_path.join(&filename); - let bytes = response.bytes().await?; + let total = response.content_length(); + let mut tracker = ProgressTracker::new(total); + + logger.log( + "info", + format!( + "Downloading backup '{}' ({})", + filename, + ProgressTracker::fmt_total(total) + ), + ); + + let start = Instant::now(); + let mut file = tokio::fs::File::create(&path).await?; + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk).await?; + for msg in tracker.advance(chunk.len()) { + logger.log("info", msg); + } + } + + file.flush().await?; - tokio::fs::write(&path, &bytes).await?; + logger.log( + "info", + format!( + "Backup downloaded to {} ({} MB in {}s)", + path.display(), + mb(tracker.downloaded), + start.elapsed().as_secs() + ), + ); - logger.log("info", format!("Backup downloaded to {}", path.display())); Ok(path) } } From 9781e08d8d6eb4353a6cb8b58ee0efe909443aee Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 11:33:16 +0200 Subject: [PATCH 3/9] chore(restore): add download diagnostics + exact byte logging Log response status/content-length/content-encoding/transfer-encoding to explain missing Content-Length ('unknown size'). Report exact byte count with human units (KB/B) instead of flooring sub-1MB downloads to '0 MB', and warn on empty body. --- src/services/restore/downloader.rs | 50 +++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index 684f47e..e40f707 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -75,6 +75,17 @@ fn mb(bytes: u64) -> u64 { bytes / 1024 / 1024 } +/// Human-readable byte count for end-of-download log (avoids "0 MB" for small files). +fn human_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{} MB", bytes / 1024 / 1024) + } else if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{bytes} B") + } +} + impl RestoreService { pub async fn download_backup(&self, file_url: &str, tmp_path: &Path, logger: Arc) -> Result { logger.log("info", "Start downloading backup archive".to_string()); @@ -82,8 +93,9 @@ impl RestoreService { let client = Client::new(); let response = client.get(file_url).send().await?; + let status = response.status(); - if !response.status().is_success() { + if !status.is_success() { logger.log("error", "Failed to download".to_string()); anyhow::bail!("download failed"); } @@ -111,6 +123,27 @@ impl RestoreService { let total = response.content_length(); let mut tracker = ProgressTracker::new(total); + // Diagnostic: explains a missing Content-Length ("unknown size") — chunked + // transfer or reqwest transparently decoding a Content-Encoding both drop it. + let content_encoding = response + .headers() + .get(reqwest::header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let transfer_encoding = response + .headers() + .get(reqwest::header::TRANSFER_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + logger.log( + "debug", + format!( + "Download response: status={}, content-length={:?}, content-encoding={:?}, transfer-encoding={:?}", + status, total, content_encoding, transfer_encoding + ), + ); + logger.log( "info", format!( @@ -134,13 +167,22 @@ impl RestoreService { file.flush().await?; + let downloaded = tracker.downloaded; + if downloaded == 0 { + logger.log( + "warn", + format!("Downloaded 0 bytes (status {status}); backup body was empty"), + ); + } + logger.log( "info", format!( - "Backup downloaded to {} ({} MB in {}s)", + "Backup downloaded to {} ({} / {} bytes in {:.1}s)", path.display(), - mb(tracker.downloaded), - start.elapsed().as_secs() + human_size(downloaded), + downloaded, + start.elapsed().as_secs_f64() ), ); From 78e0d14d9546471a2f0663aa2a7b4175377d9da3 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 11:45:33 +0200 Subject: [PATCH 4/9] refactor(restore): drop ProgressTracker, keep streaming download Remove percent/byte progress logging and its unit tests per request. Keep the core fix: stream the response to disk in constant memory instead of buffering the whole file in RAM. Retain exact byte-count log and empty-body warning. --- src/services/restore/downloader.rs | 109 +----------------- src/tests/services/mod.rs | 1 - .../services/restore_downloader_tests.rs | 68 ----------- 3 files changed, 5 insertions(+), 173 deletions(-) delete mode 100644 src/tests/services/restore_downloader_tests.rs diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index e40f707..befffc1 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -9,73 +9,7 @@ use std::time::Instant; use tokio::io::AsyncWriteExt; use crate::services::backup::logger::JobLogger; -/// Fallback progress cadence when the server sends no Content-Length. -pub(crate) const BYTE_STEP: u64 = 512 * 1024 * 1024; // 512 MB - -/// Tracks streamed download progress and emits milestone log lines. -/// Pure arithmetic, no I/O — unit-tested in -/// `src/tests/services/restore_downloader_tests.rs`. -pub(crate) struct ProgressTracker { - total: Option, - downloaded: u64, - next_pct: u64, - next_bytes: u64, -} - -impl ProgressTracker { - pub(crate) fn new(total: Option) -> Self { - Self { - total, - downloaded: 0, - next_pct: 10, - next_bytes: BYTE_STEP, - } - } - - /// Record `n` more downloaded bytes; return any milestone messages crossed - /// (normally 0 or 1; more only if one chunk spans several milestones). - pub(crate) fn advance(&mut self, n: usize) -> Vec { - self.downloaded += n as u64; - let mut msgs = Vec::new(); - - match self.total { - Some(total) if total > 0 => { - let pct = self.downloaded.saturating_mul(100) / total; - while pct >= self.next_pct && self.next_pct <= 100 { - msgs.push(format!( - "Download progress: {}% ({}/{} MB)", - self.next_pct, - mb(self.downloaded), - mb(total), - )); - self.next_pct += 10; - } - } - _ => { - while self.downloaded >= self.next_bytes { - msgs.push(format!("Downloaded {} MB", mb(self.downloaded))); - self.next_bytes += BYTE_STEP; - } - } - } - - msgs - } - - /// Human-readable total for the start log line. - pub(crate) fn fmt_total(total: Option) -> String { - match total { - Some(t) if t > 0 => format!("{} MB", mb(t)), - _ => "unknown size".to_string(), - } - } -} - -fn mb(bytes: u64) -> u64 { - bytes / 1024 / 1024 -} - -/// Human-readable byte count for end-of-download log (avoids "0 MB" for small files). +/// Human-readable byte count for the end-of-download log (avoids "0 MB" for small files). fn human_size(bytes: u64) -> String { if bytes >= 1024 * 1024 { format!("{} MB", bytes / 1024 / 1024) @@ -120,54 +54,21 @@ impl RestoreService { let path = tmp_path.join(&filename); - let total = response.content_length(); - let mut tracker = ProgressTracker::new(total); - - // Diagnostic: explains a missing Content-Length ("unknown size") — chunked - // transfer or reqwest transparently decoding a Content-Encoding both drop it. - let content_encoding = response - .headers() - .get(reqwest::header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let transfer_encoding = response - .headers() - .get(reqwest::header::TRANSFER_ENCODING) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - logger.log( - "debug", - format!( - "Download response: status={}, content-length={:?}, content-encoding={:?}, transfer-encoding={:?}", - status, total, content_encoding, transfer_encoding - ), - ); - - logger.log( - "info", - format!( - "Downloading backup '{}' ({})", - filename, - ProgressTracker::fmt_total(total) - ), - ); - + // Stream the body to disk in constant memory (was: response.bytes() buffered + // the whole file in RAM, hanging on >5GB downloads). let start = Instant::now(); let mut file = tokio::fs::File::create(&path).await?; let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk).await?; - for msg in tracker.advance(chunk.len()) { - logger.log("info", msg); - } + downloaded += chunk.len() as u64; } file.flush().await?; - let downloaded = tracker.downloaded; if downloaded == 0 { logger.log( "warn", diff --git a/src/tests/services/mod.rs b/src/tests/services/mod.rs index 8e03c98..1780d61 100644 --- a/src/tests/services/mod.rs +++ b/src/tests/services/mod.rs @@ -1,3 +1,2 @@ mod api_models_tests; mod backup_uploader_tests; -mod restore_downloader_tests; diff --git a/src/tests/services/restore_downloader_tests.rs b/src/tests/services/restore_downloader_tests.rs deleted file mode 100644 index 11f2fce..0000000 --- a/src/tests/services/restore_downloader_tests.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::services::restore::downloader::{BYTE_STEP, ProgressTracker}; - -const MB: usize = 1024 * 1024; - -#[test] -fn percent_milestones_small_chunks() { - // total = 1000 MB, fed 100 MB at a time => one milestone per advance, 10 total. - let total = 1000u64 * 1024 * 1024; - let mut t = ProgressTracker::new(Some(total)); - - let mut all = Vec::new(); - for _ in 0..10 { - all.extend(t.advance(100 * MB)); - } - - assert_eq!(all.len(), 10); - assert!(all[0].starts_with("Download progress: 10%")); - assert_eq!(all[2], "Download progress: 30% (300/1000 MB)"); - assert!(all[9].starts_with("Download progress: 100%")); -} - -#[test] -fn percent_single_chunk_crosses_multiple() { - let total = 100u64 * 1024 * 1024; - let mut t = ProgressTracker::new(Some(total)); - - let msgs = t.advance(35 * MB); // jump to 35% - assert_eq!(msgs.len(), 3); - assert!(msgs[0].starts_with("Download progress: 10%")); - assert!(msgs[1].starts_with("Download progress: 20%")); - assert!(msgs[2].starts_with("Download progress: 30%")); -} - -#[test] -fn percent_caps_at_100() { - // downloaded exceeds total (e.g. compressed transfer): never log past 100%. - let total = 100u64 * 1024 * 1024; - let mut t = ProgressTracker::new(Some(total)); - - let msgs = t.advance(200 * MB); - assert_eq!(msgs.len(), 10); // 10..=100 - assert_eq!(*msgs.last().unwrap(), "Download progress: 100% (200/100 MB)"); - assert_eq!(msgs.iter().filter(|m| m.contains("110%")).count(), 0); -} - -#[test] -fn byte_mode_when_total_unknown() { - let mut t = ProgressTracker::new(None); - - assert!(t.advance(100 * MB).is_empty()); // below first 512 MB step - let msgs = t.advance(500 * MB); // crosses 512 MB at downloaded = 600 MB - assert_eq!(msgs, vec!["Downloaded 600 MB".to_string()]); - - let _ = BYTE_STEP; // ensure the const is exported -} - -#[test] -fn zero_total_no_progress_no_panic() { - let mut t = ProgressTracker::new(Some(0)); - assert!(t.advance(10 * MB).is_empty()); -} - -#[test] -fn fmt_total_variants() { - assert_eq!(ProgressTracker::fmt_total(Some(1024 * 1024 * 1024)), "1024 MB"); - assert_eq!(ProgressTracker::fmt_total(None), "unknown size"); - assert_eq!(ProgressTracker::fmt_total(Some(0)), "unknown size"); -} From f1aac0bc20655247421fbfed2d3b6ea6aa6f3210 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 14:25:19 +0200 Subject: [PATCH 5/9] feat(restore): drive download progress from RestoreInfo.size Storage URLs often return no Content-Length, so progress showed 'unknown size'. Thread RestoreInfo.size (bytes) through dispatch -> execute_restore -> download_backup, parse it, and log progress every 10% against that total. --- src/services/restore/dispatcher.rs | 7 ++++- src/services/restore/downloader.rs | 44 +++++++++++++++++++++++++++--- src/services/restore/executor.rs | 11 ++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/services/restore/dispatcher.rs b/src/services/restore/dispatcher.rs index bf0c9c4..61f35fd 100644 --- a/src/services/restore/dispatcher.rs +++ b/src/services/restore/dispatcher.rs @@ -20,6 +20,8 @@ impl RestoreService { return; }; + let expected_size = db.data.restore.size.clone(); + let service = Self { ctx: self.ctx.clone(), }; @@ -27,7 +29,10 @@ impl RestoreService { let db_cfg = cfg.clone(); tokio::spawn(async move { - if let Err(e) = service.execute_restore(db_cfg, file_to_restore).await { + if let Err(e) = service + .execute_restore(db_cfg, file_to_restore, expected_size) + .await + { error!("Restore failed: {}", e); } }); diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index befffc1..791c8d7 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -9,7 +9,6 @@ use std::time::Instant; use tokio::io::AsyncWriteExt; use crate::services::backup::logger::JobLogger; -/// Human-readable byte count for the end-of-download log (avoids "0 MB" for small files). fn human_size(bytes: u64) -> String { if bytes >= 1024 * 1024 { format!("{} MB", bytes / 1024 / 1024) @@ -21,7 +20,13 @@ fn human_size(bytes: u64) -> String { } impl RestoreService { - pub async fn download_backup(&self, file_url: &str, tmp_path: &Path, logger: Arc) -> Result { + pub async fn download_backup( + &self, + file_url: &str, + tmp_path: &Path, + logger: Arc, + expected_size: Option, + ) -> Result { logger.log("info", "Start downloading backup archive".to_string()); let client = Client::new(); @@ -54,17 +59,48 @@ impl RestoreService { let path = tmp_path.join(&filename); - // Stream the body to disk in constant memory (was: response.bytes() buffered - // the whole file in RAM, hanging on >5GB downloads). + // Progress total comes from the restore metadata (RestoreInfo.size, bytes as + // a string); the storage URL often returns no Content-Length. + let total = expected_size + .as_deref() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&n| n > 0); + + logger.log( + "info", + format!( + "Downloading backup '{}' ({})", + filename, + total.map(human_size).unwrap_or_else(|| "unknown size".to_string()) + ), + ); + let start = Instant::now(); let mut file = tokio::fs::File::create(&path).await?; let mut stream = response.bytes_stream(); let mut downloaded: u64 = 0; + let mut next_pct: u64 = 10; while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk).await?; downloaded += chunk.len() as u64; + + if let Some(total) = total { + let pct = downloaded.saturating_mul(100) / total; + while pct >= next_pct && next_pct <= 100 { + logger.log( + "info", + format!( + "Download progress: {}% ({} / {})", + next_pct, + human_size(downloaded), + human_size(total) + ), + ); + next_pct += 10; + } + } } file.flush().await?; diff --git a/src/services/restore/executor.rs b/src/services/restore/executor.rs index 7f1cff1..27ef070 100644 --- a/src/services/restore/executor.rs +++ b/src/services/restore/executor.rs @@ -7,7 +7,12 @@ use std::time::Instant; use tempfile::TempDir; impl RestoreService { - pub async fn execute_restore(&self, cfg: DatabaseConfig, file_url: String) -> Result<()> { + pub async fn execute_restore( + &self, + cfg: DatabaseConfig, + file_url: String, + expected_size: Option, + ) -> Result<()> { let logger = Arc::new(JobLogger::new()); let start = Instant::now(); @@ -18,7 +23,9 @@ impl RestoreService { logger.log("info", format!("Created temp directory {}", tmp_path.display())); - let downloaded = self.download_backup(&file_url, tmp_path, Arc::clone(&logger)).await?; + let downloaded = self + .download_backup(&file_url, tmp_path, Arc::clone(&logger), expected_size) + .await?; let backup_file = self.prepare_archive(downloaded, tmp_path, Arc::clone(&logger)).await?; From f47300e9ae7dc9d0aefdc49d78eed8350abf67f2 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 14:30:51 +0200 Subject: [PATCH 6/9] fix(restore): accept integer or string for RestoreInfo.size Server sends size as a JSON integer (e.g. 1142), but the field was typed Option, breaking status deserialization (ping_server task errored: 'invalid type: integer, expected a string'). Reuse string_or_number_to_string so both forms parse; downstream still gets Option. --- src/services/api/models/agent/status.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/api/models/agent/status.rs b/src/services/api/models/agent/status.rs index 8b1b145..34ea915 100644 --- a/src/services/api/models/agent/status.rs +++ b/src/services/api/models/agent/status.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use crate::utils::deserializer::deserialize_snake_case; +use crate::utils::deserializer::{deserialize_snake_case, string_or_number_to_string}; use serde::{Deserialize, Serialize}; use toml::Value; @@ -54,4 +54,6 @@ pub struct RestoreInfo { pub file: Option, #[serde(rename = "metaFile")] pub meta_file: Option, + #[serde(default, deserialize_with = "string_or_number_to_string")] + pub size: Option, } From 4a8e7072bc53c2149ba60be9eae57f3dda89ce4e Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 14:33:28 +0200 Subject: [PATCH 7/9] fix(restore): show raw bytes in download progress log human_size rounding collapsed small downloads to '1 KB / 1 KB' for every milestone. Log exact byte counts instead: 'NN% (downloaded / total bytes)'. --- src/services/restore/downloader.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index 791c8d7..bfc72d0 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -59,8 +59,6 @@ impl RestoreService { let path = tmp_path.join(&filename); - // Progress total comes from the restore metadata (RestoreInfo.size, bytes as - // a string); the storage URL often returns no Content-Length. let total = expected_size .as_deref() .and_then(|s| s.trim().parse::().ok()) @@ -92,10 +90,8 @@ impl RestoreService { logger.log( "info", format!( - "Download progress: {}% ({} / {})", - next_pct, - human_size(downloaded), - human_size(total) + "Download progress: {}% ({} / {} bytes)", + next_pct, downloaded, total ), ); next_pct += 10; From 8ee4eeb53acc17d60fd256ceca0d3f13c372c76e Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 14:37:10 +0200 Subject: [PATCH 8/9] fix(restore): collapse progress to highest milestone per chunk Small files arrive in one chunk, so the while-loop logged all ten milestones at once with identical byte counts. Log only the highest 10% milestone a chunk crosses; tiny downloads now emit a single 100% line, multi-chunk downloads are unchanged. --- src/services/restore/downloader.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index bfc72d0..ca731db 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -85,16 +85,20 @@ impl RestoreService { downloaded += chunk.len() as u64; if let Some(total) = total { - let pct = downloaded.saturating_mul(100) / total; - while pct >= next_pct && next_pct <= 100 { + // Log only the highest 10% milestone crossed by this chunk, once. + // Small files arrive in a single chunk, so this emits one line + // (e.g. 100%) instead of repeating every milestone identically. + let pct = (downloaded.saturating_mul(100) / total).min(100); + let milestone = pct / 10 * 10; + if milestone >= next_pct { logger.log( "info", format!( "Download progress: {}% ({} / {} bytes)", - next_pct, downloaded, total + milestone, downloaded, total ), ); - next_pct += 10; + next_pct = milestone + 10; } } } @@ -111,9 +115,8 @@ impl RestoreService { logger.log( "info", format!( - "Backup downloaded to {} ({} / {} bytes in {:.1}s)", + "Backup downloaded to {} ( {} bytes in {:.1}s)", path.display(), - human_size(downloaded), downloaded, start.elapsed().as_secs_f64() ), From 247ddb6d0213e3e515ab3633405386ca2573af96 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Thu, 25 Jun 2026 14:39:25 +0200 Subject: [PATCH 9/9] fix: downloader.rs --- src/services/restore/downloader.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/services/restore/downloader.rs b/src/services/restore/downloader.rs index ca731db..a154160 100644 --- a/src/services/restore/downloader.rs +++ b/src/services/restore/downloader.rs @@ -85,9 +85,6 @@ impl RestoreService { downloaded += chunk.len() as u64; if let Some(total) = total { - // Log only the highest 10% milestone crossed by this chunk, once. - // Small files arrive in a single chunk, so this emits one line - // (e.g. 100%) instead of repeating every milestone identically. let pct = (downloaded.saturating_mul(100) / total).min(100); let milestone = pct / 10 * 10; if milestone >= next_pct {