pub mod destination;
pub mod engine;
pub mod restore;
#[cfg(feature = "http-client")]
pub mod s3;
pub mod segment;
pub mod sqlite;
pub mod status;
pub mod uri;
pub mod wal;
pub use uri::{DatabaseTarget, database_target, file_uri, no_create_uri};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
pub use destination::{DestinationError, FileDestination, ReplicaDestination};
pub use engine::{ReplicationError, ReplicationSettings, Replicator, TickReport};
pub use restore::{RestoreError, RestoreOutcome, RestorePlan};
pub use status::{
HealthThresholds, INDICATOR_NAME, ReplicationHealthIndicator, ReplicationSnapshot,
ReplicationStatus,
};
use crate::config::ReplicationConfig;
use crate::time::ClockSource;
pub(crate) const LAG_ALERT_MULTIPLIER: u32 = 3;
const STARTUP_GRACE: Duration = Duration::from_secs(120);
#[derive(Debug)]
#[non_exhaustive]
pub enum SetupError {
Disabled,
NotSqlite {
backend: String,
},
InMemory,
Config {
errors: Vec<String>,
},
MissingCredentialEnv {
var: String,
},
SharedBucket {
bucket: String,
},
Destination(DestinationError),
}
impl fmt::Display for SetupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => write!(f, "continuous replication is not enabled"),
Self::NotSqlite { backend } => write!(
f,
"[replication] is enabled but the database is {backend}, not SQLite.\n \
Continuous replication covers the zero-ops SQLite tier; Postgres \
deployments should use their own continuous archiving (WAL-G, pgBackRest)."
),
Self::InMemory => write!(
f,
"[replication] is enabled but the database is in-memory, so there is \
nothing to replicate. Point database.url at a file-backed SQLite target."
),
Self::Config { errors } => {
writeln!(f, "Invalid [replication] configuration:")?;
for error in errors {
writeln!(f, " - {error}")?;
}
Ok(())
}
Self::MissingCredentialEnv { var } => write!(
f,
"Replication credential environment variable {var:?} is not set (or is \
empty).\n It is NAMED by [replication.s3] access_key_id_env / \
secret_access_key_env; the secret itself never lives in config."
),
Self::SharedBucket { bucket } => write!(
f,
"[replication] targets bucket {bucket:?}, the same bucket + endpoint as the \
app's blob storage ([storage.s3]).\n A lifecycle rule written for user \
uploads would then expire your replicas. Point replication at a distinct \
bucket, or set replication.allow_shared_bucket = true to opt in."
),
Self::Destination(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SetupError {}
impl From<DestinationError> for SetupError {
fn from(e: DestinationError) -> Self {
Self::Destination(e)
}
}
pub struct ReplicationRuntime {
pub replicator: Replicator,
pub status: Arc<ReplicationStatus>,
pub indicator: Arc<ReplicationHealthIndicator>,
pub settings: ReplicationSettings,
}
impl fmt::Debug for ReplicationRuntime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReplicationRuntime")
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
#[must_use]
pub fn database_file(url: &str) -> Option<PathBuf> {
database_target(url).map(DatabaseTarget::into_path)
}
#[must_use]
pub fn connection_string(path: &Path) -> Option<String> {
let text = path.to_str()?;
Some(if text.starts_with("file:") {
format!("./{text}")
} else {
text.to_owned()
})
}
#[must_use]
pub fn redact_credentials(detail: &str) -> String {
let mut out = String::with_capacity(detail.len());
let mut rest = detail;
while let Some(scheme_at) = rest.find("://") {
let Some((before, after)) = rest.split_at_checked(scheme_at.saturating_add(3)) else {
break;
};
out.push_str(before);
let boundary = after.find(['/', '?', '#', ' ']).unwrap_or(after.len());
if let Some(at) = after
.get(..boundary)
.and_then(|authority| authority.find('@'))
{
out.push_str("<redacted>@");
rest = after.get(at.saturating_add(1)..).unwrap_or("");
} else {
out.push_str(after.get(..boundary).unwrap_or(""));
rest = after.get(boundary..).unwrap_or("");
}
}
out.push_str(rest);
out
}
#[derive(Debug, Clone, Copy)]
pub struct StorageDestination<'a> {
pub bucket: &'a str,
pub endpoint: Option<&'a str>,
}
fn canonical_authority(endpoint: Option<&str>) -> Option<String> {
let endpoint = endpoint.map(str::trim).filter(|e| !e.is_empty())?;
let parsed = url::Url::parse(endpoint).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
Some(
parsed
.port()
.map_or_else(|| host.clone(), |port| format!("{host}:{port}")),
)
}
fn credential_from_env(var: Option<&String>, field: &str) -> Result<String, SetupError> {
let name = var
.map(String::as_str)
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| SetupError::Config {
errors: vec![format!("[replication.s3] {field} is unset")],
})?;
let value = std::env::var(name).unwrap_or_default();
if value.trim().is_empty() {
return Err(SetupError::MissingCredentialEnv {
var: name.to_owned(),
});
}
Ok(value)
}
pub fn build_destination(
config: &ReplicationConfig,
storage: Option<StorageDestination<'_>>,
) -> Result<Arc<dyn ReplicaDestination>, SetupError> {
if let Some(path) = config.path.as_ref().filter(|p| !p.trim().is_empty()) {
return Ok(Arc::new(FileDestination::new(path.trim())?));
}
let Some(s3) = config.s3.as_ref() else {
return Err(SetupError::Config {
errors: vec!["[replication] has no destination configured".to_owned()],
});
};
let bucket = s3
.bucket
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty())
.ok_or_else(|| SetupError::Config {
errors: vec!["[replication.s3] bucket is unset".to_owned()],
})?;
if !config.allow_shared_bucket
&& storage.is_some_and(|storage| {
storage.bucket == bucket
&& canonical_authority(storage.endpoint)
== canonical_authority(s3.endpoint.as_deref())
})
{
return Err(SetupError::SharedBucket {
bucket: bucket.to_owned(),
});
}
#[cfg(feature = "http-client")]
{
let credentials = s3::S3Credentials {
access_key_id: credential_from_env(s3.access_key_id_env.as_ref(), "access_key_id_env")?,
secret_access_key: credential_from_env(
s3.secret_access_key_env.as_ref(),
"secret_access_key_env",
)?,
};
let settings = s3::S3Settings {
bucket: bucket.to_owned(),
region: s3.region.clone().unwrap_or_else(|| "us-east-1".to_owned()),
endpoint: s3.endpoint.clone(),
force_path_style: s3.force_path_style,
};
Ok(Arc::new(s3::S3Destination::new(settings, credentials)?))
}
#[cfg(not(feature = "http-client"))]
{
let _ = bucket;
Err(SetupError::Config {
errors: vec![
"[replication.s3] needs the `http-client` feature; this build of autumn-web \
was compiled without it. Use a `path` destination, or enable the feature."
.to_owned(),
],
})
}
}
pub fn build(
config: &ReplicationConfig,
database_url: &str,
profile: &str,
storage: Option<StorageDestination<'_>>,
clock: Arc<dyn ClockSource>,
) -> Result<ReplicationRuntime, SetupError> {
if !config.enabled {
return Err(SetupError::Disabled);
}
let errors = config.validation_errors();
if !errors.is_empty() {
return Err(SetupError::Config { errors });
}
if !(database_url.starts_with("sqlite:") || database_url.starts_with("file:")) {
return Err(SetupError::NotSqlite {
backend: crate::config::DatabaseBackend::detect(database_url)
.map_or_else(|| "an unrecognized backend".to_owned(), |b| b.to_string()),
});
}
let database_path = database_file(database_url).ok_or(SetupError::InMemory)?;
let destination = build_destination(config, storage)?;
let status = Arc::new(ReplicationStatus::new(destination.describe()));
let root = segment::root_prefix(config.prefix.as_deref(), profile);
if let Err(e) = destination::validate_key(&root) {
return Err(SetupError::Config {
errors: vec![format!(
"[replication] prefix {:?} makes an unusable destination root {root:?}: {e}",
config.prefix.as_deref().unwrap_or_default()
)],
});
}
let settings = ReplicationSettings {
database_path,
root,
sync_interval: config.sync_interval(),
snapshot_interval: Duration::from_secs(config.snapshot_interval_secs.max(1)),
max_wal_bytes: config.max_wal_bytes,
retention: Duration::from_secs(config.retention_hours.max(1).saturating_mul(3600)),
verify_interval: config.verify_interval(),
};
let started_at = clock.now();
let indicator = Arc::new(ReplicationHealthIndicator::new(
Arc::clone(&status),
HealthThresholds {
lag_alert_after: config.rpo().saturating_mul(LAG_ALERT_MULTIPLIER),
startup_grace: STARTUP_GRACE,
},
started_at,
));
let replicator = Replicator::new(
settings.clone(),
Arc::clone(&destination),
Arc::clone(&status),
)
.with_clock(clock);
Ok(ReplicationRuntime {
replicator,
status,
indicator,
settings,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_clock() -> Arc<dyn ClockSource> {
Arc::new(crate::time::SystemClock)
}
#[test]
fn database_file_understands_every_accepted_sqlite_spelling() {
assert_eq!(
database_file("sqlite:///var/lib/app.db"),
Some(PathBuf::from("/var/lib/app.db"))
);
assert_eq!(
database_file("sqlite:app.db"),
Some(PathBuf::from("app.db"))
);
assert_eq!(
database_file("sqlite://data/app.db"),
Some(PathBuf::from("data/app.db"))
);
assert_eq!(
database_file("file:/srv/app.db?mode=rwc"),
Some(PathBuf::from("/srv/app.db"))
);
}
#[test]
fn database_file_decodes_a_file_uri_the_way_sqlite_does() {
for (url, expected) in [
("file:app%20data.db", "app data.db"),
(
"file:/srv/my%20app/db%2Efile?mode=rwc",
"/srv/my app/db.file",
),
("file:///srv/app.db", "/srv/app.db"),
("file://localhost/srv/app.db", "/srv/app.db"),
("file:caf%C3%A9.db", "café.db"),
("file:100%.db", "100%.db"),
("file:a%2Fb.db", "a/b.db"),
("file:a%zz.db", "a%zz.db"),
("file:trailing%", "trailing%"),
("file:app%00ignored.db", "app"),
("file:/srv/app%00.db?mode=rwc", "/srv/app"),
] {
assert_eq!(database_file(url), Some(PathBuf::from(expected)), "{url}");
}
assert_eq!(
database_file("sqlite://app%20data.db"),
Some(PathBuf::from("app%20data.db"))
);
}
#[cfg(unix)]
#[test]
fn database_file_keeps_non_utf8_bytes_a_file_uri_decodes_to() {
use std::os::unix::ffi::OsStrExt as _;
let resolved = database_file("file:app%FF.db").expect("names a file");
assert_eq!(
resolved.as_os_str().as_bytes(),
b"app\xFF.db",
"the decoded bytes must survive, not become a replacement character"
);
assert!(
resolved.to_str().is_none(),
"this path is deliberately not valid UTF-8"
);
}
#[test]
fn database_file_reads_mode_memory_from_the_query_not_the_filename() {
for durable in [
"sqlite://mode=memory.db",
"sqlite:///var/lib/mode=memory.db",
"file:mode=memory.db",
] {
assert!(
database_file(durable).is_some(),
"{durable} names a durable file"
);
}
for memory in [
"file:app?mode=memory",
"file:app?cache=shared&mode=memory",
"file:app?mode=memory#frag",
] {
assert_eq!(database_file(memory), None, "{memory} is in-memory");
}
assert_eq!(database_file("file:%00"), None);
for memory in [
"file:%3Amemory%3A",
"file::memory:#fragment",
"file:%3amemory%3a",
] {
assert_eq!(database_file(memory), None, "{memory} is in-memory");
}
assert!(database_file("file:app?mode=memoryx").is_some());
assert!(
database_file("file:app.db?x=1;mode=memory").is_some(),
"a semicolon does not separate parameters, so this names the durable app.db"
);
}
#[test]
fn connection_string_keeps_a_file_prefixed_name_from_becoming_a_uri() {
assert_eq!(
connection_string(Path::new("file:prod.db")).as_deref(),
Some("./file:prod.db")
);
assert_eq!(
connection_string(Path::new("/srv/file:prod.db")).as_deref(),
Some("/srv/file:prod.db")
);
assert_eq!(
connection_string(Path::new("app.db")).as_deref(),
Some("app.db")
);
}
#[test]
fn a_file_named_like_a_uri_round_trips_through_the_resolver() {
let resolved = database_file("file:file%3Aprod.db?mode=rwc").expect("names a file");
assert_eq!(resolved, PathBuf::from("file:prod.db"));
assert_eq!(
connection_string(&resolved).as_deref(),
Some("./file:prod.db"),
"handing the decoded name straight back to sqlite3_open would create \
a different, empty database"
);
}
#[test]
fn database_file_rejects_in_memory_and_postgres_targets() {
for url in [
"sqlite::memory:",
"sqlite://:memory:",
"sqlite://",
"file::memory:",
"file::memory:?cache=shared",
"file:app?mode=memory&cache=shared",
"postgres://localhost/app",
] {
assert_eq!(database_file(url), None, "{url} must not name a file");
}
}
fn enabled_file_config(path: &str) -> ReplicationConfig {
ReplicationConfig {
enabled: true,
path: Some(path.to_owned()),
..ReplicationConfig::default()
}
}
#[test]
fn build_refuses_a_disabled_section() {
let config = ReplicationConfig::default();
assert!(matches!(
build(&config, "sqlite:app.db", "dev", None, test_clock()),
Err(SetupError::Disabled)
));
}
#[test]
fn build_refuses_postgres_and_in_memory_targets() {
let dir = tempfile::tempdir().expect("tempdir");
let config = enabled_file_config(&dir.path().to_string_lossy());
assert!(matches!(
build(
&config,
"postgres://localhost/app",
"prod",
None,
test_clock()
),
Err(SetupError::NotSqlite { .. })
));
assert!(matches!(
build(&config, "sqlite::memory:", "prod", None, test_clock()),
Err(SetupError::InMemory)
));
}
#[test]
fn build_refuses_a_section_with_no_destination() {
let config = ReplicationConfig {
enabled: true,
..ReplicationConfig::default()
};
let err =
build(&config, "sqlite:app.db", "prod", None, test_clock()).expect_err("must refuse");
assert!(
format!("{err}").contains("no destination is configured"),
"{err}"
);
}
#[test]
fn build_refuses_two_destinations() {
let config = ReplicationConfig {
enabled: true,
path: Some("/tmp/replicas".to_owned()),
s3: Some(crate::config::ReplicationS3Config {
bucket: Some("b".to_owned()),
access_key_id_env: Some("A".to_owned()),
secret_access_key_env: Some("S".to_owned()),
..crate::config::ReplicationS3Config::default()
}),
..ReplicationConfig::default()
};
let err =
build(&config, "sqlite:app.db", "prod", None, test_clock()).expect_err("must refuse");
assert!(
format!("{err}").contains("pick exactly one destination"),
"{err}"
);
}
#[test]
fn build_resolves_settings_from_config() {
let dir = tempfile::tempdir().expect("tempdir");
let mut config = enabled_file_config(&dir.path().to_string_lossy());
config.prefix = Some("replicas".to_owned());
config.rpo_secs = 20;
config.retention_hours = 2;
let runtime =
build(&config, "sqlite:///srv/app.db", "prod", None, test_clock()).expect("runtime");
assert_eq!(runtime.settings.database_path, PathBuf::from("/srv/app.db"));
assert_eq!(runtime.settings.root, "replicas/prod");
assert_eq!(runtime.settings.sync_interval, Duration::from_secs(10));
assert_eq!(runtime.settings.retention, Duration::from_secs(7200));
assert!(runtime.status.snapshot().destination.starts_with("file://"));
}
#[test]
fn a_shared_blob_storage_bucket_needs_an_opt_in() {
let mut config = ReplicationConfig {
enabled: true,
s3: Some(crate::config::ReplicationS3Config {
bucket: Some("shared".to_owned()),
access_key_id_env: Some("AUTUMN_TEST_REPL_KEY".to_owned()),
secret_access_key_env: Some("AUTUMN_TEST_REPL_SECRET".to_owned()),
..crate::config::ReplicationS3Config::default()
}),
..ReplicationConfig::default()
};
let same = Some(StorageDestination {
bucket: "shared",
endpoint: None,
});
assert!(matches!(
build_destination(&config, same),
Err(SetupError::SharedBucket { .. })
));
let elsewhere = Some(StorageDestination {
bucket: "shared",
endpoint: Some("https://minio.test:9000"),
});
assert!(matches!(
build_destination(&config, elsewhere),
Err(SetupError::MissingCredentialEnv { .. })
));
config.allow_shared_bucket = true;
assert!(matches!(
build_destination(&config, same),
Err(SetupError::MissingCredentialEnv { .. })
));
}
#[test]
fn credentials_are_stripped_from_any_embedded_url() {
let raw = "error sending request for url (https://KEY:SECRET@minio.test:9000/b/k)";
let redacted = redact_credentials(raw);
assert!(!redacted.contains("SECRET"), "{redacted}");
assert!(!redacted.contains("KEY"), "{redacted}");
assert!(redacted.contains("minio.test:9000/b/k"), "{redacted}");
assert_eq!(
redact_credentials("failed for url (https://minio.test/b/k)"),
"failed for url (https://minio.test/b/k)"
);
assert_eq!(redact_credentials("no url here"), "no url here");
let two = redact_credentials("a https://u:p@x.test/1 and b postgres://u2:p2@y.test/2");
assert!(!two.contains("p2"), "{two}");
assert!(
two.contains("x.test/1") && two.contains("y.test/2"),
"{two}"
);
}
#[test]
fn endpoint_spellings_of_one_destination_compare_equal() {
assert_eq!(canonical_authority(None), None);
assert_eq!(
canonical_authority(Some("https://MinIO.Test:9000")),
canonical_authority(Some("https://minio.test:9000/"))
);
assert_ne!(
canonical_authority(Some("https://minio.test:9000")),
canonical_authority(Some("https://minio.test:9001"))
);
}
#[test]
fn sync_interval_defaults_to_half_the_rpo_with_a_one_second_floor() {
let config = ReplicationConfig::default();
assert_eq!(config.rpo_secs, 10, "the documented default RPO");
assert_eq!(config.sync_interval(), Duration::from_secs(5));
let tight = ReplicationConfig {
rpo_secs: 1,
..ReplicationConfig::default()
};
assert_eq!(tight.sync_interval(), Duration::from_secs(1));
let explicit = ReplicationConfig {
rpo_secs: 60,
sync_interval_secs: Some(30),
..ReplicationConfig::default()
};
assert_eq!(explicit.sync_interval(), Duration::from_secs(30));
}
#[test]
fn a_sync_interval_longer_than_the_rpo_is_refused() {
let bad = ReplicationConfig {
enabled: true,
rpo_secs: 10,
sync_interval_secs: Some(60),
path: Some("/tmp/replica".to_owned()),
..ReplicationConfig::default()
};
let errors = bad.validation_errors();
assert!(
errors
.iter()
.any(|e| e.contains("sync_interval_secs") && e.contains("rpo_secs")),
"the pair must be rejected: {errors:?}"
);
let tight = ReplicationConfig {
enabled: true,
rpo_secs: 10,
sync_interval_secs: Some(10),
path: Some("/tmp/replica".to_owned()),
..ReplicationConfig::default()
};
assert!(
!tight
.validation_errors()
.iter()
.any(|e| e.contains("sync_interval_secs")),
"an interval equal to the RPO is valid"
);
}
#[test]
fn verification_can_be_switched_off() {
let mut config = ReplicationConfig::default();
assert_eq!(config.verify_interval(), Some(Duration::from_secs(21_600)));
config.verify_interval_secs = 0;
assert_eq!(config.verify_interval(), None);
}
}