use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
use crate::error::Result;
use crate::tiering::ArchivalOutcome;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MaintenanceOutcome {
pub archival: Vec<ArchivalOutcome>,
pub snapshots_expired: usize,
pub invariant_violations: u64,
}
impl MaintenanceOutcome {
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
}
}
#[derive(Debug, Clone)]
pub struct Maintenance {
store: 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 {
store,
interval: Self::DEFAULT_INTERVAL,
max_windows: Self::DEFAULT_MAX_WINDOWS,
expire_snapshots: false,
}
}
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 archival = self.store.archive(now, self.max_windows).await?;
let snapshots_expired = if self.expire_snapshots {
self.store.expire_snapshots(now).await?
} else {
0
};
let status = self.store.status(now).await?;
let invariant_violations = status.invariant_violations.max(0) as u64;
if invariant_violations > 0 {
warn!(
table = self.store.table(),
invariant_violations, "rows are in the wrong tier: query results may be wrong"
);
}
let outcome = MaintenanceOutcome {
archival,
snapshots_expired,
invariant_violations,
};
info!(
table = self.store.table(),
windows = outcome.windows_archived(),
rows = outcome.rows_archived(),
snapshots_expired,
healthy = outcome.healthy(),
"maintenance cycle"
);
Ok(outcome)
}
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 table = self.store.table().to_string();
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!(table = %table, "maintenance stopped");
return;
}
_ = ticker.tick() => {
let now = OffsetDateTime::now_utc();
if let Err(e) = self.run_once(now).await {
warn!(table = %table, 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()
}
}
#[test]
fn an_outcome_sums_only_the_windows_that_moved_data() {
let outcome = MaintenanceOutcome {
archival: vec![archived(96), archived(4), idle()],
snapshots_expired: 0,
invariant_violations: 0,
};
assert_eq!(outcome.rows_archived(), 100);
assert_eq!(outcome.windows_archived(), 2);
}
#[test]
fn health_is_exactly_the_absence_of_violations() {
let bad = MaintenanceOutcome {
invariant_violations: 1,
..Default::default()
};
assert!(!bad.healthy());
assert!(MaintenanceOutcome::default().healthy());
}
#[test]
fn contention_is_reported_and_is_not_a_failure() {
let outcome = MaintenanceOutcome {
archival: vec![contended()],
..Default::default()
};
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) };
}
}