use futures_util::future::{FutureExt, join_all};
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::path::Path;
use tracing::{debug, error, warn};
const WAL_CHECKPOINT_CAP_BYTES: u64 = 32 * 1024 * 1024;
const DEFAULT_CHECKPOINT_MIN_FREE_BYTES: u64 = 64 * 1024 * 1024;
fn checkpoint_min_free_bytes() -> u64 {
std::env::var("MAHBOT_CHECKPOINT_MIN_FREE_BYTES")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_CHECKPOINT_MIN_FREE_BYTES)
}
#[cfg(unix)]
fn available_free_bytes(path: &Path) -> u64 {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return 0;
};
let mut stats = unsafe { std::mem::zeroed::<libc::statvfs>() };
if unsafe { libc::statvfs(c_path.as_ptr(), std::ptr::addr_of_mut!(stats)) } != 0 {
return 0;
}
u64::from(stats.f_bavail).saturating_mul(stats.f_frsize)
}
#[cfg(not(unix))]
fn available_free_bytes(_path: &Path) -> u64 {
u64::MAX
}
fn truncate_allowed(root: &Path) -> bool {
let min = checkpoint_min_free_bytes();
if min == 0 {
return true;
}
let free = available_free_bytes(root);
let allowed = free >= min;
if !allowed {
warn!(
free_bytes = free,
min_free_bytes = min,
"Free disk space below TRUNCATE checkpoint threshold — running PASSIVE only",
);
}
allowed
}
#[derive(Debug, Clone, Copy)]
enum CheckpointPolicy {
Truncate,
PassiveCapped(u64),
}
impl CheckpointPolicy {
fn periodic() -> Self {
Self::PassiveCapped(WAL_CHECKPOINT_CAP_BYTES)
}
}
async fn for_each_store<F, Fut>(op: F)
where
F: Fn(&'static str, &'static crate::db::Connection) -> Fut,
Fut: Future<Output = ()>,
{
let futs: Vec<_> = crate::db::iter_checkpoint_stores()
.filter_map(|(name, conn_opt)| {
let conn = conn_opt?;
let fut = AssertUnwindSafe(op(name, conn)).catch_unwind();
Some(async move {
if let Err(payload) = fut.await {
error!(
panic = %crate::util::panic_message(&*payload),
db = name,
"Store operation panicked — isolated to this store",
);
}
})
})
.collect();
join_all(futs).await;
}
pub async fn checkpoint_all_databases() {
checkpoint_stores(CheckpointPolicy::Truncate, false).await;
}
pub async fn periodic_checkpoint_and_verify() {
checkpoint_stores(CheckpointPolicy::periodic(), true).await;
}
async fn checkpoint_stores(policy: CheckpointPolicy, verify: bool) {
let root = crate::config::CONFIG.try_storage_root();
let truncate_gate = root.as_deref().is_none_or(truncate_allowed);
for_each_store(|name, conn| {
let root = root.clone();
async move {
let status = root
.as_deref()
.map(|r| crate::db::wal_guard::inspect_store(r, name));
let truncate = match policy {
CheckpointPolicy::Truncate => truncate_gate,
CheckpointPolicy::PassiveCapped(cap) => {
status.as_ref().is_some_and(|s| s.wal_size > cap) && truncate_gate
}
};
let outcome = if truncate {
conn.checkpoint().await
} else {
conn.checkpoint_passive().await
};
match outcome {
Ok(o) if o.is_complete() => debug!(
db = %name,
log = o.log_frames,
checkpointed = o.checkpointed_frames,
"Database WAL checkpointed",
),
Ok(o) => warn!(
db = %name,
busy = o.busy,
log = o.log_frames,
checkpointed = o.checkpointed_frames,
"Checkpoint busy or partial — WAL frames left uncheckpointed",
),
Err(e) => warn!(error = %e, db = %name, "Failed to checkpoint database WAL"),
}
if verify {
match conn.quick_check().await {
Ok(()) => debug!(db = %name, "Database integrity check passed"),
Err(e) => error!(error = %e, db = %name, "Database integrity check failed"),
}
}
}
})
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_when_no_stores() {
checkpoint_all_databases().await;
periodic_checkpoint_and_verify().await;
}
}