use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use tracing::{debug, warn};
use crate::db::{Chronix, DbInner};
const IDLE_TICK: Duration = Duration::from_secs(1);
struct AliveGuard(Arc<std::sync::atomic::AtomicBool>);
impl Drop for AliveGuard {
fn drop(&mut self) {
self.0.store(false, std::sync::atomic::Ordering::Release);
metrics::gauge!("chronix_maintenance_running").set(0.0);
}
}
pub(crate) fn spawn(db: &Chronix) {
let weak = Arc::downgrade(&db.inner);
let lifecycle = Arc::clone(&db.lifecycle);
let wake = Arc::clone(&db.maintenance_wake);
let stop = Arc::clone(&db.stop_maintenance);
let alive = Arc::clone(&db.maintenance_alive);
let interval = db.config.maintenance_interval;
let flush_threshold = db.config.memtable_flush_threshold;
alive.store(true, std::sync::atomic::Ordering::Release);
let handle = std::thread::Builder::new()
.name("chronix::maintenance".into())
.spawn(move || {
let _alive = AliveGuard(alive);
run(&weak, &lifecycle, &wake, &stop, interval, flush_threshold);
})
.expect("spawning the maintenance thread cannot fail on a working OS");
let _ = db.maintenance_thread_id.set(handle.thread().id());
*db.maintenance_thread.lock() = Some(handle);
}
fn run(
weak: &Weak<DbInner>,
lifecycle: &parking_lot::Mutex<()>,
wake: &(parking_lot::Mutex<bool>, parking_lot::Condvar),
stop: &std::sync::atomic::AtomicBool,
interval: Duration,
flush_threshold: usize,
) {
let mut last_pass = Instant::now();
let mut last_checkpoint: Option<Instant> = None;
let tick = if interval.is_zero() {
IDLE_TICK
} else {
interval.min(IDLE_TICK)
};
loop {
{
let (flag, cv) = wake;
let mut requested = flag.lock();
if !*requested {
cv.wait_for(&mut requested, tick);
}
*requested = false;
}
metrics::gauge!("chronix_maintenance_running").set(1.0);
if stop.load(std::sync::atomic::Ordering::Acquire) || weak.strong_count() == 0 {
return;
}
let Some(_lifecycle) = lifecycle.try_lock() else {
continue;
};
let Some(probe) = weak.upgrade() else { return };
if stop.load(std::sync::atomic::Ordering::Acquire) {
return;
}
let db = Chronix {
inner: probe,
maintenance: true,
};
let closed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
one_pass(
&db,
&mut last_pass,
&mut last_checkpoint,
interval,
flush_threshold,
)
}));
match closed {
Ok(true) => return,
Ok(false) => {}
Err(_) => {
metrics::counter!("chronix_maintenance_panics_total").increment(1);
warn!(
"maintenance: a pass panicked; the thread continues, but something \
is wrong — see the panic above"
);
}
}
drop(db);
}
}
fn one_pass(
db: &Chronix,
last_pass: &mut Instant,
last_checkpoint: &mut Option<Instant>,
interval: Duration,
flush_threshold: usize,
) -> bool {
if db.shards.total_memory() > flush_threshold {
match db.flush() {
Ok(results) => debug!(segments = results.len(), "maintenance: flushed"),
Err(crate::error::DbError::Closed) => return true,
Err(e) => warn!(error = %e, "maintenance: flush failed"),
}
}
if !interval.is_zero() && last_pass.elapsed() >= interval {
*last_pass = Instant::now();
match db.compact() {
Ok(tasks) if tasks > 0 => debug!(tasks, "maintenance: compacted"),
Ok(_) => {}
Err(crate::error::DbError::Closed) => return true,
Err(e) => warn!(error = %e, "maintenance: compaction failed"),
}
if let Err(e) = db.gc() {
warn!(error = %e, "maintenance: gc failed");
}
if db.has_retention_rules() {
match db.enforce_configured_retention() {
Ok(r) if r.segments_deleted > 0 => {
tracing::info!(
shards = r.shards_dropped,
segments = r.segments_deleted,
bytes = r.bytes_freed,
"maintenance: retention enforced"
);
}
Ok(_) => {}
Err(crate::error::DbError::Closed) => return true,
Err(e) => warn!(error = %e, "maintenance: retention failed"),
}
}
}
if let Some(every) = db.config.checkpoints.interval() {
let due = last_checkpoint.is_none_or(|t| t.elapsed() >= every);
if due {
*last_checkpoint = Some(Instant::now());
match db.scheduled_checkpoint() {
Ok(Some(path)) => {
tracing::info!(path = %path.display(), "maintenance: checkpoint taken");
}
Ok(None) => {}
Err(crate::error::DbError::Closed) => return true,
Err(e) => warn!(error = %e, "maintenance: checkpoint failed"),
}
}
}
false
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::time::Duration;
use chronix_core::{ChronixConfig, FieldValue, Point, SeriesKey};
use tempfile::TempDir;
use crate::db::Chronix;
fn test_point(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
let fields = BTreeMap::from([("value".to_string(), FieldValue::F64(value))]);
let key = SeriesKey::new(measurement, tags).unwrap();
Point::new(key, fields, ts).unwrap()
}
fn wait_until(deadline: Duration, mut cond: impl FnMut() -> bool) -> bool {
let end = std::time::Instant::now() + deadline;
while std::time::Instant::now() < end {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
cond()
}
#[test]
fn a_database_without_its_maintenance_thread_is_not_writable() {
let tmp = TempDir::new().unwrap();
let config = ChronixConfig::builder()
.data_dir(tmp.path())
.memtable_flush_threshold(64 * 1024)
.max_memtable_memory(64 * 1024)
.build()
.unwrap();
let db = Chronix::open(config).unwrap();
db.check_writable()
.expect("a freshly opened database is writable");
db.stop_maintenance
.store(true, std::sync::atomic::Ordering::Release);
db.wake_maintenance();
assert!(
wait_until(Duration::from_secs(5), || !db
.maintenance_alive
.load(std::sync::atomic::Ordering::Acquire)),
"the thread clears the liveness flag as it leaves"
);
let err = db
.check_writable()
.expect_err("a database with no maintenance thread is not writable");
assert!(
matches!(err, crate::error::DbError::PersistentOverload { .. }),
"expected a persistent overload, got {err}"
);
assert!(
err.to_string().contains("maintenance thread"),
"the reason names what is missing: {err}"
);
let mut ts = 1_700_000_000_000_000_000i64;
let mut saw_persistent = false;
for round in 0..200 {
let batch: Vec<Point> = (0..200)
.map(|i| {
ts += 1_000_000;
test_point("cpu", &format!("h{}", round * 200 + i), ts, 1.5)
})
.collect();
match db.insert_batch(&batch) {
Ok(_) => {}
Err(crate::error::DbError::PersistentOverload { .. }) => {
saw_persistent = true;
break;
}
Err(e) => panic!("expected a persistent overload, got {e}"),
}
}
assert!(
saw_persistent,
"a full memtable with no thread to flush it is not transient"
);
db.close().unwrap();
}
#[test]
fn a_full_memtable_is_flushed_by_the_thread() {
let tmp = TempDir::new().unwrap();
let config = ChronixConfig::builder()
.data_dir(tmp.path())
.memtable_flush_threshold(4 * 1024)
.maintenance_interval(Duration::ZERO)
.build()
.unwrap();
let db = Chronix::open(config).unwrap();
for i in 0..200u64 {
db.insert(&test_point(
"cpu",
"srv-1",
(i * 1_000_000_000) as i64,
i as f64,
))
.unwrap();
}
assert!(
wait_until(Duration::from_secs(10), || {
!db.segments_of("cpu").is_empty()
}),
"the maintenance thread should have flushed"
);
let plan = db
.query()
.measurement("cpu")
.range(0, i64::MAX)
.build()
.unwrap();
assert_eq!(db.execute(&plan).unwrap().num_rows(), 200);
db.close().unwrap();
}
#[test]
fn the_configured_retention_is_enforced_by_the_thread() {
let tmp = TempDir::new().unwrap();
let config = ChronixConfig::builder()
.data_dir(tmp.path())
.retention(Some(Duration::from_secs(3600)))
.maintenance_interval(Duration::from_millis(50))
.build()
.unwrap();
let db = Chronix::open(config).unwrap();
for i in 0..5u64 {
db.insert(&test_point(
"cpu",
"srv-1",
(i * 1_000_000_000) as i64,
i as f64,
))
.unwrap();
}
let now_ns = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
)
.unwrap();
db.insert(&test_point("cpu", "srv-1", now_ns, 99.0))
.unwrap();
db.flush().unwrap();
assert!(
wait_until(Duration::from_secs(10), || {
db.segments_of("cpu").len() == 1
}),
"the expired shard must have been dropped by the maintenance thread"
);
let plan = db
.query()
.measurement("cpu")
.range(0, i64::MAX)
.build()
.unwrap();
assert_eq!(db.execute(&plan).unwrap().num_rows(), 1);
db.close().unwrap();
}
#[test]
fn compaction_runs_on_the_interval_and_drop_is_clean() {
let tmp = TempDir::new().unwrap();
{
let db = Chronix::open(
ChronixConfig::builder()
.data_dir(tmp.path())
.maintenance_interval(Duration::ZERO)
.build()
.unwrap(),
)
.unwrap();
for i in 0..10u64 {
db.insert(&test_point(
"cpu",
"srv-1",
(i * 1_000_000_000) as i64,
i as f64,
))
.unwrap();
db.flush().unwrap();
}
db.close().unwrap();
}
let config = ChronixConfig::builder()
.data_dir(tmp.path())
.maintenance_interval(Duration::from_millis(50))
.build()
.unwrap();
let db = Chronix::open(config).unwrap();
let before = 10;
assert!(
wait_until(Duration::from_secs(10), || {
db.segments_of("cpu").len() < before
}),
"compaction should have reduced the segment count"
);
let plan = db
.query()
.measurement("cpu")
.range(0, i64::MAX)
.build()
.unwrap();
assert_eq!(db.execute(&plan).unwrap().num_rows(), 10);
drop(db);
let db = Chronix::open(
ChronixConfig::builder()
.data_dir(tmp.path())
.build()
.unwrap(),
)
.unwrap();
assert_eq!(db.wal_replayed_records(), 0);
db.close().unwrap();
}
}