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_minutes = checked_interval_minutes(config.interval_minutes);
let interval = Duration::from_secs(interval_minutes * 60);
let retain = checked_retain(config.retain);
let audit_retention_days = config.audit_retention_days;
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 = interval_minutes,
retain = retain,
audit_retention_days = audit_retention_days.unwrap_or(0),
"backup task started"
);
tokio::time::sleep(Duration::from_secs(5)).await;
run_backup_blocking(&pool, &db_path, &backup_dir, retain, audit_retention_days).await;
let mut interval_timer = tokio::time::interval(interval);
interval_timer.tick().await; loop {
interval_timer.tick().await;
run_backup_blocking(&pool, &db_path, &backup_dir, retain, audit_retention_days).await;
}
})
}
async fn run_backup_blocking(
pool: &Arc<DbPool>,
db_path: &Path,
backup_dir: &Path,
retain: usize,
audit_retention_days: Option<u32>,
) {
let pool = Arc::clone(pool);
let db_path = db_path.to_path_buf();
let backup_dir = backup_dir.to_path_buf();
let result = tokio::task::spawn_blocking(move || {
run_backup(&pool, &db_path, &backup_dir, retain, audit_retention_days);
})
.await;
if let Err(e) = result {
error!(error = %e, "backup job failed to run to completion");
}
}
fn checked_interval_minutes(configured: u64) -> u64 {
if configured == 0 {
let fallback = BackupConfig::default().interval_minutes;
warn!(
fallback_minutes = fallback,
"[backup] interval_minutes = 0 is not a valid schedule; using the default instead"
);
return fallback;
}
configured
}
fn checked_retain(configured: usize) -> usize {
if configured == 0 {
let fallback = BackupConfig::default().retain;
warn!(
fallback_retain = fallback,
"[backup] retain = 0 would delete every backup as soon as it is written; using \
the default instead (set [backup] enabled = false to turn backups off)"
);
return fallback;
}
configured
}
static LEGACY_MIRROR_HINTED: AtomicBool = AtomicBool::new(false);
fn run_backup(
pool: &DbPool,
db_path: &Path,
backup_dir: &Path,
retain: usize,
audit_retention_days: Option<u32>,
) {
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_or(0, |m| m.len());
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);
prune_audit_log(pool, audit_retention_days);
trim_retained_heap();
}
fn trim_retained_heap() {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
libc::malloc_trim(0);
}
}
fn prune_audit_log(pool: &DbPool, audit_retention_days: Option<u32>) {
let Some(days) = audit_retention_days.filter(|d| *d > 0) else {
return;
};
let conn = match pool.write() {
Ok(conn) => conn,
Err(e) => {
warn!(error = %e, "could not acquire write connection for audit log pruning");
return;
}
};
let cutoff = format!("-{days} days");
match conn.execute(
"DELETE FROM audit_log WHERE ts < datetime('now', ?1)",
rusqlite::params![cutoff],
) {
Ok(0) => {}
Ok(deleted) => info!(
deleted,
retention_days = days,
"pruned old audit log entries"
),
Err(e) => warn!(error = %e, "audit log pruning failed"),
}
}
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;
};
let is_new_staging_dir = name.starts_with(".lific-dump-");
let is_legacy_tmp = name.starts_with(&prefix)
&& (name.ends_with(".dbsnapshot.tmp") || name.ends_with(".archive.tmp"));
if !is_new_staging_dir && !is_legacy_tmp {
continue;
}
let age_path = if is_new_staging_dir {
path.join("activity")
} else {
path.clone()
};
let stale = std::fs::symlink_metadata(&age_path)
.and_then(|m| m.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > STALE_TMP_AGE);
if !stale {
continue;
}
if is_new_staging_dir {
if dump::staging_is_locked(&path) {
continue;
}
let activity = path.join("activity");
let active = std::fs::symlink_metadata(&activity)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age <= STALE_TMP_AGE);
if active {
continue;
}
}
let result = if is_new_staging_dir {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
match result {
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 tempfile::TempDir;
fn make_temp_dir() -> TempDir {
tempfile::tempdir().unwrap()
}
fn make_backup_dir(parent: &Path) -> PathBuf {
let backup_dir = parent.join("backups");
fs::create_dir_all(&backup_dir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&backup_dir, fs::Permissions::from_mode(0o700)).unwrap();
}
backup_dir
}
#[test]
fn rotate_keeps_only_retain_count() {
let tmp = make_temp_dir();
let dir = tmp.path();
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());
}
#[test]
fn rotate_does_nothing_under_retain() {
let tmp = make_temp_dir();
let dir = tmp.path();
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);
}
#[test]
fn rotate_ignores_other_files() {
let tmp = make_temp_dir();
let dir = tmp.path();
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()); }
#[test]
fn rotate_ages_out_legacy_db_snapshots_alongside_archives() {
let tmp = make_temp_dir();
let dir = tmp.path();
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());
}
#[tokio::test]
async fn a_scheduled_backup_does_not_park_the_async_runtime() {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
let tmp = make_temp_dir();
let dir = tmp.path();
let db_path = dir.join("lific.db");
let backup_dir = make_backup_dir(dir);
let pool = Arc::new(crate::db::open(&db_path).expect("open test db"));
let store = crate::storage::AttachmentStore::from_db_path(&db_path);
let (holding_tx, holding_rx) = std::sync::mpsc::sync_channel::<()>(1);
let holder = std::thread::spawn(move || {
store
.with_lock(|_| {
holding_tx.send(()).unwrap();
std::thread::sleep(Duration::from_millis(200));
Ok(())
})
.unwrap();
});
holding_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let ticks = Arc::new(AtomicUsize::new(0));
let counter = tokio::spawn({
let ticks = Arc::clone(&ticks);
async move {
loop {
tokio::time::sleep(Duration::from_millis(5)).await;
ticks.fetch_add(1, Ordering::Relaxed);
}
}
});
run_backup_blocking(&pool, &db_path, &backup_dir, 5, None).await;
counter.abort();
holder.join().unwrap();
assert!(
ticks.load(Ordering::Relaxed) > 0,
"async work must keep running while a backup is in flight"
);
let archives = fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tar.gz"))
.count();
assert_eq!(archives, 1, "and the backup still produced its archive");
}
fn backdate(path: &Path, by: Duration) {
let f = if cfg!(unix) && path.is_dir() {
fs::File::open(path).unwrap()
} else {
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 tmp = make_temp_dir();
let dir = tmp.path();
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");
let stale_dir = dir.join(".lific-dump-stale");
for p in [
&stale_snap,
&stale_arch,
&fresh_arch,
&other_stem,
&real_backup,
] {
fs::write(p, "x").unwrap();
}
fs::create_dir(&stale_dir).unwrap();
fs::write(stale_dir.join("activity"), "stale").unwrap();
backdate(&stale_snap, old);
backdate(&stale_arch, old);
backdate(&other_stem, old);
backdate(&real_backup, old);
backdate(&stale_dir.join("activity"), 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"
);
assert!(
!stale_dir.exists(),
"stale dump staging directory must be swept"
);
}
#[test]
fn sweep_keeps_stale_directory_while_dump_lock_is_held() {
use fs2::FileExt;
let tmp = make_temp_dir();
let dir = tmp.path();
let staging = dir.join(".lific-dump-active");
fs::create_dir(&staging).unwrap();
fs::write(staging.join("activity"), "active").unwrap();
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(staging.join("lock"))
.unwrap();
lock.try_lock_exclusive().unwrap();
backdate(
&staging.join("activity"),
STALE_TMP_AGE + Duration::from_secs(60),
);
sweep_stale_tmps(dir, "lific");
assert!(staging.exists(), "a locked dump must not be swept");
drop(lock);
}
#[test]
fn run_backup_sweeps_stale_tmps_even_when_it_writes_nothing_new() {
let tmp = make_temp_dir();
let dir = tmp.path();
let db_path = dir.join("lific.db");
let backup_dir = make_backup_dir(dir);
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, None);
assert!(!stale.exists(), "run_backup must sweep stale staging files");
}
#[test]
fn run_backup_emits_tar_gz_archive_with_data_and_blobs() {
let tmp = make_temp_dir();
let dir = tmp.path();
let db_path = dir.join("lific.db");
let backup_dir = make_backup_dir(dir);
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();
let blob_name = crate::storage::AttachmentStore::hash_bytes(b"blob contents");
fs::write(att_dir.join(&blob_name), b"blob contents").unwrap();
fs::write(att_dir.join("deadbeefsha.tmp"), b"partial").unwrap();
run_backup(&pool, &db_path, &backup_dir, 5, None);
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 == &format!("attachments/{blob_name}"))
);
assert!(
!names.iter().any(|n| n.ends_with(".tmp")),
"in-progress .tmp writes must not be archived: {names:?}"
);
}
#[test]
fn zero_interval_falls_back_to_the_default_schedule() {
assert_eq!(
checked_interval_minutes(0),
BackupConfig::default().interval_minutes
);
assert_eq!(checked_interval_minutes(1), 1);
assert_eq!(checked_interval_minutes(30), 30);
}
#[test]
fn zero_retain_falls_back_to_the_default_count() {
assert_eq!(checked_retain(0), BackupConfig::default().retain);
assert_eq!(checked_retain(1), 1);
assert_eq!(checked_retain(48), 48);
}
#[test]
fn rotate_with_zero_retain_would_delete_everything() {
let tmp = make_temp_dir();
let dir = tmp.path();
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", 0);
assert_eq!(fs::read_dir(dir).unwrap().count(), 0);
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", checked_retain(0));
assert_eq!(fs::read_dir(dir).unwrap().count(), 2);
}
fn seed_audit_row(pool: &DbPool, label: &str, days_ago: i64) {
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO audit_log (ts, transport, entity_type, entity_id, entity_label, action)
VALUES (datetime('now', ?1), 'system', 'issue', 1, ?2, 'create')",
rusqlite::params![format!("-{days_ago} days"), label],
)
.unwrap();
}
fn audit_labels(pool: &DbPool) -> Vec<String> {
let conn = pool.read().unwrap();
let mut stmt = conn
.prepare("SELECT entity_label FROM audit_log ORDER BY entity_label")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
#[test]
fn prune_audit_log_deletes_rows_past_the_window_and_keeps_recent_ones() {
let pool = crate::db::open_memory().unwrap();
seed_audit_row(&pool, "ancient", 400);
seed_audit_row(&pool, "old", 31);
seed_audit_row(&pool, "recent", 29);
seed_audit_row(&pool, "today", 0);
prune_audit_log(&pool, Some(30));
assert_eq!(audit_labels(&pool), vec!["recent", "today"]);
}
#[test]
fn prune_audit_log_keeps_everything_when_retention_is_unset_or_zero() {
let pool = crate::db::open_memory().unwrap();
seed_audit_row(&pool, "ancient", 4000);
seed_audit_row(&pool, "today", 0);
let before = audit_labels(&pool);
prune_audit_log(&pool, None);
assert_eq!(audit_labels(&pool), before);
prune_audit_log(&pool, Some(0));
assert_eq!(audit_labels(&pool), before);
}
#[test]
fn prune_audit_log_one_day_window_spares_todays_rows() {
let pool = crate::db::open_memory().unwrap();
seed_audit_row(&pool, "yesterday", 2);
seed_audit_row(&pool, "now", 0);
prune_audit_log(&pool, Some(1));
assert_eq!(audit_labels(&pool), vec!["now"]);
}
#[test]
fn run_backup_prunes_audit_log_only_when_retention_is_configured() {
let tmp = make_temp_dir();
let dir = tmp.path();
let db_path = dir.join("lific.db");
let backup_dir = make_backup_dir(dir);
let pool = crate::db::open(&db_path).expect("open test db");
seed_audit_row(&pool, "ancient", 400);
seed_audit_row(&pool, "today", 0);
run_backup(&pool, &db_path, &backup_dir, 5, None);
assert_eq!(audit_labels(&pool), vec!["ancient", "today"]);
run_backup(&pool, &db_path, &backup_dir, 5, Some(30));
assert_eq!(audit_labels(&pool), vec!["today"]);
}
}