use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{error, info, warn};
use crate::config::BackupConfig;
use crate::db::DbPool;
use crate::dump;
pub fn start_backup_task(
pool: Arc<DbPool>,
db_path: PathBuf,
config: BackupConfig,
) -> tokio::task::JoinHandle<()> {
let backup_dir = if config.dir.is_absolute() {
config.dir.clone()
} else if let Some(parent) = db_path.parent() {
parent.join(&config.dir)
} else {
config.dir.clone()
};
let interval = Duration::from_secs(config.interval_minutes * 60);
let retain = config.retain;
tokio::spawn(async move {
if let Err(e) = std::fs::create_dir_all(&backup_dir) {
error!(dir = %backup_dir.display(), error = %e, "failed to create backup directory");
return;
}
info!(
dir = %backup_dir.display(),
interval_min = config.interval_minutes,
retain = retain,
"backup task started"
);
tokio::time::sleep(Duration::from_secs(5)).await;
run_backup(&pool, &db_path, &backup_dir, retain);
let mut interval_timer = tokio::time::interval(interval);
interval_timer.tick().await; loop {
interval_timer.tick().await;
run_backup(&pool, &db_path, &backup_dir, retain);
}
})
}
static LEGACY_MIRROR_HINTED: AtomicBool = AtomicBool::new(false);
fn run_backup(pool: &DbPool, db_path: &Path, backup_dir: &Path, retain: usize) {
let db_stem = db_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("lific");
sweep_stale_tmps(backup_dir, db_stem);
let filename = dump::archive_filename(db_stem, &dump::archive_timestamp());
let backup_path = backup_dir.join(&filename);
match dump::write_dump(pool, db_path, &backup_path) {
Ok(manifest) => {
let size = std::fs::metadata(&backup_path)
.map(|m| m.len())
.unwrap_or(0);
info!(
path = %backup_path.display(),
size_kb = size / 1024,
attachments = manifest.attachment_count,
"backup archive written"
);
}
Err(e) => {
error!(error = %e, "backup archive failed");
let _ = std::fs::remove_file(&backup_path);
return;
}
}
let legacy_mirror = backup_dir.join("attachments");
if legacy_mirror.is_dir() && !LEGACY_MIRROR_HINTED.swap(true, Ordering::Relaxed) {
info!(
dir = %legacy_mirror.display(),
"legacy mirrored attachments dir from the pre-archive backup scheme is no longer \
used and can be deleted"
);
}
rotate_backups(backup_dir, db_stem, retain);
}
const STALE_TMP_AGE: Duration = Duration::from_secs(60 * 60);
fn sweep_stale_tmps(backup_dir: &Path, db_stem: &str) {
let prefix = format!("{db_stem}_");
let entries = match std::fs::read_dir(backup_dir) {
Ok(entries) => entries,
Err(e) => {
warn!(error = %e, "failed to read backup directory for tmp sweep");
return;
}
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with(&prefix)
|| !(name.ends_with(".dbsnapshot.tmp") || name.ends_with(".archive.tmp"))
{
continue;
}
let stale = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > STALE_TMP_AGE);
if stale {
match std::fs::remove_file(&path) {
Ok(()) => info!(path = %path.display(), "removed stale backup staging file"),
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to remove stale staging file")
}
}
}
}
}
fn rotate_backups(backup_dir: &Path, db_stem: &str, retain: usize) {
let prefix = format!("{db_stem}_");
let mut backups: Vec<PathBuf> = match std::fs::read_dir(backup_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let name = match p.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
if !name.starts_with(&prefix) {
return false;
}
name.ends_with(".tar.gz")
|| p.extension().and_then(|e| e.to_str()) == Some("db")
})
.collect(),
Err(e) => {
warn!(error = %e, "failed to read backup directory for rotation");
return;
}
};
backups.sort();
if backups.len() > retain {
let to_remove = backups.len() - retain;
for path in backups.iter().take(to_remove) {
match std::fs::remove_file(path) {
Ok(()) => info!(path = %path.display(), "removed old backup"),
Err(e) => warn!(path = %path.display(), error = %e, "failed to remove old backup"),
}
}
}
}
pub fn checkpoint_wal(pool: &DbPool) {
match pool.write() {
Ok(conn) => match conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") {
Ok(()) => info!("WAL checkpointed on shutdown"),
Err(e) => warn!(error = %e, "WAL checkpoint failed"),
},
Err(e) => warn!(error = %e, "could not acquire write connection for checkpoint"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn make_temp_dir() -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("lific_backup_test_{}_{n}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn rotate_keeps_only_retain_count() {
let dir = make_temp_dir();
for i in 1..=5 {
fs::write(dir.join(format!("lific_2026010{i}_120000.tar.gz")), "fake").unwrap();
}
rotate_backups(&dir, "lific", 3);
let remaining: Vec<_> = fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).collect();
assert_eq!(remaining.len(), 3);
assert!(!dir.join("lific_20260101_120000.tar.gz").exists());
assert!(!dir.join("lific_20260102_120000.tar.gz").exists());
assert!(dir.join("lific_20260103_120000.tar.gz").exists());
assert!(dir.join("lific_20260105_120000.tar.gz").exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rotate_does_nothing_under_retain() {
let dir = make_temp_dir();
fs::write(dir.join("lific_20260101_120000.tar.gz"), "fake").unwrap();
fs::write(dir.join("lific_20260102_120000.tar.gz"), "fake").unwrap();
rotate_backups(&dir, "lific", 5);
let count = fs::read_dir(&dir).unwrap().count();
assert_eq!(count, 2);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rotate_ignores_other_files() {
let dir = make_temp_dir();
fs::write(dir.join("other_20260101_120000.tar.gz"), "x").unwrap();
fs::write(dir.join("lific_20260101_120000.txt"), "x").unwrap();
fs::write(dir.join("lific_20260101_120000.tar.gz"), "x").unwrap();
fs::write(dir.join("lific_20260102_120000.tar.gz"), "x").unwrap();
rotate_backups(&dir, "lific", 1);
assert!(dir.join("other_20260101_120000.tar.gz").exists());
assert!(dir.join("lific_20260101_120000.txt").exists());
assert!(!dir.join("lific_20260101_120000.tar.gz").exists()); assert!(dir.join("lific_20260102_120000.tar.gz").exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rotate_ages_out_legacy_db_snapshots_alongside_archives() {
let dir = make_temp_dir();
fs::write(dir.join("lific_20260101_120000.db"), "old1").unwrap();
fs::write(dir.join("lific_20260102_120000.db"), "old2").unwrap();
fs::write(dir.join("lific_20260103_120000.tar.gz"), "new1").unwrap();
fs::write(dir.join("lific_20260104_120000.tar.gz"), "new2").unwrap();
rotate_backups(&dir, "lific", 2);
assert!(!dir.join("lific_20260101_120000.db").exists());
assert!(!dir.join("lific_20260102_120000.db").exists());
assert!(dir.join("lific_20260103_120000.tar.gz").exists());
assert!(dir.join("lific_20260104_120000.tar.gz").exists());
fs::remove_dir_all(&dir).ok();
}
fn backdate(path: &Path, by: Duration) {
let f = fs::File::options().write(true).open(path).unwrap();
f.set_modified(std::time::SystemTime::now() - by).unwrap();
}
#[test]
fn sweep_removes_stale_staging_tmps_keeps_fresh_and_unrelated() {
let dir = make_temp_dir();
let old = STALE_TMP_AGE + Duration::from_secs(60);
let stale_snap = dir.join("lific_20260101_120000.tar.dbsnapshot.tmp");
let stale_arch = dir.join("lific_20260101_120000.tar.archive.tmp");
let fresh_arch = dir.join("lific_20260714_120000.tar.archive.tmp");
let other_stem = dir.join("other_20260101_120000.tar.archive.tmp");
let real_backup = dir.join("lific_20260101_120000.tar.gz");
for p in [&stale_snap, &stale_arch, &fresh_arch, &other_stem, &real_backup] {
fs::write(p, "x").unwrap();
}
backdate(&stale_snap, old);
backdate(&stale_arch, old);
backdate(&other_stem, old);
backdate(&real_backup, old);
sweep_stale_tmps(&dir, "lific");
assert!(!stale_snap.exists(), "stale snapshot tmp must be swept");
assert!(!stale_arch.exists(), "stale archive tmp must be swept");
assert!(fresh_arch.exists(), "fresh staging tmp must survive");
assert!(other_stem.exists(), "other stems are not ours to sweep");
assert!(real_backup.exists(), "real archives are rotation's job, not the sweep's");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn run_backup_sweeps_stale_tmps_even_when_it_writes_nothing_new() {
let dir = make_temp_dir();
let db_path = dir.join("lific.db");
let backup_dir = dir.join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let pool = crate::db::open(&db_path).expect("open test db");
let stale = backup_dir.join("lific_20260101_120000.tar.archive.tmp");
fs::write(&stale, "partial").unwrap();
backdate(&stale, STALE_TMP_AGE + Duration::from_secs(60));
run_backup(&pool, &db_path, &backup_dir, 5);
assert!(!stale.exists(), "run_backup must sweep stale staging files");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn run_backup_emits_tar_gz_archive_with_data_and_blobs() {
let dir = make_temp_dir();
let db_path = dir.join("lific.db");
let backup_dir = dir.join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let pool = crate::db::open(&db_path).expect("open test db");
{
let conn = pool.write().unwrap();
crate::db::queries::create_project(
&conn,
&crate::db::models::CreateProject {
name: "BackupTest".into(),
identifier: "BKP".into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
}
let att_dir = dir.join("attachments");
fs::create_dir_all(&att_dir).unwrap();
fs::write(att_dir.join("deadbeefsha"), b"blob contents").unwrap();
fs::write(att_dir.join("deadbeefsha.tmp"), b"partial").unwrap();
run_backup(&pool, &db_path, &backup_dir, 5);
let archives: Vec<_> = fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.ends_with(".tar.gz"))
.collect();
assert_eq!(archives.len(), 1, "expected one archive, got {archives:?}");
let archive_path = backup_dir.join(&archives[0]);
let file = fs::File::open(&archive_path).unwrap();
let dec = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(dec);
let names: Vec<String> = tar
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.iter().any(|n| n == crate::dump::ARCHIVE_DB_NAME));
assert!(names.iter().any(|n| n == crate::dump::ARCHIVE_MANIFEST_NAME));
assert!(names.iter().any(|n| n == "attachments/deadbeefsha"));
assert!(
!names.iter().any(|n| n.ends_with(".tmp")),
"in-progress .tmp writes must not be archived: {names:?}"
);
fs::remove_dir_all(&dir).ok();
}
}