use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use pensieve_core::catalog::{Catalog, PrunePredicate, TableRef};
use pensieve_core::errors::Error as PensieveError;
use serde::Deserialize;
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_MAX_MERGE: usize = 32;
const DEFAULT_SMALL_BYTES: u64 = 64 * 1024 * 1024; const WAIT_TIMEOUT: Duration = Duration::from_secs(120);
const WAIT_POLL: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub struct CompactState {
pub catalog: Arc<dyn Catalog>,
}
#[derive(Debug, Deserialize, Default)]
pub struct CompactRequest {
pub database: Option<String>,
pub table: Option<String>,
pub max_merge: Option<usize>,
pub small_bytes: Option<u64>,
#[serde(default)]
pub wait: bool,
}
pub async fn compact(
State(state): State<CompactState>,
Json(req): Json<CompactRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let cat = &state.catalog;
let max_merge = req.max_merge.unwrap_or(DEFAULT_MAX_MERGE).clamp(2, 256);
let small_bytes = req.small_bytes.unwrap_or(DEFAULT_SMALL_BYTES);
let target_names: Vec<(String, String)> = match &req.table {
Some(t) => {
let db = req.database.clone().unwrap_or_else(|| "default".into());
vec![(db, t.clone())]
}
None => {
let dbs = match &req.database {
Some(d) => vec![d.clone()],
None => cat.list_databases().await.map_err(PensieveError::from)?,
};
let mut out = Vec::new();
for d in dbs {
for t in cat.list_tables(&d).await.map_err(PensieveError::from)? {
out.push((d.clone(), t));
}
}
out
}
};
let before = live_extent_count(cat, &target_names).await?;
let mut tasks_submitted = 0usize;
let mut extents_queued = 0usize;
for tref in resolve(cat, &target_names).await {
let mut exts = cat
.list_extents(tref.id, tref.current_snapshot_id, &PrunePredicate::default())
.await?;
exts.retain(|e| e.byte_size < small_bytes);
exts.sort_by_key(|e| e.byte_size); for chunk in exts.chunks(max_merge) {
if chunk.len() < 2 {
continue; }
let ids: Vec<String> = chunk.iter().map(|e| e.id.as_uuid().to_string()).collect();
let payload = serde_json::json!({ "source_extent_ids": ids });
cat.submit_task("compaction", Some(tref.id), payload, 0).await?;
tasks_submitted += 1;
extents_queued += chunk.len();
}
}
let after = if req.wait && tasks_submitted > 0 {
wait_for_drain(cat, &target_names).await?
} else {
before
};
Ok(Json(serde_json::json!({
"tasks_submitted": tasks_submitted,
"extents_queued": extents_queued,
"tables": target_names.iter().map(|(_, t)| t.clone()).collect::<Vec<_>>(),
"live_extents_before": before,
"live_extents_after": after,
"waited": req.wait,
"note": "compaction soft-deletes source extents; physical disk is reclaimed by a separate GC pass",
})))
}
async fn resolve(cat: &Arc<dyn Catalog>, names: &[(String, String)]) -> Vec<TableRef> {
let mut out = Vec::with_capacity(names.len());
for (db, table) in names {
if let Ok(tref) = cat.lookup_table(db, table).await {
out.push(tref);
}
}
out
}
async fn live_extent_count(
cat: &Arc<dyn Catalog>,
names: &[(String, String)],
) -> Result<usize, ApiError> {
let mut n = 0;
for tref in resolve(cat, names).await {
n += cat
.list_extents(tref.id, tref.current_snapshot_id, &PrunePredicate::default())
.await?
.len();
}
Ok(n)
}
async fn wait_for_drain(
cat: &Arc<dyn Catalog>,
names: &[(String, String)],
) -> Result<usize, ApiError> {
let start = Instant::now();
let mut last = usize::MAX;
let mut stable = 0u8;
loop {
tokio::time::sleep(WAIT_POLL).await;
let n = live_extent_count(cat, names).await?;
if n == last {
stable += 1;
} else {
stable = 0;
last = n;
}
if stable >= 2 || start.elapsed() >= WAIT_TIMEOUT {
return Ok(n);
}
}
}
#[derive(Debug)]
pub enum ApiError {
Catalog(PensieveError),
}
impl From<PensieveError> for ApiError {
fn from(e: PensieveError) -> Self {
ApiError::Catalog(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let ApiError::Catalog(e) = self;
use pensieve_core::errors::CatalogError;
match e {
PensieveError::Catalog(CatalogError::TableNotFound { database, name }) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("table '{database}'.'{name}' not found") })),
)
.into_response(),
other => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": other.to_string() })),
)
.into_response(),
}
}
}