Skip to main content

dre_protocol/
delivery.rs

1//! The delivery and network rules every file destination shares, written once.
2//!
3//! A destination implements a few primitives on its server ([`Store`]: write a file, rename,
4//! exists, delete, and what it can do atomically). [`deliver`] applies the rules on top:
5//!
6//! - `if_exists` ([`IfExists`]): replace a file already at the path, fail, or keep both by
7//!   numbering the new one (`report_2.xlsx`);
8//! - `atomic` and `temp_dir`: upload under a temporary name (`.<name>.dre-part`, in the same
9//!   folder or in `temp_dir`), then rename, so a half-written file never appears under its
10//!   final name;
11//! - `retries`: try again on a temporary error (a dropped connection, a 429 or 5xx), with
12//!   backoff and `Retry-After`;
13//! - `connect_timeout` and `timeout`, which the plugin applies to its client ([`Rules`] parses
14//!   them).
15//!
16//! The Go plugin module has the same rules (`go/plugin/delivery.go`). [`LocalStore`] is the local
17//! file system, used by core's built-in `local` destination.
18
19use std::path::Path;
20use std::time::Duration;
21
22use serde_json::{Map, Value};
23
24use crate::msg::{ConnectionField, FieldKind};
25use crate::options::{OptionField, OptionType};
26use crate::plugin::{ErrorKind, PluginError};
27
28/// What to do when a file already exists at the delivery path.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IfExists {
31    /// Replace it (the default: a rerun replaces a bad file).
32    Overwrite,
33    /// Fail this delivery.
34    Error,
35    /// Keep both: the new file is saved as `<name>_2.<ext>`, `_3`, and so on.
36    Number,
37}
38
39impl IfExists {
40    pub fn parse(s: &str) -> Option<IfExists> {
41        match s {
42            "overwrite" => Some(IfExists::Overwrite),
43            "error" => Some(IfExists::Error),
44            "number" => Some(IfExists::Number),
45            _ => None,
46        }
47    }
48}
49
50/// The rules for one delivery.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Rules {
53    pub if_exists: IfExists,
54    /// Write under a temporary name, then rename.
55    pub atomic: bool,
56    /// Where the temporary file goes: absolute, or relative to the final file's folder. `None`:
57    /// next to the final file.
58    pub temp_dir: Option<String>,
59    /// How many times to try again after a temporary error (0: never).
60    pub retries: u32,
61    /// For the plugin's client: how long to wait for a connection.
62    pub connect_timeout: Duration,
63    /// For the plugin's client: how long a read or write may make no progress.
64    pub timeout: Duration,
65}
66
67pub const DEFAULT_RETRIES: u32 = 3;
68pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
69pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
70
71impl Default for Rules {
72    fn default() -> Rules {
73        Rules {
74            if_exists: IfExists::Overwrite,
75            atomic: true,
76            temp_dir: None,
77            retries: DEFAULT_RETRIES,
78            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
79            timeout: DEFAULT_TIMEOUT,
80        }
81    }
82}
83
84impl Rules {
85    /// How destinations delivered before these rules: replace, straight to the final name, no
86    /// retries.
87    pub fn legacy() -> Rules {
88        Rules {
89            atomic: false,
90            retries: 0,
91            ..Rules::default()
92        }
93    }
94
95    /// The rules from a profile target's `connection` (`connect_timeout`, `timeout`,
96    /// `retries`) and a destination entry's `options` (`if_exists`, `atomic`, `temp_dir`),
97    /// starting from `base`. `aliases` maps deprecated keys to these (`("upload_timeout",
98    /// "timeout")`): an alias still works, with a warning. Returns the rules and the warnings.
99    pub fn from_settings(
100        base: Rules,
101        connection: &Map<String, Value>,
102        options: &Map<String, Value>,
103        aliases: &[(&str, &str)],
104    ) -> Result<(Rules, Vec<String>), String> {
105        let mut warnings = Vec::new();
106        let get = |key: &str, warnings: &mut Vec<String>| -> Option<Value> {
107            for map in [options, connection] {
108                if let Some(v) = map.get(key).filter(|v| !v.is_null()) {
109                    return Some(v.clone());
110                }
111                for (old, new) in aliases {
112                    if *new == key
113                        && let Some(v) = map.get(*old).filter(|v| !v.is_null())
114                    {
115                        warnings.push(format!("`{old}` is deprecated; use `{new}`"));
116                        return Some(v.clone());
117                    }
118                }
119            }
120            None
121        };
122        let mut r = base;
123        if let Some(v) = get("if_exists", &mut warnings) {
124            r.if_exists = v.as_str().and_then(IfExists::parse).ok_or_else(|| {
125                format!("`if_exists` must be one of `overwrite`, `error`, `number`, got {v}")
126            })?;
127        }
128        if let Some(v) = get("atomic", &mut warnings) {
129            r.atomic = v
130                .as_bool()
131                .ok_or_else(|| format!("`atomic` must be true or false, got {v}"))?;
132        }
133        if let Some(v) = get("temp_dir", &mut warnings) {
134            let s = v
135                .as_str()
136                .ok_or_else(|| format!("`temp_dir` must be a path, got {v}"))?;
137            r.temp_dir = Some(s.to_string()).filter(|s| !s.is_empty());
138        }
139        if let Some(v) = get("retries", &mut warnings) {
140            r.retries = v
141                .as_u64()
142                .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
143                .and_then(|n| u32::try_from(n).ok())
144                .ok_or_else(|| format!("`retries` must be a whole number, got {v}"))?;
145        }
146        if let Some(v) = get("connect_timeout", &mut warnings) {
147            r.connect_timeout = parse_duration(&v).map_err(|e| format!("`connect_timeout` {e}"))?;
148        }
149        if let Some(v) = get("timeout", &mut warnings) {
150            r.timeout = parse_duration(&v).map_err(|e| format!("`timeout` {e}"))?;
151        }
152        Ok((r, warnings))
153    }
154}
155
156/// A duration: `30s`, `5m`, `2h`, `1500ms`, or a bare number of seconds.
157pub fn parse_duration(v: &Value) -> Result<Duration, String> {
158    let bad = || format!("must be a duration such as `30s`, `5m` or a number of seconds, got {v}");
159    if let Some(n) = v.as_f64() {
160        return (n >= 0.0 && n.is_finite())
161            .then(|| Duration::from_secs_f64(n))
162            .ok_or_else(bad);
163    }
164    let s = v.as_str().ok_or_else(bad)?.trim();
165    let (num, unit) = s.split_at(
166        s.find(|c: char| !c.is_ascii_digit() && c != '.')
167            .unwrap_or(s.len()),
168    );
169    let n: f64 = num.parse().map_err(|_| bad())?;
170    let secs = match unit.trim() {
171        "" | "s" => n,
172        "ms" => n / 1000.0,
173        "m" => n * 60.0,
174        "h" => n * 3600.0,
175        _ => return Err(bad()),
176    };
177    Ok(Duration::from_secs_f64(secs))
178}
179
180/// The timeouts every network plugin shares (`connect_timeout`, `timeout`), for its
181/// `connection_fields()`. Read them with [`Rules::from_settings`].
182pub fn timeout_fields() -> Vec<ConnectionField> {
183    connection_fields()
184        .into_iter()
185        .filter(|f| f.name != "retries")
186        .collect()
187}
188
189/// The connection fields every network plugin shares, for its `connection_fields()`.
190pub fn connection_fields() -> Vec<ConnectionField> {
191    vec![
192        ConnectionField::new(
193            "connect_timeout",
194            "how long to wait for a connection (`30s`, `2m`, or seconds)",
195        )
196        .default("30s")
197        .kind(FieldKind::Duration)
198        .manual(),
199        ConnectionField::new(
200            "timeout",
201            "how long a read or write may make no progress before it fails",
202        )
203        .default("60s")
204        .kind(FieldKind::Duration)
205        .manual(),
206        ConnectionField::new(
207            "retries",
208            "how many times to try again after a temporary error (0: never)",
209        )
210        .default(DEFAULT_RETRIES)
211        .kind(FieldKind::Integer)
212        .manual(),
213    ]
214}
215
216/// The options every file destination shares, for its `options()`.
217pub fn option_fields() -> Vec<OptionField> {
218    vec![
219        OptionField::new(
220            "if_exists",
221            OptionType::String,
222            "when a file is already at the path: `overwrite` it, fail with `error`, or `number` the new one",
223        )
224        .choices(&["overwrite", "error", "number"])
225        .default("overwrite"),
226        OptionField::new(
227            "atomic",
228            OptionType::Boolean,
229            "upload under a temporary name, then rename, so no half-written file appears",
230        )
231        .default(true),
232        OptionField::new(
233            "temp_dir",
234            OptionType::String,
235            "where the temporary file goes (on the same server), for receivers that pick up any new file",
236        ),
237    ]
238}
239
240/// What a store can do in one step.
241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
242pub struct Caps {
243    /// `write` with `exclusive` fails with [`StoreError::Exists`] in the same step.
244    pub create_exclusive: bool,
245    /// `rename` without `replace` fails with [`StoreError::Exists`] in the same step.
246    pub rename_no_replace: bool,
247    /// A file only appears once it's complete (object stores), so no temporary name is needed.
248    pub visible_when_complete: bool,
249}
250
251/// Why a store operation failed.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum StoreError {
254    /// Something is already at the path.
255    Exists,
256    /// Trying again may work: a dropped connection, a timeout before anything was accepted, a
257    /// 429 or a 5xx. `retry_after` is the server's `Retry-After`.
258    Temporary {
259        message: String,
260        retry_after: Option<Duration>,
261    },
262    /// Trying again won't help: refused credentials or permissions, a bad path.
263    Failed(String),
264}
265
266impl StoreError {
267    pub fn temporary(message: impl Into<String>) -> StoreError {
268        StoreError::Temporary {
269            message: message.into(),
270            retry_after: None,
271        }
272    }
273}
274
275impl std::fmt::Display for StoreError {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        match self {
278            StoreError::Exists => f.write_str("a file is already there"),
279            StoreError::Temporary { message, .. } | StoreError::Failed(message) => f.write_str(message),
280        }
281    }
282}
283
284/// A destination's primitives. Paths are the destination's own (`outbound/report.xlsx`).
285pub trait Store {
286    fn caps(&self) -> Caps;
287    /// Write `local` to `remote`, creating its folders. With `exclusive`, fail with
288    /// [`StoreError::Exists`] if something is there (only asked when `caps().create_exclusive`).
289    fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError>;
290    /// Rename `from` to `to`, creating `to`'s folders. Without `replace`, fail with
291    /// [`StoreError::Exists`] if something is there (only asked when `caps().rename_no_replace`).
292    fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError>;
293    fn exists(&mut self, remote: &str) -> Result<bool, StoreError>;
294    /// Remove `remote`; a missing file isn't an error.
295    fn delete(&mut self, remote: &str) -> Result<(), StoreError>;
296    /// Called before trying again after a temporary error: drop a broken connection, so the
297    /// next call makes a new one.
298    fn reset(&mut self) {}
299}
300
301/// The most tries any delivery step took in the request being served, for the `delivered`
302/// reply (the plugin SDK reads it).
303static ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
304
305fn note_attempts(n: u32) {
306    ATTEMPTS.fetch_max(n, std::sync::atomic::Ordering::Relaxed);
307}
308
309/// The most tries a step took since the last call, at least 1; resets the count.
310pub fn take_attempts() -> u32 {
311    ATTEMPTS.swap(0, std::sync::atomic::Ordering::Relaxed).max(1)
312}
313
314/// What one try of a [`retry`] step gave.
315#[derive(Debug)]
316pub enum Retry<E> {
317    /// Trying again may work (a connection refused or reset, a 429 or 503); with the server's
318    /// `Retry-After`.
319    Temporary(E, Option<Duration>),
320    /// Trying again won't help, or might repeat something already done (a message the server
321    /// may have accepted).
322    Fail(E),
323}
324
325/// Run `f`, trying again on [`Retry::Temporary`] up to `retries` more times, with backoff
326/// (about 1s, then 4s, then 16s) or the server's `Retry-After`. Each retry is logged at info
327/// level, naming `what`; a step that needed several tries is reported to core.
328pub fn retry<T, E: std::fmt::Display>(
329    retries: u32,
330    what: &str,
331    f: impl FnMut() -> Result<T, Retry<E>>,
332) -> Result<T, E> {
333    retry_with(retries, what, &mut std::thread::sleep, f)
334}
335
336/// [`retry`], with the wait between tries supplied (tests don't wait).
337pub fn retry_with<T, E: std::fmt::Display>(
338    retries: u32,
339    what: &str,
340    sleep: &mut dyn FnMut(Duration),
341    mut f: impl FnMut() -> Result<T, Retry<E>>,
342) -> Result<T, E> {
343    let mut attempt = 1;
344    loop {
345        let r = f();
346        note_attempts(attempt);
347        match r {
348            Ok(v) => return Ok(v),
349            Err(Retry::Temporary(e, retry_after)) if attempt <= retries => {
350                let wait = retry_after.unwrap_or_else(|| backoff(attempt));
351                crate::log::info!(
352                    "{what}: {e}; trying again in {}s (attempt {} of {})",
353                    wait.as_secs(),
354                    attempt + 1,
355                    retries + 1
356                );
357                sleep(wait);
358                attempt += 1;
359            }
360            Err(Retry::Temporary(e, _) | Retry::Fail(e)) => return Err(e),
361        }
362    }
363}
364
365/// Whether an I/O error is a connection failing (refused, reset, aborted, timed out, closed
366/// early), which trying again may fix.
367pub fn is_connection_error(e: &std::io::Error) -> bool {
368    use std::io::ErrorKind::*;
369    matches!(
370        e.kind(),
371        ConnectionRefused
372            | ConnectionReset
373            | ConnectionAborted
374            | NotConnected
375            | BrokenPipe
376            | TimedOut
377            | UnexpectedEof
378            | HostUnreachable
379            | NetworkUnreachable
380            | NetworkDown
381    )
382}
383
384/// The `Retry-After` of a 429 or 503, in seconds (an HTTP date isn't read).
385pub fn retry_after(header: Option<&str>) -> Option<Duration> {
386    header?.trim().parse::<u64>().ok().map(Duration::from_secs)
387}
388
389/// Where a delivery landed.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct Delivered {
392    /// The final path (with `number`, possibly `report_2.xlsx`).
393    pub path: String,
394    /// How many tries it took (1 without a retry).
395    pub attempts: u32,
396}
397
398/// A failed delivery.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct DeliveryError {
401    pub message: String,
402    /// It failed because a file was already at the path (`if_exists: error`).
403    pub exists: bool,
404    pub attempts: u32,
405}
406
407impl std::fmt::Display for DeliveryError {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        f.write_str(&self.message)
410    }
411}
412
413impl std::error::Error for DeliveryError {}
414
415impl From<DeliveryError> for PluginError {
416    fn from(e: DeliveryError) -> PluginError {
417        let pe = PluginError::new(ErrorKind::Delivery, e.message);
418        if e.exists { pe.code("file-exists") } else { pe }
419    }
420}
421
422/// Deliver `local` to `remote` under `rules`, sleeping between retries.
423pub fn deliver(
424    store: &mut dyn Store,
425    local: &Path,
426    remote: &str,
427    rules: &Rules,
428) -> Result<Delivered, DeliveryError> {
429    deliver_with(store, local, remote, rules, &mut std::thread::sleep)
430}
431
432/// [`deliver`], with the wait between retries supplied (tests don't wait).
433pub fn deliver_with(
434    store: &mut dyn Store,
435    local: &Path,
436    remote: &str,
437    rules: &Rules,
438    sleep: &mut dyn FnMut(Duration),
439) -> Result<Delivered, DeliveryError> {
440    let mut attempt = 1;
441    loop {
442        let r = try_once(store, local, remote, rules);
443        note_attempts(attempt);
444        match r {
445            Ok(path) => {
446                return Ok(Delivered {
447                    path,
448                    attempts: attempt,
449                });
450            }
451            Err(StoreError::Temporary { message, retry_after }) if attempt <= rules.retries => {
452                let wait = retry_after.unwrap_or_else(|| backoff(attempt));
453                crate::log::info!(
454                    "{remote}: {message}; trying again in {}s (attempt {} of {})",
455                    wait.as_secs(),
456                    attempt + 1,
457                    rules.retries + 1
458                );
459                store.reset();
460                sleep(wait);
461                attempt += 1;
462            }
463            Err(e) => {
464                let exists = e == StoreError::Exists;
465                let message = if exists {
466                    format!("{remote} already exists (`if_exists: error`)")
467                } else if attempt > 1 {
468                    format!("{e} (after {attempt} tries)")
469                } else {
470                    e.to_string()
471                };
472                return Err(DeliveryError {
473                    message,
474                    exists,
475                    attempts: attempt,
476                });
477            }
478        }
479    }
480}
481
482/// About 1s, then 4s, then 16s, give or take a quarter.
483fn backoff(attempt: u32) -> Duration {
484    let base = 4f64.powi(attempt as i32 - 1);
485    let nanos = std::time::SystemTime::now()
486        .duration_since(std::time::UNIX_EPOCH)
487        .map(|d| d.subsec_nanos())
488        .unwrap_or(0);
489    let jitter = 0.75 + (nanos % 1000) as f64 / 2000.0;
490    Duration::from_secs_f64(base * jitter)
491}
492
493/// One try: the final path, or why it failed.
494fn try_once(store: &mut dyn Store, local: &Path, remote: &str, rules: &Rules) -> Result<String, StoreError> {
495    let caps = store.caps();
496    let candidates = |i: u32| numbered(remote, i);
497    let limit = if rules.if_exists == IfExists::Number {
498        1000
499    } else {
500        1
501    };
502    if rules.atomic && !caps.visible_when_complete {
503        let temp = temp_path(remote, rules.temp_dir.as_deref());
504        if let Err(e) = store.write(local, &temp, false) {
505            let _ = store.delete(&temp);
506            return Err(e);
507        }
508        let replace = rules.if_exists == IfExists::Overwrite;
509        for i in 1..=limit {
510            let to = candidates(i);
511            let r = if !replace && !caps.rename_no_replace {
512                // No single-step check here (FTP): look, then rename.
513                match store.exists(&to) {
514                    Ok(true) => Err(StoreError::Exists),
515                    Ok(false) => store.rename(&temp, &to, false),
516                    Err(e) => Err(e),
517                }
518            } else {
519                store.rename(&temp, &to, replace)
520            };
521            match r {
522                Ok(()) => return Ok(to),
523                Err(StoreError::Exists) if i < limit => continue,
524                Err(e) => {
525                    let _ = store.delete(&temp);
526                    return Err(e);
527                }
528            }
529        }
530        unreachable!("the loop returns on its last candidate")
531    }
532    let exclusive = rules.if_exists != IfExists::Overwrite;
533    for i in 1..=limit {
534        let to = candidates(i);
535        let r = if exclusive && !caps.create_exclusive {
536            match store.exists(&to) {
537                Ok(true) => Err(StoreError::Exists),
538                Ok(false) => store.write(local, &to, false),
539                Err(e) => Err(e),
540            }
541        } else {
542            store.write(local, &to, exclusive)
543        };
544        match r {
545            Ok(()) => return Ok(to),
546            Err(StoreError::Exists) if i < limit => continue,
547            Err(e) => return Err(e),
548        }
549    }
550    unreachable!("the loop returns on its last candidate")
551}
552
553/// `remote`, or for `i` > 1 the numbered name: `out/report_2.xlsx`.
554fn numbered(remote: &str, i: u32) -> String {
555    if i == 1 {
556        return remote.to_string();
557    }
558    let (dir, file) = split(remote);
559    let (stem, ext) = match file.rfind('.') {
560        Some(p) if p > 0 => (&file[..p], &file[p..]),
561        _ => (file, ""),
562    };
563    format!("{dir}{stem}_{i}{ext}")
564}
565
566/// The temporary name for `remote`: `.<name>.dre-part`, next to it or in `temp_dir`.
567fn temp_path(remote: &str, temp_dir: Option<&str>) -> String {
568    let (dir, file) = split(remote);
569    let name = format!(".{file}.dre-part");
570    match temp_dir {
571        None => format!("{dir}{name}"),
572        Some(t) if t.starts_with('/') || Path::new(t).is_absolute() => {
573            format!("{}/{name}", t.trim_end_matches(['/', '\\']))
574        }
575        Some(t) => format!("{dir}{}/{name}", t.trim_end_matches(['/', '\\'])),
576    }
577}
578
579/// `("out/", "report.xlsx")`: the folder (with its separator) and the file name.
580fn split(remote: &str) -> (&str, &str) {
581    match remote.rfind(['/', '\\']) {
582        Some(p) => (&remote[..=p], &remote[p + 1..]),
583        None => ("", remote),
584    }
585}
586
587/// The local file system, for core's `local` destination. Paths are file system paths.
588#[derive(Debug, Default)]
589pub struct LocalStore;
590
591fn io_failed(what: &str, path: &str, e: std::io::Error) -> StoreError {
592    if e.kind() == std::io::ErrorKind::AlreadyExists {
593        StoreError::Exists
594    } else {
595        StoreError::Failed(format!("can't {what} {path}: {e}"))
596    }
597}
598
599fn make_parent(path: &str) -> Result<(), StoreError> {
600    match Path::new(path).parent() {
601        Some(p) if !p.as_os_str().is_empty() => std::fs::create_dir_all(p)
602            .map_err(|e| StoreError::Failed(format!("can't create {}: {e}", p.display()))),
603        _ => Ok(()),
604    }
605}
606
607impl Store for LocalStore {
608    fn caps(&self) -> Caps {
609        Caps {
610            create_exclusive: true,
611            rename_no_replace: true,
612            visible_when_complete: false,
613        }
614    }
615
616    fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
617        make_parent(remote)?;
618        if !exclusive {
619            return std::fs::copy(local, remote)
620                .map(|_| ())
621                .map_err(|e| io_failed("write", remote, e));
622        }
623        let mut src = std::fs::File::open(local)
624            .map_err(|e| StoreError::Failed(format!("can't read {}: {e}", local.display())))?;
625        let mut dst = std::fs::OpenOptions::new()
626            .write(true)
627            .create_new(true)
628            .open(remote)
629            .map_err(|e| io_failed("write", remote, e))?;
630        std::io::copy(&mut src, &mut dst).map_err(|e| {
631            let _ = std::fs::remove_file(remote);
632            io_failed("write", remote, e)
633        })?;
634        Ok(())
635    }
636
637    fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
638        make_parent(to)?;
639        if replace {
640            return std::fs::rename(from, to).map_err(|e| io_failed("rename to", to, e));
641        }
642        // A hard link fails if `to` exists, in one step; then drop the old name.
643        std::fs::hard_link(from, to).map_err(|e| io_failed("rename to", to, e))?;
644        std::fs::remove_file(from).map_err(|e| io_failed("remove", from, e))
645    }
646
647    fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
648        Ok(Path::new(remote).exists())
649    }
650
651    fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
652        match std::fs::remove_file(remote) {
653            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io_failed("remove", remote, e)),
654            _ => Ok(()),
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use serde_json::json;
663    use std::collections::BTreeMap;
664
665    /// An in-memory store that can fail on cue.
666    #[derive(Default)]
667    struct Fake {
668        caps: Caps,
669        files: BTreeMap<String, String>,
670        /// Temporary failures to give before writes succeed.
671        flaky: u32,
672        /// Fail the next rename (a dropped connection after the upload).
673        break_rename: bool,
674        log: Vec<String>,
675    }
676
677    impl Store for Fake {
678        fn caps(&self) -> Caps {
679            self.caps
680        }
681        fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
682            self.log
683                .push(format!("write {remote}{}", if exclusive { " excl" } else { "" }));
684            if self.flaky > 0 {
685                self.flaky -= 1;
686                return Err(StoreError::temporary("connection reset"));
687            }
688            if exclusive && self.files.contains_key(remote) {
689                return Err(StoreError::Exists);
690            }
691            self.files.insert(remote.into(), local.display().to_string());
692            Ok(())
693        }
694        fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
695            self.log.push(format!(
696                "rename {from} {to}{}",
697                if replace { " replace" } else { "" }
698            ));
699            if std::mem::take(&mut self.break_rename) {
700                return Err(StoreError::Failed("connection dropped".into()));
701            }
702            if !replace && self.files.contains_key(to) {
703                return Err(StoreError::Exists);
704            }
705            let v = self
706                .files
707                .remove(from)
708                .ok_or(StoreError::Failed("no such file".into()))?;
709            self.files.insert(to.into(), v);
710            Ok(())
711        }
712        fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
713            self.log.push(format!("exists {remote}"));
714            Ok(self.files.contains_key(remote))
715        }
716        fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
717            self.files.remove(remote);
718            Ok(())
719        }
720    }
721
722    fn all_caps() -> Caps {
723        Caps {
724            create_exclusive: true,
725            rename_no_replace: true,
726            visible_when_complete: false,
727        }
728    }
729
730    fn run(store: &mut Fake, rules: &Rules) -> Result<Delivered, DeliveryError> {
731        deliver_with(store, Path::new("new"), "out/r.xlsx", rules, &mut |_| {})
732    }
733
734    fn rules(if_exists: IfExists, atomic: bool) -> Rules {
735        Rules {
736            if_exists,
737            atomic,
738            ..Rules::default()
739        }
740    }
741
742    #[test]
743    fn legacy_rules_write_straight_to_the_final_name() {
744        let mut s = Fake::default();
745        let d = run(&mut s, &Rules::legacy()).unwrap();
746        assert_eq!((d.path.as_str(), d.attempts), ("out/r.xlsx", 1));
747        assert_eq!(s.log, ["write out/r.xlsx"]);
748    }
749
750    #[test]
751    fn atomic_uploads_under_a_temporary_name_then_renames() {
752        let mut s = Fake {
753            caps: all_caps(),
754            ..Fake::default()
755        };
756        s.files.insert("out/r.xlsx".into(), "old".into());
757        run(&mut s, &rules(IfExists::Overwrite, true)).unwrap();
758        assert_eq!(
759            s.log,
760            [
761                "write out/.r.xlsx.dre-part",
762                "rename out/.r.xlsx.dre-part out/r.xlsx replace"
763            ]
764        );
765        assert_eq!(s.files.keys().collect::<Vec<_>>(), ["out/r.xlsx"]);
766        assert_eq!(s.files["out/r.xlsx"], "new");
767    }
768
769    #[test]
770    fn a_dropped_rename_leaves_no_file_at_the_final_name_or_temporary_one() {
771        let mut s = Fake {
772            caps: all_caps(),
773            break_rename: true,
774            ..Fake::default()
775        };
776        assert!(run(&mut s, &rules(IfExists::Overwrite, true)).is_err());
777        assert!(s.files.is_empty(), "{:?}", s.files);
778    }
779
780    #[test]
781    fn temp_dir_holds_the_temporary_file() {
782        let mut s = Fake {
783            caps: all_caps(),
784            ..Fake::default()
785        };
786        let mut r = rules(IfExists::Overwrite, true);
787        r.temp_dir = Some("../staging".into());
788        run(&mut s, &r).unwrap();
789        assert_eq!(s.log[0], "write out/../staging/.r.xlsx.dre-part");
790        r.temp_dir = Some("/tmp/".into());
791        s.log.clear();
792        run(&mut s, &r).unwrap();
793        assert_eq!(s.log[0], "write /tmp/.r.xlsx.dre-part");
794    }
795
796    #[test]
797    fn error_fails_when_the_file_exists_and_cleans_up() {
798        for atomic in [false, true] {
799            let mut s = Fake {
800                caps: all_caps(),
801                ..Fake::default()
802            };
803            s.files.insert("out/r.xlsx".into(), "old".into());
804            let e = run(&mut s, &rules(IfExists::Error, atomic)).unwrap_err();
805            assert!(e.exists && e.message.contains("already exists"), "{e:?}");
806            assert_eq!(s.files.len(), 1, "atomic={atomic}: {:?}", s.files);
807            assert_eq!(s.files["out/r.xlsx"], "old");
808            let pe = PluginError::from(e);
809            assert_eq!(
810                (pe.kind, pe.code.as_deref()),
811                (ErrorKind::Delivery, Some("file-exists"))
812            );
813        }
814    }
815
816    #[test]
817    fn number_keeps_both_files() {
818        for (atomic, caps) in [
819            (false, all_caps()),
820            (true, all_caps()),
821            (false, Caps::default()),
822            (true, Caps::default()),
823        ] {
824            let mut s = Fake {
825                caps,
826                ..Fake::default()
827            };
828            s.files.insert("out/r.xlsx".into(), "old".into());
829            s.files.insert("out/r_2.xlsx".into(), "old2".into());
830            let d = run(&mut s, &rules(IfExists::Number, atomic)).unwrap();
831            assert_eq!(d.path, "out/r_3.xlsx", "atomic={atomic} caps={caps:?}");
832            assert_eq!(s.files["out/r_3.xlsx"], "new");
833            assert_eq!(s.files.len(), 3, "{:?}", s.files);
834        }
835    }
836
837    #[test]
838    fn without_single_step_checks_it_looks_first() {
839        let mut s = Fake::default();
840        s.files.insert("out/r.xlsx".into(), "old".into());
841        assert!(run(&mut s, &rules(IfExists::Error, false)).unwrap_err().exists);
842        assert_eq!(s.log, ["exists out/r.xlsx"]);
843    }
844
845    #[test]
846    fn temporary_errors_are_retried_then_give_up() {
847        let mut s = Fake {
848            flaky: 2,
849            ..Fake::default()
850        };
851        let mut waits = Vec::new();
852        let d = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |w| {
853            waits.push(w)
854        })
855        .unwrap();
856        assert_eq!(d.attempts, 3);
857        assert_eq!(waits.len(), 2);
858        assert!(waits[0] < waits[1]);
859        let mut s = Fake {
860            flaky: 9,
861            ..Fake::default()
862        };
863        let e = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |_| {}).unwrap_err();
864        assert_eq!(e.attempts, 4);
865        assert!(e.message.contains("after 4 tries"), "{e}");
866        let no_retry = Rules {
867            retries: 0,
868            ..Rules::default()
869        };
870        let mut s = Fake {
871            flaky: 1,
872            ..Fake::default()
873        };
874        assert_eq!(run(&mut s, &no_retry).unwrap_err().attempts, 1);
875    }
876
877    #[test]
878    fn object_stores_need_no_temporary_name() {
879        let mut s = Fake {
880            caps: Caps {
881                create_exclusive: true,
882                visible_when_complete: true,
883                ..Caps::default()
884            },
885            ..Fake::default()
886        };
887        run(&mut s, &rules(IfExists::Error, true)).unwrap();
888        assert_eq!(s.log, ["write out/r.xlsx excl"]);
889    }
890
891    #[test]
892    fn settings_parse_with_aliases_and_errors() {
893        let conn = json!({"connect_timeout": "2m", "upload_timeout": 90, "retries": "0"});
894        let opts = json!({"if_exists": "number", "atomic": false, "temp_dir": "../in"});
895        let (r, warnings) = Rules::from_settings(
896            Rules::default(),
897            conn.as_object().unwrap(),
898            opts.as_object().unwrap(),
899            &[("upload_timeout", "timeout")],
900        )
901        .unwrap();
902        assert_eq!(r.if_exists, IfExists::Number);
903        assert!(!r.atomic);
904        assert_eq!(r.temp_dir.as_deref(), Some("../in"));
905        assert_eq!(r.retries, 0);
906        assert_eq!(r.connect_timeout, Duration::from_secs(120));
907        assert_eq!(r.timeout, Duration::from_secs(90));
908        assert_eq!(warnings, ["`upload_timeout` is deprecated; use `timeout`"]);
909        let bad = json!({"if_exists": "keep"});
910        assert!(
911            Rules::from_settings(Rules::default(), &Map::new(), bad.as_object().unwrap(), &[])
912                .unwrap_err()
913                .contains("if_exists")
914        );
915    }
916
917    #[test]
918    fn durations_take_units_or_seconds() {
919        for (v, secs) in [
920            (json!("30s"), 30.0),
921            (json!("5m"), 300.0),
922            (json!("1h"), 3600.0),
923            (json!("1500ms"), 1.5),
924            (json!(45), 45.0),
925            (json!("7"), 7.0),
926        ] {
927            assert_eq!(parse_duration(&v).unwrap(), Duration::from_secs_f64(secs), "{v}");
928        }
929        for v in [json!("soon"), json!("5 days"), json!(-1), json!(true)] {
930            assert!(parse_duration(&v).is_err(), "{v}");
931        }
932    }
933
934    #[test]
935    fn names_number_and_hide_the_temporary_file() {
936        assert_eq!(numbered("out/r.xlsx", 2), "out/r_2.xlsx");
937        assert_eq!(numbered("r", 3), "r_3");
938        assert_eq!(numbered("a.b/.env", 2), "a.b/.env_2");
939        assert_eq!(numbered("x.tar.gz", 2), "x.tar_2.gz");
940        assert_eq!(temp_path("r.csv", None), ".r.csv.dre-part");
941    }
942
943    #[test]
944    fn the_local_store_follows_every_rule() {
945        let dir = tempfile::tempdir().unwrap();
946        let src = dir.path().join("src.csv");
947        std::fs::write(&src, "new").unwrap();
948        let dst = dir.path().join("out/r.csv");
949        let dst = dst.to_str().unwrap();
950        let mut s = LocalStore;
951        deliver(&mut s, &src, dst, &Rules::legacy()).unwrap();
952        assert_eq!(std::fs::read_to_string(dst).unwrap(), "new");
953        std::fs::write(&src, "newer").unwrap();
954        deliver(&mut s, &src, dst, &rules(IfExists::Overwrite, true)).unwrap();
955        assert_eq!(std::fs::read_to_string(dst).unwrap(), "newer");
956        let e = deliver(&mut s, &src, dst, &rules(IfExists::Error, true)).unwrap_err();
957        assert!(e.exists);
958        let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, true)).unwrap();
959        assert!(d.path.ends_with("r_2.csv"), "{d:?}");
960        let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, false)).unwrap();
961        assert!(d.path.ends_with("r_3.csv"), "{d:?}");
962        let mut names: Vec<String> = std::fs::read_dir(dir.path().join("out"))
963            .unwrap()
964            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
965            .collect();
966        names.sort();
967        assert_eq!(names, ["r.csv", "r_2.csv", "r_3.csv"]);
968    }
969}