use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use super::rclone_log::{RcloneLogSummary, RcloneLogTailer, TailDelta};
const LOG_POLL_INTERVAL: Duration = Duration::from_secs(10);
fn emit_delta_metrics(
job_name: &'static str,
phase_label: &'static str,
emit_upload_metrics: bool,
delta: &TailDelta,
) {
log_collapsed_repeats(phase_label, delta);
if delta.is_empty() {
return;
}
if delta.transferred > 0 {
crate::observability::metrics::record_phase_count(
job_name,
phase_label,
"transferred",
delta.transferred,
None,
);
}
if delta.errors > 0 {
crate::observability::metrics::record_phase_count(
job_name,
phase_label,
"failed",
delta.errors,
None,
);
}
if !emit_upload_metrics {
return;
}
if delta.bytes > 0 {
::metrics::counter!(
"pigeon_upload_bytes_total",
"pigeon_job" => job_name,
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.bytes);
}
if delta.transferred > 0 {
::metrics::counter!(
"pigeon_upload_outcomes_total",
"pigeon_job" => job_name,
"outcome" => "uploaded",
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.transferred);
}
if delta.errors > 0 {
::metrics::counter!(
"pigeon_upload_outcomes_total",
"pigeon_job" => job_name,
"outcome" => "failed",
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.errors);
}
}
fn forward_copied(
on_copied: &Option<tokio::sync::mpsc::UnboundedSender<String>>,
objects: Vec<String>,
) {
if let Some(sender) = on_copied {
for object in objects {
let _ = sender.send(object);
}
}
}
pub(crate) fn log_collapsed_repeats(step: &'static str, delta: &TailDelta) {
for summary in &delta.collapsed_repeats {
tracing::warn!(
cause = %summary.cause,
first_key = ?summary.first_key,
repeated = summary.repeated,
step = step,
"rclone: {} more object failure(s) with the same cause since the last report",
summary.repeated
);
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_rclone_copy(
source: &str,
destination: &str,
include_extension: Option<&str>,
log_path: &Path,
transfers: usize,
checkers: usize,
tpslimit: Option<usize>,
job_name: &'static str,
phase_label: &'static str,
emit_upload_metrics: bool,
on_copied: Option<tokio::sync::mpsc::UnboundedSender<String>>,
files_from: Option<&Path>,
) -> Result<RcloneLogSummary, String> {
tracing::info!(
source,
destination,
phase = phase_label,
"rclone: copy starting"
);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let mut rclone_args: Vec<String> = vec![
"--transfers".to_string(),
transfers.to_string(),
"--checkers".to_string(),
checkers.to_string(),
];
if let Some(tpslimit) = tpslimit {
rclone_args.push("--tpslimit".to_string());
rclone_args.push(tpslimit.to_string());
}
if let Some(extension) = include_extension {
rclone_args.push("--include".to_string());
rclone_args.push(format!("*.{extension}"));
rclone_args.push("--ignore-case".to_string());
}
if let Some(files_from) = files_from {
rclone_args.push("--files-from-raw".to_string());
rclone_args.push(files_from.display().to_string());
}
rclone_args.extend(
[
"--fast-list",
"--buffer-size",
"32M",
"--multi-thread-streams",
"4",
"--multi-thread-cutoff",
"256M",
"--retries",
"5",
"--low-level-retries",
"20",
"--stats",
"30s",
"--use-json-log",
"--log-level",
"INFO",
"--log-file",
]
.iter()
.map(|s| s.to_string()),
);
let mut child = tokio::process::Command::new("rclone")
.arg("copy")
.arg(source)
.arg(destination)
.args(rclone_args)
.arg(log_path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run rclone: {err}"))?;
let mut stderr_pipe = child.stderr.take().expect("stderr was configured as piped");
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
let _ = stderr_pipe.read_to_string(&mut buf).await;
buf
});
let mut tailer = RcloneLogTailer::new(log_path, phase_label);
let mut wait_handle = tokio::spawn(async move { child.wait().await });
let mut poll_interval = tokio::time::interval(LOG_POLL_INTERVAL);
poll_interval.tick().await;
let exit_status = loop {
tokio::select! {
join_result = &mut wait_handle => {
let wait_result = join_result
.map_err(|err| format!("rclone process join failed: {err}"))?;
break wait_result;
}
_ = poll_interval.tick() => {
let delta = tailer.poll();
emit_delta_metrics(job_name, phase_label, emit_upload_metrics, &delta);
forward_copied(&on_copied, delta.copied_objects);
}
}
};
let exit_status = exit_status.map_err(|err| format!("failed to run rclone: {err}"))?;
let final_delta = tailer.poll();
emit_delta_metrics(job_name, phase_label, emit_upload_metrics, &final_delta);
forward_copied(&on_copied, final_delta.copied_objects);
let log_summary = tailer.summary();
let exit_code = exit_status.code();
let stderr = stderr_task.await.unwrap_or_default();
tracing::info!(
transferred = log_summary.transferred,
errors = log_summary.errors,
bytes = log_summary.bytes,
exit_code,
phase = phase_label,
"rclone: copy complete"
);
if !exit_status.success() {
return Err(format!(
"rclone copy exited with status {} ({} file(s) transferred, {} error(s)); see {} for details{}",
exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
log_summary.transferred,
log_summary.errors,
log_path.display(),
if stderr.trim().is_empty() {
String::new()
} else {
format!(" -- rclone stderr: {}", stderr.trim())
},
));
}
Ok(log_summary)
}
#[cfg(test)]
mod tests {
use super::*;
async fn check_rclone_available() -> Result<(), String> {
tokio::process::Command::new("rclone")
.arg("version")
.output()
.await
.map_err(|_| "rclone not found on PATH".to_string())?;
Ok(())
}
#[tokio::test]
async fn copies_a_file_between_two_local_directories() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("hello.txt"), b"hello world").unwrap();
let log_path = log_dir.path().join("rclone.jsonl");
let summary = run_rclone_copy(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
None,
&log_path,
2,
4,
None,
"test-job",
"transfer",
true,
None,
None,
)
.await
.unwrap();
assert_eq!(summary.transferred, 1);
assert_eq!(summary.errors, 0);
assert!(dest_dir.path().join("hello.txt").exists());
}
#[tokio::test]
async fn include_extension_excludes_a_non_matching_file() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("photo.png"), b"png bytes").unwrap();
std::fs::write(source_dir.path().join("notes.txt"), b"not a png").unwrap();
let log_path = log_dir.path().join("rclone.jsonl");
let summary = run_rclone_copy(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
Some("png"),
&log_path,
2,
4,
None,
"test-job",
"pull",
false,
None,
None,
)
.await
.unwrap();
assert_eq!(summary.transferred, 1);
assert!(dest_dir.path().join("photo.png").exists());
assert!(!dest_dir.path().join("notes.txt").exists());
}
#[tokio::test]
async fn run_rclone_copy_sends_each_copied_objects_relative_path_on_the_channel() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("a.txt"), b"a").unwrap();
std::fs::write(source_dir.path().join("b.txt"), b"b").unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let log_path = log_dir.path().join("rclone.jsonl");
run_rclone_copy(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
None,
&log_path,
2,
4,
None,
"test-job",
"transfer",
true,
Some(tx),
None,
)
.await
.unwrap();
let mut received = Vec::new();
while let Ok(object) = rx.try_recv() {
received.push(object);
}
received.sort();
assert_eq!(received, vec!["a.txt".to_string(), "b.txt".to_string()]);
}
#[tokio::test]
async fn files_from_restricts_the_copy_to_exactly_the_listed_relative_paths() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("a.txt"), b"a").unwrap();
std::fs::write(source_dir.path().join("b.txt"), b"b").unwrap();
let files_from_path = log_dir.path().join("files-from.txt");
std::fs::write(&files_from_path, "a.txt\n").unwrap();
let log_path = log_dir.path().join("rclone.jsonl");
let summary = run_rclone_copy(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
None,
&log_path,
2,
4,
None,
"test-job",
"pull",
false,
None,
Some(&files_from_path),
)
.await
.unwrap();
assert_eq!(summary.transferred, 1);
assert!(dest_dir.path().join("a.txt").exists());
assert!(!dest_dir.path().join("b.txt").exists());
}
}