use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::sync::{Mutex, Notify, watch};
use tokio::task::JoinHandle;
use crate::database::{Database, ReplicaStats};
use crate::error::{Error, Result};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Checkpoint {
Never,
#[default]
AfterPush,
WalAbove(u64),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SyncPolicy {
pub interval: Duration,
pub checkpoint: Checkpoint,
pub pull_only: bool,
pub max_backoff: Duration,
}
impl Default for SyncPolicy {
fn default() -> Self {
Self {
interval: Duration::from_secs(30),
checkpoint: Checkpoint::AfterPush,
pull_only: false,
max_backoff: Duration::from_mins(5),
}
}
}
impl SyncPolicy {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
#[must_use]
pub fn checkpoint(mut self, checkpoint: Checkpoint) -> Self {
self.checkpoint = checkpoint;
self
}
#[must_use]
pub fn pull_only(mut self, pull_only: bool) -> Self {
self.pull_only = pull_only;
self
}
#[must_use]
pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
self.max_backoff = max_backoff;
self
}
fn wait_after(&self, failures: u32) -> Duration {
if failures == 0 {
return self.interval;
}
let factor = 2_u32.saturating_pow(failures.min(16));
self.interval.saturating_mul(factor).min(self.max_backoff)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SyncStatus {
pub rounds: u64,
pub consecutive_failures: u32,
pub last_round: Option<SystemTime>,
pub last_success: Option<SystemTime>,
pub pulled: bool,
pub checkpointed: bool,
pub last_error: Option<String>,
pub stats: Option<ReplicaStats>,
}
struct Round {
pulled: bool,
checkpointed: bool,
stats: ReplicaStats,
}
struct Shared {
db: Database,
policy: SyncPolicy,
round: Mutex<()>,
status: watch::Sender<SyncStatus>,
stop: Notify,
}
#[must_use = "dropping the handle stops the synchronisation task"]
pub struct ReplicaSync {
shared: Arc<Shared>,
task: JoinHandle<()>,
}
impl std::fmt::Debug for ReplicaSync {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReplicaSync")
.field("policy", &self.shared.policy)
.field("status", &*self.shared.status.borrow())
.finish_non_exhaustive()
}
}
impl ReplicaSync {
pub fn spawn(db: Database, policy: SyncPolicy) -> Result<Self> {
if !db.is_replica() {
return Err(Error::InvalidOptions("not an embedded replica".into()));
}
if !db.claim_sync_task() {
return Err(Error::Misuse(
"a ReplicaSync already runs on this database".into(),
));
}
let (status, _) = watch::channel(SyncStatus::default());
let shared = Arc::new(Shared {
db,
policy,
round: Mutex::new(()),
status,
stop: Notify::new(),
});
let task = tokio::spawn(run(Arc::clone(&shared)));
tracing::info!(
interval = ?shared.policy.interval,
checkpoint = ?shared.policy.checkpoint,
pull_only = shared.policy.pull_only,
"replica sync started"
);
Ok(Self { shared, task })
}
pub fn policy(&self) -> &SyncPolicy {
&self.shared.policy
}
pub fn status(&self) -> SyncStatus {
self.shared.status.borrow().clone()
}
pub fn subscribe(&self) -> watch::Receiver<SyncStatus> {
self.shared.status.subscribe()
}
pub async fn trigger(&self) -> Result<SyncStatus> {
round(&self.shared).await?;
Ok(self.status())
}
pub async fn shutdown(mut self) -> Result<()> {
self.shared.stop.notify_one();
let _ = (&mut self.task).await;
if !self.shared.policy.pull_only {
self.shared.db.push().await?;
}
tracing::info!("replica sync shut down");
Ok(())
}
}
impl Drop for ReplicaSync {
fn drop(&mut self) {
self.task.abort();
self.shared.db.release_sync_task();
}
}
async fn run(shared: Arc<Shared>) {
loop {
let failures = shared.status.borrow().consecutive_failures;
let wait = shared.policy.wait_after(failures);
if tokio::time::timeout(wait, shared.stop.notified())
.await
.is_ok()
{
break;
}
if let Err(err) = round(&shared).await {
tracing::warn!(%err, failures = failures + 1, "replica sync round failed");
}
}
tracing::info!("replica sync stopped");
}
async fn round(shared: &Shared) -> Result<()> {
let _one_at_a_time = shared.round.lock().await;
let outcome = perform(shared).await;
let now = SystemTime::now();
shared.status.send_modify(|status| {
status.rounds += 1;
status.last_round = Some(now);
match &outcome {
Ok(round) => {
status.consecutive_failures = 0;
status.last_success = Some(now);
status.pulled = round.pulled;
status.checkpointed = round.checkpointed;
status.last_error = None;
status.stats = Some(round.stats.clone());
}
Err(err) => {
status.consecutive_failures += 1;
status.last_error = Some(err.to_string());
}
}
});
outcome.map(|_| ())
}
async fn perform(shared: &Shared) -> Result<Round> {
let db = &shared.db;
let policy = &shared.policy;
let pushed = if policy.pull_only {
false
} else {
db.push().await?;
true
};
let pulled = db.pull().await?;
let mut stats = db.stats().await?;
let checkpointed = match policy.checkpoint {
Checkpoint::Never => false,
Checkpoint::AfterPush => pushed,
Checkpoint::WalAbove(limit) => stats.wal_bytes > limit,
};
if checkpointed {
db.checkpoint().await?;
stats = db.stats().await?;
}
tracing::debug!(
pulled,
checkpointed,
pending = stats.pending_operations,
"replica sync round done"
);
Ok(Round {
pulled,
checkpointed,
stats,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::ConnectOptions;
#[test]
fn wait_backs_off_up_to_the_cap() {
let policy = SyncPolicy::new()
.interval(Duration::from_secs(10))
.max_backoff(Duration::from_mins(1));
assert_eq!(policy.wait_after(0), Duration::from_secs(10));
assert_eq!(policy.wait_after(1), Duration::from_secs(20));
assert_eq!(policy.wait_after(2), Duration::from_secs(40));
assert_eq!(policy.wait_after(3), Duration::from_mins(1));
assert_eq!(policy.wait_after(40), Duration::from_mins(1));
}
#[tokio::test]
async fn spawn_needs_a_replica() {
let db = Database::connect(ConnectOptions::in_memory())
.await
.expect("open");
let err = ReplicaSync::spawn(db, SyncPolicy::new()).expect_err("not a replica");
assert!(matches!(err, Error::InvalidOptions(_)), "{err:?}");
}
}