38aaddfd2a
Add retry_with_backoff utility (100ms base, ±25% jitter, 3 attempts) and replace all 11 direct DAO calls in insight_chat.rs and insight_generator.rs. The Mutex guard drops between retries so the maintenance connection can release its SQLite write lock.
154 lines
4.4 KiB
Rust
154 lines
4.4 KiB
Rust
use rand::Rng;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
/// Retry a fallible operation with exponential backoff + jitter.
|
|
///
|
|
/// Runs the closure immediately, then retries up to `max_retries` times on
|
|
/// failure. Each retry sleeps for `base_delay * 2^attempt` plus a uniform
|
|
/// jitter of ±25%. Non-final errors are logged at `debug` with the label;
|
|
/// the final error is logged at `error`.
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use image_api::utils::retry_with_backoff;
|
|
///
|
|
/// let result = retry_with_backoff("my-op", 3, || {
|
|
/// // something that may fail transiently
|
|
/// Ok::<_, anyhow::Error>(42)
|
|
/// });
|
|
/// ```
|
|
pub fn retry_with_backoff<F, T, E>(label: &str, max_retries: u32, mut op: F) -> Result<T, E>
|
|
where
|
|
F: FnMut() -> Result<T, E>,
|
|
E: std::fmt::Debug,
|
|
{
|
|
let mut last_err = match op() {
|
|
Ok(v) => return Ok(v),
|
|
Err(e) => e,
|
|
};
|
|
for attempt in 1..=max_retries {
|
|
let base = Duration::from_millis(100).saturating_mul(1_u32.pow(attempt - 1));
|
|
let jitter_range = base.as_millis() as f64 * 0.25;
|
|
let jitter = rand::thread_rng().gen_range(-jitter_range..=jitter_range) as u128;
|
|
let delay = base.as_millis() as i128 + jitter as i128;
|
|
std::thread::sleep(Duration::from_millis(delay.max(0) as u64));
|
|
log::debug!(
|
|
"{}: attempt {}/{} failed ({:?}), retrying in {}ms",
|
|
label,
|
|
attempt,
|
|
max_retries,
|
|
last_err,
|
|
delay.max(0)
|
|
);
|
|
match op() {
|
|
Ok(v) => return Ok(v),
|
|
Err(e) => last_err = e,
|
|
}
|
|
}
|
|
log::error!(
|
|
"{}: all {} retries exhausted: {:?}",
|
|
label,
|
|
max_retries,
|
|
last_err
|
|
);
|
|
Err(last_err)
|
|
}
|
|
|
|
/// Normalize a file path to use forward slashes for cross-platform consistency
|
|
/// This ensures paths stored in the database always use `/` regardless of OS
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use image_api::utils::normalize_path;
|
|
///
|
|
/// assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg");
|
|
/// assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg");
|
|
/// ```
|
|
pub fn normalize_path(path: &str) -> String {
|
|
path.replace('\\', "/")
|
|
}
|
|
|
|
/// Pick the earlier of a file's created and modified timestamps.
|
|
///
|
|
/// On copied/restored files (e.g., a backup library), `created` is stamped at
|
|
/// copy time while `modified` is preserved from the source — so the earlier
|
|
/// of the two is a better proxy for when the content originated. Falls back
|
|
/// to whichever timestamp is available if one platform lacks the other.
|
|
pub fn earliest_fs_time(md: &std::fs::Metadata) -> Option<SystemTime> {
|
|
match (md.created().ok(), md.modified().ok()) {
|
|
(Some(c), Some(m)) => Some(c.min(m)),
|
|
(Some(t), None) | (None, Some(t)) => Some(t),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_normalize_path_with_backslashes() {
|
|
assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_with_forward_slashes() {
|
|
assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_mixed() {
|
|
assert_eq!(
|
|
normalize_path("foo\\bar/baz\\qux.jpg"),
|
|
"foo/bar/baz/qux.jpg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_empty() {
|
|
assert_eq!(normalize_path(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_absolute_windows() {
|
|
assert_eq!(
|
|
normalize_path("C:\\Users\\Photos\\image.jpg"),
|
|
"C:/Users/Photos/image.jpg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_unc_path() {
|
|
assert_eq!(
|
|
normalize_path("\\\\server\\share\\folder\\file.jpg"),
|
|
"//server/share/folder/file.jpg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_single_filename() {
|
|
assert_eq!(normalize_path("image.jpg"), "image.jpg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_trailing_slash() {
|
|
assert_eq!(normalize_path("foo\\bar\\"), "foo/bar/");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_multiple_consecutive_backslashes() {
|
|
assert_eq!(
|
|
normalize_path("foo\\\\bar\\\\\\baz.jpg"),
|
|
"foo//bar///baz.jpg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_path_deep_nesting() {
|
|
assert_eq!(
|
|
normalize_path("a\\b\\c\\d\\e\\f\\g\\file.jpg"),
|
|
"a/b/c/d/e/f/g/file.jpg"
|
|
);
|
|
}
|
|
}
|