use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
use crate::error::Result;
use crate::tiering::ArchivalOutcome;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TableMaintenance {
pub table: String,
pub archival: Vec<ArchivalOutcome>,
pub snapshots_expired: usize,
pub invariant_violations: u64,
pub failure: Option<String>,
}
impl TableMaintenance {
pub fn rows_archived(&self) -> u64 {
self.archival.iter().map(|o| o.rows).sum()
}
pub fn windows_archived(&self) -> usize {
self.archival
.iter()
.filter(|o| o.archived_anything())
.count()
}
pub fn lease_contended(&self) -> bool {
self.archival.iter().any(|o| o.lease_contended)
}
pub fn healthy(&self) -> bool {
self.invariant_violations == 0 && self.failure.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MaintenanceOutcome {
pub tables: Vec<TableMaintenance>,
}
impl MaintenanceOutcome {
pub fn rows_archived(&self) -> u64 {
self.tables
.iter()
.map(TableMaintenance::rows_archived)
.sum()
}
pub fn windows_archived(&self) -> usize {
self.tables
.iter()
.map(TableMaintenance::windows_archived)
.sum()
}
pub fn snapshots_expired(&self) -> usize {
self.tables.iter().map(|t| t.snapshots_expired).sum()
}
pub fn invariant_violations(&self) -> u64 {
self.tables.iter().map(|t| t.invariant_violations).sum()
}
pub fn lease_contended(&self) -> bool {
self.tables.iter().any(TableMaintenance::lease_contended)
}
pub fn healthy(&self) -> bool {
self.tables.iter().all(TableMaintenance::healthy)
}
pub fn unhealthy(&self) -> impl Iterator<Item = &TableMaintenance> {
self.tables.iter().filter(|t| !t.healthy())
}
pub fn failures(&self) -> impl Iterator<Item = (&str, &str)> {
self.tables
.iter()
.filter_map(|t| Some((t.table.as_str(), t.failure.as_deref()?)))
}
}
#[derive(Debug, Clone)]
pub struct Maintenance {
stores: Vec<crate::session::MeterStore>,
interval: Duration,
max_windows: usize,
expire_snapshots: bool,
}
impl Maintenance {
pub const DEFAULT_INTERVAL: Duration = Duration::minutes(15);
pub const DEFAULT_MAX_WINDOWS: usize = 32;
pub fn new(store: crate::session::MeterStore) -> Self {
Self::over(vec![store])
}
pub fn over(stores: Vec<crate::session::MeterStore>) -> Self {
Self {
stores,
interval: Self::DEFAULT_INTERVAL,
max_windows: Self::DEFAULT_MAX_WINDOWS,
expire_snapshots: false,
}
}
pub fn tables(&self) -> Vec<&str> {
self.stores.iter().map(|s| s.table()).collect()
}
pub fn interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn max_windows(mut self, max: usize) -> Self {
self.max_windows = max.max(1);
self
}
pub fn expire_snapshots(mut self, enabled: bool) -> Self {
self.expire_snapshots = enabled;
self
}
pub async fn run_once(&self, now: OffsetDateTime) -> Result<MaintenanceOutcome> {
let mut tables = Vec::with_capacity(self.stores.len());
for store in &self.stores {
tables.push(match self.run_table(store, now).await {
Ok(done) => done,
Err(e) => {
warn!(
table = store.table(),
error = %e,
"maintenance failed for this table; the cycle continues with the rest"
);
TableMaintenance {
table: store.table().to_string(),
failure: Some(e.to_string()),
..Default::default()
}
}
});
}
let outcome = MaintenanceOutcome { tables };
info!(
tables = outcome.tables.len(),
windows = outcome.windows_archived(),
rows = outcome.rows_archived(),
snapshots_expired = outcome.snapshots_expired(),
failed = outcome.failures().count(),
healthy = outcome.healthy(),
"maintenance cycle"
);
Ok(outcome)
}
async fn run_table(
&self,
store: &crate::session::MeterStore,
now: OffsetDateTime,
) -> Result<TableMaintenance> {
let archival = store.archive(now, self.max_windows).await?;
let snapshots_expired = match self.expire_snapshots {
true => store.expire_snapshots(now).await?,
false => 0,
};
let status = store.status(now).await?;
let invariant_violations = status.invariant_violations.max(0) as u64;
if invariant_violations > 0 {
warn!(
table = store.table(),
invariant_violations, "rows are in the wrong tier: query results may be wrong"
);
}
Ok(TableMaintenance {
table: store.table().to_string(),
archival,
snapshots_expired,
invariant_violations,
failure: None,
})
}
pub fn spawn(self) -> MaintenanceHandle {
let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
let period = std::time::Duration::from_secs(
u64::try_from(self.interval.whole_seconds().max(1)).unwrap_or(900),
);
let tables = self.tables().join(", ");
let task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(period);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = &mut rx => {
info!(tables = %tables, "maintenance stopped");
return;
}
_ = ticker.tick() => {
let now = OffsetDateTime::now_utc();
if let Err(e) = self.run_once(now).await {
warn!(tables = %tables, error = %e, "maintenance cycle failed; retrying next tick");
}
}
}
}
});
MaintenanceHandle {
task,
stop: Some(tx),
}
}
}
#[derive(Debug)]
pub struct MaintenanceHandle {
task: tokio::task::JoinHandle<()>,
stop: Option<tokio::sync::oneshot::Sender<()>>,
}
impl MaintenanceHandle {
pub async fn shutdown(mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
let _ = (&mut self.task).await;
}
pub fn is_finished(&self) -> bool {
self.task.is_finished()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::watermark::{ArchivalWindow, TieringWatermark};
use time::macros::datetime;
fn window() -> ArchivalWindow {
ArchivalWindow::new(
datetime!(2026-07-20 00:00 UTC),
datetime!(2026-07-21 00:00 UTC),
)
.unwrap()
}
fn archived(rows: u64) -> ArchivalOutcome {
ArchivalOutcome {
window: Some(window()),
rows,
watermark: window().resulting_watermark(),
orphans_reclaimed: 0,
partitions_created: 0,
lease_contended: false,
}
}
fn idle() -> ArchivalOutcome {
ArchivalOutcome {
window: None,
rows: 0,
watermark: TieringWatermark::empty(),
orphans_reclaimed: 0,
partitions_created: 3,
lease_contended: false,
}
}
fn contended() -> ArchivalOutcome {
ArchivalOutcome {
lease_contended: true,
..idle()
}
}
fn table(name: &str, archival: Vec<ArchivalOutcome>) -> TableMaintenance {
TableMaintenance {
table: name.to_string(),
archival,
snapshots_expired: 0,
invariant_violations: 0,
failure: None,
}
}
#[test]
fn an_outcome_sums_only_the_windows_that_moved_data() {
let outcome = MaintenanceOutcome {
tables: vec![table(
"readings_versions",
vec![archived(96), archived(4), idle()],
)],
};
assert_eq!(outcome.rows_archived(), 100);
assert_eq!(outcome.windows_archived(), 2);
}
#[test]
fn an_outcome_over_several_tables_folds_them_and_keeps_them_apart() {
let outcome = MaintenanceOutcome {
tables: vec![
table("readings_versions", vec![archived(96)]),
TableMaintenance {
invariant_violations: 3,
..table("esa_typ2_versions", vec![archived(4)])
},
],
};
assert_eq!(outcome.rows_archived(), 100);
assert_eq!(outcome.windows_archived(), 2);
assert_eq!(outcome.invariant_violations(), 3);
assert!(!outcome.healthy());
let named: Vec<&str> = outcome.unhealthy().map(|t| t.table.as_str()).collect();
assert_eq!(named, vec!["esa_typ2_versions"]);
assert!(outcome.tables[0].healthy(), "the other table is fine");
}
#[test]
fn a_failed_table_is_reported_rather_than_ending_the_cycle() {
let outcome = MaintenanceOutcome {
tables: vec![
table("readings_versions", vec![archived(96)]),
TableMaintenance {
failure: Some("quarantined: column \"tenant\" …".to_string()),
..table("esa_typ2_versions", Vec::new())
},
],
};
assert!(!outcome.healthy());
assert_eq!(
outcome.failures().map(|(t, _)| t).collect::<Vec<_>>(),
vec!["esa_typ2_versions"],
);
assert_eq!(outcome.rows_archived(), 96);
assert!(outcome.tables[0].healthy());
}
#[test]
fn health_is_exactly_the_absence_of_violations() {
let bad = MaintenanceOutcome {
tables: vec![TableMaintenance {
invariant_violations: 1,
..table("readings_versions", Vec::new())
}],
};
assert!(!bad.healthy());
assert!(MaintenanceOutcome::default().healthy());
}
#[test]
fn contention_is_reported_and_is_not_a_failure() {
let outcome = MaintenanceOutcome {
tables: vec![table("readings_versions", vec![contended()])],
};
assert!(outcome.lease_contended());
assert!(outcome.healthy());
assert_eq!(outcome.rows_archived(), 0);
}
#[test]
fn the_defaults_are_sane() {
assert!(Maintenance::DEFAULT_INTERVAL < Duration::DAY);
const { assert!(Maintenance::DEFAULT_MAX_WINDOWS >= 1) };
}
}