#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::BTreeMap;
use std::fmt;
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
use diesel::SqliteConnection;
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
use diesel::sql_types::Text;
use super::destination::{DestinationError, ReplicaDestination};
use super::restore::{self, RestoreError, RestoreOutcome};
use super::segment::{self, SegmentError, SegmentHeader, SnapshotMeta};
use super::sqlite::{self, CheckpointOutcome, SqliteError};
use super::status::ReplicationStatus;
use super::wal::{self, ScanCursor, WalError, WalHeader};
use crate::time::{ClockSource, SystemClock};
const SHUTDOWN_POLL: Duration = Duration::from_millis(100);
const MIN_KEPT_GENERATIONS: usize = 2;
pub const FENCE_CHECK_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct ReplicationSettings {
pub database_path: PathBuf,
pub root: String,
pub sync_interval: Duration,
pub snapshot_interval: Duration,
pub max_wal_bytes: u64,
pub retention: Duration,
pub verify_interval: Option<Duration>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ReplicationError {
DatabaseMissing {
path: String,
},
NotWalMode {
mode: String,
},
CheckpointBlocked {
wal_bytes: u64,
},
Wal(WalError),
Segment(SegmentError),
Destination(DestinationError),
Io {
op: &'static str,
detail: String,
},
Sqlite(SqliteError),
Fenced {
epoch: u64,
newest: u64,
},
}
impl fmt::Display for ReplicationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DatabaseMissing { path } => write!(
f,
"the database file {path} does not exist, so there is nothing to replicate"
),
Self::NotWalMode { mode } => write!(
f,
"continuous replication needs WAL journal mode, but the database reports \
{mode:?}. Autumn's SQLite pool sets `journal_mode = WAL`; a read-only or \
in-memory database cannot be replicated."
),
Self::CheckpointBlocked { wal_bytes } => write!(
f,
"could not open a replication generation: the checkpoint it needs first was \
blocked by another connection ({wal_bytes} byte(s) still in the -wal). \
This is transient; the next tick retries."
),
Self::Wal(e) => write!(f, "{e}"),
Self::Segment(e) => write!(f, "{e}"),
Self::Destination(e) => write!(f, "{e}"),
Self::Io { op, detail } => write!(f, "replication {op} failed: {detail}"),
Self::Sqlite(e) => write!(f, "{e}"),
Self::Fenced { epoch, newest } => write!(
f,
"replication stopped: epoch {newest} owns this replica now, and this \
replicator has epoch {epoch}"
),
}
}
}
impl std::error::Error for ReplicationError {}
impl From<WalError> for ReplicationError {
fn from(e: WalError) -> Self {
Self::Wal(e)
}
}
impl From<SegmentError> for ReplicationError {
fn from(e: SegmentError) -> Self {
Self::Segment(e)
}
}
impl From<DestinationError> for ReplicationError {
fn from(e: DestinationError) -> Self {
Self::Destination(e)
}
}
impl From<SqliteError> for ReplicationError {
fn from(e: SqliteError) -> Self {
Self::Sqlite(e)
}
}
impl ReplicationError {
fn io(op: &'static str) -> impl Fn(std::io::Error) -> Self {
move |e| Self::Io {
op,
detail: e.to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TickReport {
pub snapshot_taken: bool,
pub segments: u64,
pub bytes: u64,
pub pending_bytes: u64,
pub checkpointed: bool,
pub index_rotated: bool,
pub pruned_generations: u64,
}
#[derive(Debug)]
struct GenerationState {
id: String,
index: u32,
salt: Option<(u32, u32)>,
page_size: u32,
started_at: DateTime<Utc>,
next_seq: u64,
shipped_offset: u64,
cursor: Option<ScanCursor>,
}
impl GenerationState {
const fn begin_next_index(&mut self) {
self.index = self.index.saturating_add(1);
self.salt = None;
self.page_size = 0;
self.next_seq = 0;
self.shipped_offset = 0;
self.cursor = None;
}
}
#[derive(QueryableByName)]
struct JournalModeRow {
#[diesel(sql_type = Text)]
journal_mode: String,
}
pub struct Replicator {
settings: ReplicationSettings,
destination: Arc<dyn ReplicaDestination>,
status: Arc<ReplicationStatus>,
connection: Option<SqliteConnection>,
state: Option<GenerationState>,
verifying: Arc<AtomicBool>,
clock: Arc<dyn ClockSource>,
epoch: Option<u64>,
fence_checked_at: Option<DateTime<Utc>>,
fenced_by: Option<u64>,
}
impl fmt::Debug for Replicator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Replicator")
.field("settings", &self.settings)
.field("destination", &self.destination.describe())
.field("generation", &self.state.as_ref().map(|s| &s.id))
.finish_non_exhaustive()
}
}
impl Replicator {
#[must_use]
pub fn new(
settings: ReplicationSettings,
destination: Arc<dyn ReplicaDestination>,
status: Arc<ReplicationStatus>,
) -> Self {
Self {
settings,
destination,
status,
connection: None,
state: None,
verifying: Arc::new(AtomicBool::new(false)),
clock: Arc::new(SystemClock),
epoch: None,
fence_checked_at: None,
fenced_by: None,
}
}
#[must_use]
pub fn with_epoch(mut self, epoch: Option<u64>) -> Self {
self.set_epoch(epoch);
self
}
pub fn set_epoch(&mut self, epoch: Option<u64>) {
if self.epoch != epoch {
self.epoch = epoch;
self.state = None;
self.fence_checked_at = None;
self.fenced_by = None;
}
}
#[must_use]
pub const fn fenced_by(&self) -> Option<u64> {
self.fenced_by
}
fn check_fence(&mut self) -> Result<(), ReplicationError> {
let Some(epoch) = self.epoch else {
return Ok(());
};
if let Some(newest) = self.fenced_by {
return Err(ReplicationError::Fenced { epoch, newest });
}
let root = self.settings.root.clone();
let newest = self
.destination
.list(&segment::epochs_prefix(&root))?
.iter()
.filter_map(|key| segment::parse_epoch_marker_key(&root, key))
.max();
self.fence_checked_at = Some(self.clock.now());
match newest {
Some(newest) if newest > epoch => {
self.fenced_by = Some(newest);
Err(ReplicationError::Fenced { epoch, newest })
}
_ => Ok(()),
}
}
fn generation_epoch(&self) -> Result<Option<u64>, ReplicationError> {
if self.epoch.is_some() {
return Ok(self.epoch);
}
let root = &self.settings.root;
Ok(self
.destination
.list(&segment::epochs_prefix(root))?
.iter()
.filter_map(|key| segment::parse_epoch_marker_key(root, key))
.max())
}
fn fence_check_due(&self) -> bool {
if self.fenced_by.is_some() {
return true;
}
self.epoch.is_some()
&& self.fence_checked_at.is_none_or(|at| {
self.clock
.now()
.signed_duration_since(at)
.to_std()
.map_or(true, |age| age >= FENCE_CHECK_INTERVAL)
})
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn ClockSource>) -> Self {
self.clock = clock;
self
}
#[must_use]
pub fn destination(&self) -> &Arc<dyn ReplicaDestination> {
&self.destination
}
pub fn run(mut self, shutdown: &tokio_util::sync::CancellationToken) {
let mut last_verify = self.clock.now();
loop {
let now = self.clock.now();
self.tick_and_record();
if let Some(interval) = self.settings.verify_interval {
let due = now
.signed_duration_since(last_verify)
.to_std()
.is_ok_and(|elapsed| elapsed >= interval);
if due {
last_verify = now;
self.spawn_verification();
}
}
if sleep_or_cancelled(shutdown, self.settings.sync_interval) {
break;
}
}
self.tick_and_record();
}
fn tick_and_record(&mut self) {
match self.tick() {
Ok(report) => {
if report.segments > 0 || report.snapshot_taken {
tracing::debug!(
segments = report.segments,
bytes = report.bytes,
snapshot = report.snapshot_taken,
checkpointed = report.checkpointed,
"SQLite replication tick"
);
}
}
Err(e) => tracing::warn!(error = %e, "SQLite replication tick failed"),
}
}
pub fn tick(&mut self) -> Result<TickReport, ReplicationError> {
let result = self.tick_inner();
if let Err(e) = &result {
self.status
.record_tick_error(e.to_string(), self.clock.now());
}
result
}
fn tick_inner(&mut self) -> Result<TickReport, ReplicationError> {
let db = self.settings.database_path.clone();
if !db.is_file() {
return Err(ReplicationError::DatabaseMissing {
path: db.display().to_string(),
});
}
if self.state.is_some() && self.fence_check_due() {
self.check_fence()?;
}
self.ensure_connection(&db)?;
let wal_path = wal::wal_path(&db);
let wal_len = std::fs::metadata(&wal_path).map_or(0, |m| m.len());
let header = read_wal_header(&wal_path, wal_len)?;
let mut report = TickReport::default();
let mut header = header;
let mut wal_len = wal_len;
if self.needs_new_generation(header.as_ref(), wal_len) {
if self.state.is_some()
&& let Some(pending) = header.as_ref()
{
let shipped = self.ship(&wal_path, pending, wal_len)?;
report.segments = report.segments.saturating_add(shipped.0);
report.bytes = report.bytes.saturating_add(shipped.1);
}
self.start_generation(&db)?;
report.snapshot_taken = true;
wal_len = std::fs::metadata(&wal_path).map_or(0, |m| m.len());
header = read_wal_header(&wal_path, wal_len)?;
}
if let Some(header) = header {
let shipped = self.ship(&wal_path, &header, wal_len)?;
report.segments = shipped.0;
report.bytes = shipped.1;
}
let (pending, caught_up) = self.settled_tail(&wal_path);
report.pending_bytes = pending;
let now = self.clock.now();
let mut index_rotated = false;
report.checkpointed =
self.maybe_checkpoint(&db, &wal_path, pending, now, &mut index_rotated)?;
report.index_rotated = index_rotated;
match self.prune(now) {
Ok(pruned) => report.pruned_generations = pruned,
Err(e) => tracing::warn!(error = %e, "SQLite replica retention sweep failed"),
}
if caught_up {
self.status.record_tick_ok(pending, self.clock.now());
} else {
self.status.record_tick_behind(pending);
}
Ok(report)
}
fn settled_tail(&self, wal_path: &Path) -> (u64, bool) {
let Some(state) = self.state.as_ref() else {
return (0, true);
};
let wal_len = std::fs::metadata(wal_path).map_or(0, |m| m.len());
let pending = wal_len.saturating_sub(state.shipped_offset);
if pending == 0 {
return (0, true);
}
let (Ok(Some(header)), Some(cursor)) = (read_wal_header(wal_path, wal_len), state.cursor)
else {
return (pending, true);
};
if state.salt != Some(header.salt()) {
return (pending, true);
}
let Ok(buffer) = read_range(wal_path, state.shipped_offset, wal_len) else {
return (pending, true);
};
let scan_at = usize::try_from(cursor.offset.saturating_sub(state.shipped_offset))
.unwrap_or(usize::MAX);
let committed = wal::scan_from(&header, &cursor, buffer.get(scan_at..).unwrap_or(&[]))
.is_ok_and(|outcome| outcome.last_commit_end > state.shipped_offset);
(pending, !committed)
}
fn maybe_checkpoint(
&mut self,
db: &Path,
wal_path: &Path,
pending: u64,
now: DateTime<Utc>,
index_rotated: &mut bool,
) -> Result<bool, ReplicationError> {
let expired = self.generation_expired(now);
let over_budget = self
.state
.as_ref()
.is_some_and(|state| state.shipped_offset >= self.settings.max_wal_bytes);
if pending != 0 || !(over_budget || expired) {
return Ok(false);
}
let Some(shipped_offset) = self.state.as_ref().map(|state| state.shipped_offset) else {
return Ok(false);
};
if shipped_offset == 0 {
if expired {
self.state = None;
}
return Ok(false);
}
let Some(conn) = self.connection.as_mut() else {
return Ok(false);
};
let before = sqlite::data_version(conn)?;
if std::fs::metadata(wal_path).map_or(0, |m| m.len()) != shipped_offset {
return Ok(false);
}
if sqlite::checkpoint_truncate(conn, db)? != CheckpointOutcome::Truncated {
return Ok(false);
}
let raced = sqlite::data_version(conn)? != before;
if expired || raced {
if raced {
tracing::info!(
"a write landed while the SQLite WAL was being checkpointed; taking a \
fresh base snapshot rather than assuming it was replicated"
);
}
self.state = None;
} else if let Some(state) = self.state.as_mut() {
state.begin_next_index();
*index_rotated = true;
}
Ok(true)
}
pub fn verify(&self) -> Result<RestoreOutcome, RestoreError> {
verify_replica(
self.destination.as_ref(),
&self.settings.root,
&self.settings.database_path,
)
}
fn spawn_verification(&self) {
if self.verifying.swap(true, Ordering::SeqCst) {
tracing::warn!(
"skipping a SQLite replica verification: the previous one is still running"
);
return;
}
let destination = Arc::clone(&self.destination);
let status = Arc::clone(&self.status);
let verifying = Arc::clone(&self.verifying);
let root = self.settings.root.clone();
let database_path = self.settings.database_path.clone();
let clock = Arc::clone(&self.clock);
let spawned = std::thread::Builder::new()
.name("autumn-replica-verify".to_owned())
.spawn(move || {
let _guard = InFlightGuard(verifying);
let result = verify_replica(destination.as_ref(), &root, &database_path)
.map(|_| ())
.map_err(|e| e.to_string());
if let Err(detail) = &result {
tracing::error!(
error = %detail,
"SQLite replica verification FAILED — the replica may not be restorable"
);
}
status.record_verification(result, clock.now());
});
if let Err(e) = spawned {
tracing::warn!("could not start the SQLite replica verification thread: {e}");
self.verifying.store(false, Ordering::SeqCst);
}
}
fn ensure_connection(&mut self, db: &Path) -> Result<(), ReplicationError> {
if self.connection.is_some() {
return Ok(());
}
let mut conn = sqlite::open(db)?;
let mode = journal_mode(&mut conn)?;
if !mode.eq_ignore_ascii_case("wal") {
conn.batch_execute("PRAGMA journal_mode = WAL;")
.map_err(|e| {
ReplicationError::Sqlite(SqliteError::Query {
op: "journal_mode = WAL",
detail: e.to_string(),
})
})?;
let mode = journal_mode(&mut conn)?;
if !mode.eq_ignore_ascii_case("wal") {
return Err(ReplicationError::NotWalMode { mode });
}
}
self.connection = Some(conn);
Ok(())
}
fn needs_new_generation(&self, header: Option<&WalHeader>, wal_len: u64) -> bool {
match (&self.state, header) {
(None, _) => true,
(Some(state), Some(header)) => {
state.salt.is_some_and(|salt| salt != header.salt())
|| wal_len < state.shipped_offset
}
(Some(state), None) => state.shipped_offset > 0 || wal_len < state.shipped_offset,
}
}
fn newest_generation_ms(&self) -> Result<Option<i64>, ReplicationError> {
let keys = self
.destination
.list(&segment::generations_prefix(&self.settings.root))?;
Ok(keys
.iter()
.filter_map(|key| segment::generation_of_key(&self.settings.root, key))
.filter_map(|id| segment::parse_generation_id(&id))
.map(|info| info.created_ms)
.max())
}
fn start_generation(&mut self, db: &Path) -> Result<(), ReplicationError> {
self.check_fence()?;
if let Some(epoch) = self.epoch {
self.destination.put(
&segment::epoch_marker_key(&self.settings.root, epoch),
b"{}",
)?;
}
if let Some(conn) = self.connection.as_mut() {
let outcome = sqlite::checkpoint_truncate(conn, db)?;
if let CheckpointOutcome::Busy { wal_bytes } = outcome {
return Err(ReplicationError::CheckpointBlocked { wal_bytes });
}
}
let now = self.clock.now();
let floor_ms = self.newest_generation_ms()?;
let created_ms = now
.timestamp_millis()
.max(floor_ms.map_or(i64::MIN, |ms| ms.saturating_add(1)));
let id = segment::generation_id(created_ms, rand::random::<u64>());
if let Some(epoch) = self.generation_epoch()? {
self.destination.put(
&segment::generation_epoch_key(&self.settings.root, &id, epoch),
b"{}",
)?;
}
let staging = staging_path(db);
let (sha256, uncompressed_len) = gzip_file(db, &staging)?;
let upload = self
.destination
.put_file(&segment::snapshot_key(&self.settings.root, &id), &staging);
let _ = std::fs::remove_file(&staging);
upload?;
let meta = SnapshotMeta {
version: segment::LAYOUT_VERSION,
generation: id.clone(),
created_at: now.to_rfc3339(),
created_ms,
sha256,
uncompressed_len,
};
let body = serde_json::to_vec(&meta).map_err(|e| ReplicationError::Io {
op: "encode snapshot metadata",
detail: e.to_string(),
})?;
self.destination
.put(&segment::snapshot_meta_key(&self.settings.root, &id), &body)?;
tracing::info!(
generation = %id,
bytes = uncompressed_len,
destination = %self.destination.describe(),
"opened a new SQLite replication generation"
);
self.status.record_generation(&id, now);
self.state = Some(GenerationState {
id,
index: 0,
salt: None,
page_size: 0,
started_at: now,
next_seq: 0,
shipped_offset: 0,
cursor: None,
});
Ok(())
}
fn ship(
&mut self,
wal_path: &Path,
header: &WalHeader,
wal_len: u64,
) -> Result<(u64, u64), ReplicationError> {
let root = self.settings.root.clone();
let clock = Arc::clone(&self.clock);
let Some(state) = self.state.as_mut() else {
return Ok((0, 0));
};
if state.salt.is_none() {
state.salt = Some(header.salt());
state.page_size = header.page_size;
state.cursor = Some(ScanCursor::start(header));
}
if state.salt != Some(header.salt()) {
return Ok((0, 0));
}
let Some(cursor) = state.cursor else {
return Ok((0, 0));
};
if wal_len <= cursor.offset {
return Ok((0, 0));
}
let window_end = wal_len.min(
state
.shipped_offset
.saturating_add(segment::MAX_SEGMENT_BYTES),
);
let buffer = read_range(wal_path, state.shipped_offset, window_end)?;
let scan_at = usize::try_from(cursor.offset.saturating_sub(state.shipped_offset))
.unwrap_or(usize::MAX);
let outcome = wal::scan_from(header, &cursor, buffer.get(scan_at..).unwrap_or(&[]))?;
if outcome.last_commit_end <= cursor.offset {
return Ok((0, 0));
}
let take = usize::try_from(outcome.last_commit_end.saturating_sub(state.shipped_offset))
.unwrap_or(usize::MAX);
let raw = buffer.get(..take).ok_or_else(|| ReplicationError::Io {
op: "slice the WAL range",
detail: format!(
"the -wal shrank while it was being read (wanted {take} bytes, have {})",
buffer.len()
),
})?;
let now = clock.now();
let segment_header = SegmentHeader {
version: segment::LAYOUT_VERSION,
index: state.index,
seq: state.next_seq,
start_offset: state.shipped_offset,
end_offset: outcome.last_commit_end,
frame_count: outcome.frames,
commit_count: outcome.commits,
page_size: header.page_size,
db_size_pages: outcome.last_commit_db_pages,
salt1: header.salt1,
salt2: header.salt2,
created_at: now.to_rfc3339(),
created_ms: now.timestamp_millis(),
sha256: segment::sha256_hex(raw),
uncompressed_len: raw.len() as u64,
};
let payload = segment::encode_segment(&segment_header, raw)?;
let key = segment::segment_key(
&root,
&state.id,
state.index,
state.next_seq,
segment_header.created_ms,
);
self.destination.put(&key, &payload)?;
let head = segment::GenerationHead {
version: segment::LAYOUT_VERSION,
index: state.index,
seq: state.next_seq,
created_ms: segment_header.created_ms,
};
let head_body = serde_json::to_vec(&head).map_err(|e| ReplicationError::Io {
op: "encode the generation head",
detail: e.to_string(),
})?;
self.destination
.put(&segment::head_key(&root, &state.id), &head_body)?;
let bytes = raw.len() as u64;
state.shipped_offset = outcome.last_commit_end;
state.cursor = Some(outcome.last_commit_cursor);
state.next_seq = state.next_seq.saturating_add(1);
self.status.record_segment(bytes, now);
Ok((1, bytes))
}
fn generation_expired(&self, now: DateTime<Utc>) -> bool {
self.state.as_ref().is_some_and(|state| {
let elapsed = now.signed_duration_since(state.started_at);
elapsed
.to_std()
.map_or(true, |age| age >= self.settings.snapshot_interval)
})
}
fn prune(&self, now: DateTime<Utc>) -> Result<u64, ReplicationError> {
let Ok(retention) = chrono::Duration::from_std(self.settings.retention) else {
return Ok(0);
};
let cutoff_ms = now
.checked_sub_signed(retention)
.unwrap_or(now)
.timestamp_millis();
let keys = self
.destination
.list(&segment::generations_prefix(&self.settings.root))?;
let open_generation = self.state.as_ref().map(|state| state.id.clone());
let droppable = prunable(
&self.settings.root,
keys,
cutoff_ms,
open_generation.as_deref(),
);
let mut pruned: u64 = 0;
for (id, keys) in &droppable {
let marker = segment::snapshot_meta_key(&self.settings.root, id);
if keys.contains(&marker) {
self.destination.delete(&marker)?;
}
for key in keys {
if *key == marker {
continue;
}
self.destination.delete(key)?;
}
pruned = pruned.saturating_add(1);
tracing::debug!(generation = %id, "pruned an expired SQLite replication generation");
}
if pruned > MIN_KEPT_GENERATIONS as u64 {
tracing::warn!(
pruned,
"the SQLite replica retention sweep removed an unusual number of generations \
at once — check the host clock if this was not expected"
);
}
Ok(pruned)
}
}
fn prunable(
root: &str,
keys: Vec<String>,
cutoff_ms: i64,
open: Option<&str>,
) -> Vec<(String, Vec<String>)> {
let epochs = restore::generation_epochs(root, &keys);
let mut generations: BTreeMap<(u64, i64, String), Vec<String>> = BTreeMap::new();
let mut complete: Vec<(u64, i64, String)> = Vec::new();
for key in keys {
let Some(id) = segment::generation_of_key(root, &key) else {
continue;
};
let Some(info) = segment::parse_generation_id(&id) else {
continue;
};
let epoch = epochs.get(&id).copied().unwrap_or(0);
if key.ends_with(segment::SNAPSHOT_META_OBJECT) {
complete.push((epoch, info.created_ms, id.clone()));
}
generations
.entry((epoch, info.created_ms, id))
.or_default()
.push(key);
}
complete.sort_unstable();
let Some(floor) = complete
.iter()
.rev()
.find(|(_, ms, _)| *ms <= cutoff_ms)
.cloned()
else {
return Vec::new();
};
let keep_newest: std::collections::BTreeSet<&(u64, i64, String)> =
complete.iter().rev().take(MIN_KEPT_GENERATIONS).collect();
let is_complete: std::collections::BTreeSet<&str> =
complete.iter().map(|(_, _, id)| id.as_str()).collect();
generations
.into_iter()
.filter(|(order, _)| {
let (_, ms, id) = order;
*order < floor
&& !keep_newest.contains(order)
&& open != Some(id.as_str())
&& (is_complete.contains(id.as_str()) || *ms <= floor.1)
})
.map(|((_, _, id), keys)| (id, keys))
.collect()
}
struct InFlightGuard(Arc<AtomicBool>);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
fn verify_replica(
destination: &dyn ReplicaDestination,
root: &str,
database_path: &Path,
) -> Result<RestoreOutcome, RestoreError> {
let parent = database_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let scratch = parent.join(format!(
".autumn-replica-verify-{}-{:016x}",
std::process::id(),
rand::random::<u64>()
));
if let Err(e) = create_private_dir(&scratch) {
return Err(RestoreError::Io {
op: "create the verification scratch directory",
detail: e.to_string(),
});
}
let outcome = restore::restore(destination, root, None, &scratch.join("verified.db"));
let _ = std::fs::remove_dir_all(&scratch);
outcome
}
fn create_private_dir(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(path)
}
}
fn journal_mode(conn: &mut SqliteConnection) -> Result<String, ReplicationError> {
let rows: Vec<JournalModeRow> = diesel::sql_query("PRAGMA journal_mode")
.load(conn)
.map_err(|e| {
ReplicationError::Sqlite(SqliteError::Query {
op: "journal_mode",
detail: e.to_string(),
})
})?;
Ok(rows
.first()
.map_or_else(|| "unknown".to_owned(), |row| row.journal_mode.clone()))
}
fn read_wal_header(wal_path: &Path, wal_len: u64) -> Result<Option<WalHeader>, ReplicationError> {
if wal_len < wal::WAL_HEADER_SIZE as u64 {
return Ok(None);
}
let mut file =
std::fs::File::open(wal_path).map_err(ReplicationError::io("open the -wal file"))?;
let mut buf = [0u8; wal::WAL_HEADER_SIZE];
file.read_exact(&mut buf)
.map_err(ReplicationError::io("read the -wal header"))?;
match WalHeader::parse(&buf) {
Ok(header) => Ok(Some(header)),
Err(WalError::HeaderChecksum | WalError::TooShort { .. }) => Ok(None),
Err(e) => Err(ReplicationError::Wal(e)),
}
}
fn read_range(path: &Path, start: u64, end: u64) -> Result<Vec<u8>, ReplicationError> {
let len = end.saturating_sub(start);
let mut file = std::fs::File::open(path).map_err(ReplicationError::io("open the -wal file"))?;
file.seek(SeekFrom::Start(start))
.map_err(ReplicationError::io("seek the -wal file"))?;
let mut buffer = Vec::new();
file.take(len)
.read_to_end(&mut buffer)
.map_err(ReplicationError::io("read the -wal file"))?;
Ok(buffer)
}
fn staging_path(db: &Path) -> PathBuf {
let mut name = db.as_os_str().to_os_string();
name.push(format!(".autumn-replica-{}.gz", std::process::id()));
PathBuf::from(name)
}
fn gzip_file(source: &Path, target: &Path) -> Result<(String, u64), ReplicationError> {
use sha2::{Digest as _, Sha256};
let mut input =
std::fs::File::open(source).map_err(ReplicationError::io("open the database file"))?;
let output =
std::fs::File::create(target).map_err(ReplicationError::io("create the snapshot"))?;
let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::fast());
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 256 * 1024];
let mut total: u64 = 0;
loop {
let read = input
.read(&mut buffer)
.map_err(ReplicationError::io("read the database file"))?;
if read == 0 {
break;
}
let chunk = buffer.get(..read).unwrap_or(&[]);
hasher.update(chunk);
encoder
.write_all(chunk)
.map_err(ReplicationError::io("compress the snapshot"))?;
total = total.saturating_add(read as u64);
}
let file = encoder
.finish()
.map_err(ReplicationError::io("finish the snapshot"))?;
file.sync_all()
.map_err(ReplicationError::io("flush the snapshot"))?;
Ok((hex::encode(hasher.finalize()), total))
}
#[allow(
clippy::disallowed_methods,
reason = "the deadline paces a real thread sleep, and a virtual clock does not move during it"
)]
fn sleep_or_cancelled(shutdown: &tokio_util::sync::CancellationToken, total: Duration) -> bool {
let start = std::time::Instant::now();
let deadline = start.checked_add(total).unwrap_or(start);
loop {
if shutdown.is_cancelled() {
return true;
}
let now = std::time::Instant::now();
if now >= deadline {
return false;
}
std::thread::sleep(SHUTDOWN_POLL.min(deadline.saturating_duration_since(now)));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(id: &str, epoch: Option<u64>, complete: bool) -> Vec<String> {
let mut keys = vec![segment::snapshot_key("r", id)];
if let Some(epoch) = epoch {
keys.push(segment::generation_epoch_key("r", id, epoch));
}
if complete {
keys.push(segment::snapshot_meta_key("r", id));
}
keys
}
fn pruned(all: &[Vec<String>], cutoff_ms: i64, open: Option<&str>) -> Vec<String> {
let mut ids: Vec<String> = prunable("r", all.concat(), cutoff_ms, open)
.into_iter()
.map(|(id, _)| id)
.collect();
ids.sort();
ids
}
#[test]
fn retention_orders_by_epoch_then_time() {
let id = segment::generation_id;
let (old, stale, owner) = (id(1_000, 1), id(5_000, 2), id(2_000, 3));
let (newest, kept) = (id(6_000, 4), id(7_000, 5));
let all = vec![
keys(&old, Some(1), true),
keys(&stale, Some(1), true),
keys(&owner, Some(2), true),
keys(&newest, Some(2), true),
keys(&kept, Some(2), true),
];
assert_eq!(pruned(&all, 5_500, None), vec![old, stale]);
}
#[test]
fn retention_keeps_an_upload_in_progress_newer_than_the_floor() {
let id = segment::generation_id;
let (floor, uploading) = (id(1_000, 1), id(9_000, 2));
let (later, latest) = (id(10_000, 3), id(11_000, 4));
let all = vec![
keys(&floor, Some(1), true),
keys(&uploading, None, false),
keys(&later, Some(1), true),
keys(&latest, Some(1), true),
];
assert!(pruned(&all, 5_000, None).is_empty());
let (old, mid) = (id(1_000, 1), id(2_000, 2));
let (recent, newest) = (id(9_000, 3), id(9_500, 4));
let all = vec![
keys(&old, None, true),
keys(&mid, None, true),
keys(&recent, None, true),
keys(&newest, None, true),
];
assert_eq!(pruned(&all, 5_000, None), vec![old.clone()]);
assert!(
pruned(&all, 5_000, Some(&old)).is_empty(),
"the open generation stays"
);
}
#[test]
fn staging_path_sits_next_to_the_database() {
let path = staging_path(Path::new("/srv/app.db"));
assert_eq!(path.parent(), Some(Path::new("/srv")));
assert!(
path.to_string_lossy()
.starts_with("/srv/app.db.autumn-replica-"),
"{}",
path.display()
);
}
#[test]
fn gzip_file_reports_the_uncompressed_digest_and_length() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("app.db");
let payload = b"autumn".repeat(1024);
std::fs::write(&source, &payload).expect("write");
let target = dir.path().join("app.db.gz");
let (digest, len) = gzip_file(&source, &target).expect("gzip");
assert_eq!(len, payload.len() as u64);
assert_eq!(digest, segment::sha256_hex(&payload));
let mut inflated = Vec::new();
let file = std::fs::File::open(&target).expect("open gz");
std::io::copy(&mut flate2::read::GzDecoder::new(file), &mut inflated).expect("inflate");
assert_eq!(inflated, payload);
}
#[test]
fn read_range_returns_exactly_the_requested_window() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("wal");
std::fs::write(&path, (0u8..=255).collect::<Vec<_>>()).expect("write");
assert_eq!(
read_range(&path, 10, 20).expect("read"),
(10u8..20).collect::<Vec<_>>()
);
assert_eq!(read_range(&path, 250, 300).expect("read").len(), 6);
}
#[test]
fn sleep_or_cancelled_returns_immediately_when_already_cancelled() {
let token = tokio_util::sync::CancellationToken::new();
token.cancel();
let start = std::time::Instant::now();
assert!(sleep_or_cancelled(&token, Duration::from_secs(30)));
assert!(start.elapsed() < Duration::from_secs(1));
}
#[test]
fn read_wal_header_treats_a_short_wal_as_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("app.db-wal");
std::fs::write(&path, [0u8; 8]).expect("write");
assert!(read_wal_header(&path, 8).expect("read").is_none());
}
}