pub mod copier;
pub mod layout;
pub mod pass;
pub mod prune;
pub mod restore;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::config::{MirrorMode, ResolvedMirror, ResolvedMirrorTarget};
use crate::registry;
use crate::services::DatabaseHandle;
pub use pass::{PassOutcome, sync_pass};
pub use prune::{PruneReport, prune};
pub use restore::{RestoreOutcome, RestorePoint, restore};
const IDLE_POLL_MAX: Duration = Duration::from_secs(300);
const IDLE_PRUNE_EVERY: Duration = Duration::from_secs(600);
#[derive(Debug, thiserror::Error)]
pub enum MirrorError {
#[error(transparent)]
Url(#[from] registry::UrlError),
#[error("object store error: {0}")]
Store(#[from] object_store::Error),
#[error(transparent)]
SlateDb(#[from] slatedb::Error),
#[error("{url} is not a SlateDB database (no manifest)")]
NotADatabase {
url: String,
},
#[error("{url} cannot be a mirror source: {reason}")]
ExcludedSource {
url: String,
reason: String,
},
#[error(
"destination {destination} has diverged: its manifest {dest_id} is ahead of the \
source's {source_id}; it is not a mirror of this source"
)]
Diverged {
destination: String,
dest_id: u64,
source_id: u64,
},
#[error("mirror pass for target {target} kept restarting: {reason}")]
Stalled {
target: String,
reason: String,
},
#[error("rclone: {0}")]
Rclone(String),
#[error("destination {url} is not empty; restore never deletes, pick a fresh root")]
DestinationNotEmpty {
url: String,
},
#[error("no restore point for --at {at}: {reason}")]
NoRestorePoint {
at: String,
reason: String,
},
}
#[derive(Clone, Debug)]
pub struct AppliedTarget {
pub name: String,
pub destination: String,
pub settings: ResolvedMirrorTarget,
}
pub fn applied_targets(db_url: &str, mirror: &ResolvedMirror) -> Vec<AppliedTarget> {
mirror
.targets
.iter()
.filter_map(|(name, settings)| {
let destination = destination_for(db_url, settings)?;
Some(AppliedTarget {
name: name.clone(),
destination,
settings: settings.clone(),
})
})
.collect()
}
fn destination_for(db_url: &str, settings: &ResolvedMirrorTarget) -> Option<String> {
if settings.disabled {
return None;
}
let url = registry::canonicalize_url(settings.url.as_deref()?).ok()?;
let destination = match &settings.source_prefix {
None => url,
Some(prefix) => {
let prefix = registry::canonicalize_url(prefix).ok()?;
let rest = db_url.strip_prefix(&prefix)?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
format!("{url}{rest}")
}
};
(destination != db_url).then_some(destination)
}
pub async fn run_mirror(
source: &DatabaseHandle,
dest: &DatabaseHandle,
target: &AppliedTarget,
jobs: Arc<tokio::sync::Semaphore>,
rclone: Option<String>,
token: CancellationToken,
) -> Result<(), MirrorError> {
info!(
database = %source.url,
target = %target.name,
destination = %dest.url,
mode = ?target.settings.mode,
"mirror task starting"
);
match target.settings.mode {
MirrorMode::Continuous => {
run_continuous(source, dest, target, jobs, rclone.as_deref(), token).await
}
MirrorMode::Periodic => {
run_periodic(source, dest, target, jobs, rclone.as_deref(), token).await
}
}
}
pub async fn sync_once(
source: &DatabaseHandle,
dest: &DatabaseHandle,
target: &AppliedTarget,
rclone: Option<&str>,
) -> Result<(PassOutcome, PruneReport), MirrorError> {
let outcome = sync_pass(source, dest, &target.name, &target.settings, rclone).await?;
let report = prune(source, dest, &target.name, &target.settings).await?;
Ok((outcome, report))
}
async fn run_continuous(
source: &DatabaseHandle,
dest: &DatabaseHandle,
target: &AppliedTarget,
jobs: Arc<tokio::sync::Semaphore>,
rclone: Option<&str>,
token: CancellationToken,
) -> Result<(), MirrorError> {
let settings = &target.settings;
let max_idle = IDLE_POLL_MAX.max(settings.poll);
let mut idle = settings.poll;
let mut watermark: Option<u64> = None;
let mut tail: Option<pass::Tail> = None;
let mut last_prune = Instant::now();
loop {
if token.is_cancelled() {
return Ok(());
}
let mut active = false;
let source_head = layout::max_manifest_id(source).await?;
if source_head.is_some() && source_head != watermark {
let _permit = tokio::select! {
_ = token.cancelled() => return Ok(()),
permit = jobs.clone().acquire_owned() => permit.expect("semaphore never closes"),
};
let outcome = sync_pass(source, dest, &target.name, settings, rclone).await?;
active |= outcome.committed;
watermark = Some(outcome.head);
match &mut tail {
Some(tail) => tail.advance_floor(outcome.next_wal_sst_id),
None => tail = Some(pass::Tail::start(dest, outcome.next_wal_sst_id).await?),
}
if outcome.committed || last_prune.elapsed() >= IDLE_PRUNE_EVERY {
prune(source, dest, &target.name, settings).await?;
last_prune = Instant::now();
}
} else if settings.keep.is_some() && last_prune.elapsed() >= IDLE_PRUNE_EVERY {
let _permit = tokio::select! {
_ = token.cancelled() => return Ok(()),
permit = jobs.clone().acquire_owned() => permit.expect("semaphore never closes"),
};
prune(source, dest, &target.name, settings).await?;
last_prune = Instant::now();
}
if let Some(tail) = &mut tail {
active |= tail.step(source, dest).await? > 0;
}
idle = if active {
settings.poll
} else {
(idle * 2).min(max_idle)
};
tokio::select! {
_ = token.cancelled() => return Ok(()),
_ = tokio::time::sleep(idle) => {}
}
}
}
async fn run_periodic(
source: &DatabaseHandle,
dest: &DatabaseHandle,
target: &AppliedTarget,
jobs: Arc<tokio::sync::Semaphore>,
rclone: Option<&str>,
token: CancellationToken,
) -> Result<(), MirrorError> {
let settings = &target.settings;
let interval = chrono::Duration::from_std(settings.interval).expect("interval fits");
let check = (settings.interval / 20).clamp(Duration::from_secs(60), Duration::from_secs(3600));
loop {
if token.is_cancelled() {
return Ok(());
}
let latest = layout::list_manifests(dest).await?.pop();
let age = latest.map(|(_, meta)| chrono::Utc::now() - meta.last_modified);
let sleep = match age {
Some(age) if age < interval => (interval - age)
.to_std()
.unwrap_or(settings.interval)
.min(settings.interval),
_ => {
let _permit = tokio::select! {
_ = token.cancelled() => return Ok(()),
permit = jobs.clone().acquire_owned() => permit.expect("semaphore never closes"),
};
let (outcome, _) = sync_once(source, dest, target, rclone).await?;
if outcome.committed {
settings.interval
} else {
check.min(settings.interval)
}
}
};
tokio::select! {
_ = token.cancelled() => return Ok(()),
_ = tokio::time::sleep(sleep) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ResolvedMirrorTarget;
fn target(url: &str, prefix: Option<&str>) -> ResolvedMirrorTarget {
ResolvedMirrorTarget {
url: Some(url.to_string()),
source_prefix: prefix.map(String::from),
..ResolvedMirrorTarget::default()
}
}
#[test]
fn prefix_targets_map_and_scope() {
let t = target("s3://dr-bucket/mirrors", Some("s3://user-data"));
assert_eq!(
destination_for("s3://user-data/tenant1/db1", &t).as_deref(),
Some("s3://dr-bucket/mirrors/tenant1/db1")
);
assert_eq!(destination_for("s3://user-database/x", &t), None);
assert_eq!(
destination_for("s3://user-data", &t).as_deref(),
Some("s3://dr-bucket/mirrors")
);
}
#[test]
fn exact_disabled_and_self_targets() {
let t = target("s3://backup/db1", None);
assert_eq!(
destination_for("s3://data/db1", &t).as_deref(),
Some("s3://backup/db1")
);
let off = ResolvedMirrorTarget {
disabled: true,
..t.clone()
};
assert_eq!(destination_for("s3://data/db1", &off), None);
assert_eq!(destination_for("s3://backup/db1", &t), None, "self");
let no_url = ResolvedMirrorTarget {
url: None,
..ResolvedMirrorTarget::default()
};
assert_eq!(destination_for("s3://data/db1", &no_url), None);
}
#[test]
fn applied_targets_filter_and_map() {
let mut mirror = ResolvedMirror::default();
mirror
.targets
.insert("dr".into(), target("s3://dr/mirrors", Some("s3://data")));
mirror.targets.insert(
"off".into(),
ResolvedMirrorTarget {
disabled: true,
..target("s3://elsewhere", None)
},
);
mirror
.targets
.insert("other".into(), target("s3://x", Some("s3://other-bucket")));
let applied = applied_targets("s3://data/db1", &mirror);
assert_eq!(applied.len(), 1);
assert_eq!(applied[0].name, "dr");
assert_eq!(applied[0].destination, "s3://dr/mirrors/db1");
}
}