use crate::cron::{CronContext, CronHandler, FromCronContext};
use crate::error::{Error, Result};
use crate::service::Service;
use super::Database;
#[derive(Debug, Clone)]
pub struct DbHealth {
pub page_count: u64,
pub freelist_count: u64,
pub page_size: u64,
pub free_percent: f64,
pub total_size_bytes: u64,
pub wasted_bytes: u64,
}
impl DbHealth {
pub async fn collect(conn: &libsql::Connection) -> Result<Self> {
let page_count = Self::pragma_u64(conn, "page_count").await?;
let freelist_count = Self::pragma_u64(conn, "freelist_count").await?;
let page_size = Self::pragma_u64(conn, "page_size").await?;
let free_percent = if page_count > 0 {
(freelist_count as f64 / page_count as f64) * 100.0
} else {
0.0
};
Ok(Self {
page_count,
freelist_count,
page_size,
free_percent,
total_size_bytes: page_count * page_size,
wasted_bytes: freelist_count * page_size,
})
}
pub fn needs_vacuum(&self, threshold_percent: f64) -> bool {
self.free_percent >= threshold_percent
}
async fn pragma_u64(conn: &libsql::Connection, name: &str) -> Result<u64> {
let mut rows = conn
.query(&format!("PRAGMA {name}"), ())
.await
.map_err(Error::from)?;
let row = rows
.next()
.await
.map_err(Error::from)?
.ok_or_else(|| Error::internal(format!("PRAGMA {name} returned no rows")))?;
let val: i64 = row.get(0).map_err(Error::from)?;
u64::try_from(val)
.map_err(|_| Error::internal(format!("PRAGMA {name} returned negative value: {val}")))
}
}
#[derive(Debug, Clone)]
pub struct VacuumOptions {
pub threshold_percent: f64,
pub dry_run: bool,
}
impl Default for VacuumOptions {
fn default() -> Self {
Self {
threshold_percent: 20.0,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct VacuumResult {
pub health_before: DbHealth,
pub health_after: Option<DbHealth>,
pub vacuumed: bool,
pub duration: std::time::Duration,
}
pub async fn run_vacuum(conn: &libsql::Connection, opts: VacuumOptions) -> Result<VacuumResult> {
let start = std::time::Instant::now();
let health_before = DbHealth::collect(conn).await?;
tracing::debug!(
page_count = health_before.page_count,
freelist_count = health_before.freelist_count,
free_pct = health_before.free_percent,
wasted_bytes = health_before.wasted_bytes,
"vacuum: health before"
);
if opts.dry_run || !health_before.needs_vacuum(opts.threshold_percent) {
tracing::debug!(
free_pct = health_before.free_percent,
threshold = opts.threshold_percent,
dry_run = opts.dry_run,
"vacuum: skipped"
);
return Ok(VacuumResult {
health_before,
health_after: None,
vacuumed: false,
duration: start.elapsed(),
});
}
conn.execute("VACUUM", ())
.await
.map_err(|e| Error::internal("VACUUM failed").chain(e))?;
let health_after = DbHealth::collect(conn).await?;
tracing::debug!(
page_count = health_after.page_count,
freelist_count = health_after.freelist_count,
free_pct = health_after.free_percent,
wasted_bytes = health_after.wasted_bytes,
"vacuum: health after"
);
Ok(VacuumResult {
health_before,
health_after: Some(health_after),
vacuumed: true,
duration: start.elapsed(),
})
}
pub async fn vacuum_if_needed(
conn: &libsql::Connection,
threshold_percent: f64,
) -> Result<VacuumResult> {
run_vacuum(
conn,
VacuumOptions {
threshold_percent,
..Default::default()
},
)
.await
}
#[derive(Clone)]
pub struct VacuumHandler {
threshold_percent: f64,
}
impl CronHandler<(Service<Database>,)> for VacuumHandler {
async fn call(self, ctx: CronContext) -> Result<()> {
let Service(db) = Service::<Database>::from_cron_context(&ctx)?;
let result = run_vacuum(
db.conn(),
VacuumOptions {
threshold_percent: self.threshold_percent,
..Default::default()
},
)
.await?;
if let Some(after) = result.health_after.as_ref() {
tracing::info!(
before_free_pct = result.health_before.free_percent,
after_free_pct = after.free_percent,
reclaimed_bytes = result
.health_before
.wasted_bytes
.saturating_sub(after.wasted_bytes),
duration_ms = result.duration.as_millis(),
"vacuum completed"
);
} else {
tracing::info!(
free_pct = result.health_before.free_percent,
threshold = self.threshold_percent,
"vacuum skipped, below threshold"
);
}
Ok(())
}
}
pub fn vacuum_handler(threshold_percent: f64) -> VacuumHandler {
VacuumHandler { threshold_percent }
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_conn() -> libsql::Connection {
let db = libsql::Builder::new_local(":memory:")
.build()
.await
.unwrap();
db.connect().unwrap()
}
#[tokio::test]
async fn collect_returns_metrics_for_fresh_db() {
let conn = test_conn().await;
conn.execute("CREATE TABLE _health_probe (id INTEGER PRIMARY KEY)", ())
.await
.unwrap();
let health = DbHealth::collect(&conn).await.unwrap();
assert!(health.page_count > 0);
assert_eq!(health.freelist_count, 0);
assert!(health.page_size > 0);
assert_eq!(health.free_percent, 0.0);
assert_eq!(
health.total_size_bytes,
health.page_count * health.page_size
);
assert_eq!(health.wasted_bytes, 0);
}
#[tokio::test]
async fn needs_vacuum_threshold_logic() {
let health = DbHealth {
page_count: 100,
freelist_count: 25,
page_size: 4096,
free_percent: 25.0,
total_size_bytes: 100 * 4096,
wasted_bytes: 25 * 4096,
};
assert!(health.needs_vacuum(20.0));
assert!(health.needs_vacuum(25.0));
assert!(!health.needs_vacuum(30.0));
}
#[tokio::test]
async fn run_vacuum_skips_when_below_threshold() {
let conn = test_conn().await;
let result = run_vacuum(
&conn,
VacuumOptions {
threshold_percent: 20.0,
..Default::default()
},
)
.await
.unwrap();
assert!(!result.vacuumed);
assert!(result.health_after.is_none());
assert_eq!(result.health_before.freelist_count, 0);
}
#[tokio::test]
async fn run_vacuum_skips_in_dry_run() {
let conn = test_conn().await;
let result = run_vacuum(
&conn,
VacuumOptions {
threshold_percent: 0.0, dry_run: true,
},
)
.await
.unwrap();
assert!(!result.vacuumed);
assert!(result.health_after.is_none());
}
#[tokio::test]
async fn run_vacuum_executes_when_threshold_met() {
let conn = test_conn().await;
conn.execute("CREATE TABLE bloat (id INTEGER PRIMARY KEY, data TEXT)", ())
.await
.unwrap();
for i in 0..500 {
conn.execute(
"INSERT INTO bloat (id, data) VALUES (?1, ?2)",
libsql::params![i, "x".repeat(200)],
)
.await
.unwrap();
}
conn.execute("DELETE FROM bloat", ()).await.unwrap();
let health = DbHealth::collect(&conn).await.unwrap();
assert!(
health.freelist_count > 0,
"expected freelist pages after bulk delete"
);
let result = run_vacuum(
&conn,
VacuumOptions {
threshold_percent: 0.0,
..Default::default()
},
)
.await
.unwrap();
assert!(result.vacuumed);
let after = result.health_after.unwrap();
assert!(
after.freelist_count < health.freelist_count,
"freelist should shrink after vacuum"
);
}
#[tokio::test]
async fn vacuum_if_needed_delegates_correctly() {
let conn = test_conn().await;
let result = vacuum_if_needed(&conn, 20.0).await.unwrap();
assert!(!result.vacuumed);
}
}