use std::sync::atomic::{AtomicU64, Ordering};
use futures::{StreamExt, TryStreamExt};
use object_store::{ObjectStoreExt, WriteMultipart};
use tracing::debug;
use super::MirrorError;
use super::layout::{self, object_path};
use crate::config::{CopierKind, ResolvedMirrorTarget};
use crate::services::DatabaseHandle;
const MULTIPART_THRESHOLD: u64 = 8 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Default)]
pub struct Copied {
pub objects: u64,
pub bytes: u64,
}
pub struct Copier<'a> {
kind: CopierKind,
parallelism: usize,
rclone: Option<String>,
source: &'a DatabaseHandle,
dest: &'a DatabaseHandle,
}
impl<'a> Copier<'a> {
pub fn new(
settings: &ResolvedMirrorTarget,
rclone: Option<&str>,
source: &'a DatabaseHandle,
dest: &'a DatabaseHandle,
) -> Self {
Self {
kind: settings.copier,
parallelism: settings.copy_parallelism.max(1) as usize,
rclone: rclone.map(String::from),
source,
dest,
}
}
pub async fn plan_compacted(
&self,
candidates: Vec<String>,
seeding: bool,
) -> Result<Vec<String>, MirrorError> {
match self.kind {
CopierKind::Builtin | CopierKind::Rclone => Ok(candidates),
CopierKind::External => {
if seeding {
let present: std::collections::BTreeSet<String> =
layout::list_compacted(self.dest)
.await?
.into_iter()
.map(|(ulid, _)| ulid)
.collect();
Ok(candidates
.into_iter()
.filter(|ulid| !present.contains(ulid))
.collect())
} else {
layout::head_misses(self.dest, candidates, |ulid| layout::compacted_rel(ulid))
.await
}
}
}
}
pub async fn copy(&self, names: &[String]) -> Result<Copied, MirrorError> {
if names.is_empty() {
return Ok(Copied::default());
}
match self.kind {
CopierKind::Builtin | CopierKind::External => self.copy_builtin(names).await,
CopierKind::Rclone => self.copy_rclone(names).await,
}
}
async fn copy_builtin(&self, names: &[String]) -> Result<Copied, MirrorError> {
let bytes = AtomicU64::new(0);
futures::stream::iter(names)
.map(Ok)
.try_for_each_concurrent(self.parallelism, |name| {
let bytes = &bytes;
async move {
let copied = copy_object(self.source, self.dest, name).await?;
bytes.fetch_add(copied, Ordering::Relaxed);
debug!(object = %name, bytes = copied, "copied");
Ok::<(), MirrorError>(())
}
})
.await?;
Ok(Copied {
objects: names.len() as u64,
bytes: bytes.into_inner(),
})
}
async fn copy_rclone(&self, names: &[String]) -> Result<Copied, MirrorError> {
let rclone = self.rclone.as_deref().unwrap_or("rclone");
let source = rclone_remote(&self.source.url)?;
let dest = rclone_remote(&self.dest.url)?;
let list = std::env::temp_dir().join(format!(
"sleet-rclone-{}-{:x}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
tokio::fs::write(&list, names.join("\n") + "\n")
.await
.map_err(|e| MirrorError::Rclone(format!("failed to write files-from list: {e}")))?;
let output = tokio::process::Command::new(rclone)
.arg("copy")
.arg("--files-from")
.arg(&list)
.arg(&source)
.arg(&dest)
.kill_on_drop(true)
.output()
.await;
let _ = tokio::fs::remove_file(&list).await;
let output =
output.map_err(|e| MirrorError::Rclone(format!("failed to run {rclone:?}: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let tail: String = stderr
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("; ");
return Err(MirrorError::Rclone(format!(
"{rclone} exited with {}: {tail}",
output.status
)));
}
Ok(Copied {
objects: names.len() as u64,
bytes: 0,
})
}
}
async fn copy_object(
source: &DatabaseHandle,
dest: &DatabaseHandle,
rel: &str,
) -> Result<u64, MirrorError> {
let get = source.store.get(&object_path(source, rel)).await?;
let size = get.meta.size;
let to = object_path(dest, rel);
if size <= MULTIPART_THRESHOLD {
let bytes = get.bytes().await?;
dest.store.put(&to, bytes.into()).await?;
} else {
let upload = dest.store.put_multipart(&to).await?;
let mut write = WriteMultipart::new(upload);
let mut stream = get.into_stream();
while let Some(chunk) = stream.try_next().await? {
write.wait_for_capacity(8).await?;
write.write(&chunk);
}
write.finish().await?;
}
Ok(size)
}
pub fn rclone_remote(url: &str) -> Result<String, MirrorError> {
let parsed = url::Url::parse(url)
.map_err(|_| MirrorError::Rclone(format!("cannot map {url:?} to an rclone remote")))?;
let host = parsed.host_str().unwrap_or_default();
let path = parsed.path().trim_start_matches('/');
let backend = match parsed.scheme() {
"s3" | "s3a" => ":s3,env_auth",
"gs" => ":gcs,env_auth",
"az" | "azure" | "abfs" | "abfss" | "adl" => ":azureblob,env_auth",
"file" => {
return Ok(parsed
.to_file_path()
.map_err(|_| MirrorError::Rclone(format!("bad file URL {url:?}")))?
.display()
.to_string());
}
other => {
return Err(MirrorError::Rclone(format!(
"no rclone backend for scheme {other:?}"
)));
}
};
Ok(format!("{backend}:{host}/{path}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rclone_remotes_map_by_scheme() {
assert_eq!(
rclone_remote("s3://bucket/a/b").unwrap(),
":s3,env_auth:bucket/a/b"
);
assert_eq!(
rclone_remote("gs://bucket/db").unwrap(),
":gcs,env_auth:bucket/db"
);
assert_eq!(
rclone_remote("az://container/db").unwrap(),
":azureblob,env_auth:container/db"
);
assert_eq!(rclone_remote("file:///tmp/db").unwrap(), "/tmp/db");
assert!(rclone_remote("memory:///db").is_err());
}
}