#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeMap;
use chronix::prelude::*;
use chronix::{Chronix, fields, tags};
use tempfile::TempDir;
fn open_db(dir: &TempDir) -> Chronix {
let config = ChronixConfig::builder()
.data_dir(dir.path())
.build()
.unwrap();
Chronix::open(config).unwrap()
}
fn point(host: &str, ts: i64) -> Point {
let key = SeriesKey::new("cpu", tags! { "host" => host }).unwrap();
Point::new(key, fields! { "v" => ts as f64 }, ts).unwrap()
}
fn visible_timestamps(db: &Chronix) -> Vec<i64> {
let plan = db
.query()
.measurement("cpu")
.range(0, i64::MAX)
.build()
.unwrap();
let batch = db.execute(&plan).unwrap();
let Some(col) = batch.column_by_name(chronix_core::TIME_COLUMN) else {
return Vec::new();
};
let arr = col
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.unwrap();
let mut out: Vec<i64> = (0..arr.len()).map(|i| arr.value(i)).collect();
out.sort_unstable();
out
}
fn host_tags(host: &str) -> BTreeMap<String, String> {
tags! { "host" => host }
}
#[test]
fn a_ranged_delete_leaves_points_outside_the_range() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000, 4_000] {
db.insert(&point("a", ts)).unwrap();
}
db.flush().unwrap();
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.range(2_000, 3_000)
.build()
.unwrap();
let outcome = db.execute_delete(&req).unwrap();
assert!(outcome.is_complete(), "delete must scan every segment");
assert_eq!(
visible_timestamps(&db),
vec![1_000, 4_000],
"a delete of [2000,3000] must not touch 1000 or 4000"
);
}
#[test]
fn a_ranged_delete_leaves_last_value_intact() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000, 4_000] {
db.insert(&point("a", ts)).unwrap();
}
db.flush().unwrap();
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.range(2_000, 3_000)
.build()
.unwrap();
db.execute_delete(&req).unwrap();
let last = db.last_value("cpu", &host_tags("a")).unwrap();
assert_eq!(
last.map(|p| p.timestamp()),
Some(4_000),
"the point at 4000 is outside the deleted range and is still the last value"
);
}
#[test]
fn a_ranged_delete_stays_ranged_across_a_restart() {
let tmp = TempDir::new().unwrap();
{
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000, 4_000] {
db.insert(&point("a", ts)).unwrap();
}
db.flush().unwrap();
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.range(2_000, 3_000)
.build()
.unwrap();
db.execute_delete(&req).unwrap();
assert_eq!(visible_timestamps(&db), vec![1_000, 4_000]);
db.close().unwrap();
}
let db = open_db(&tmp);
assert_eq!(
visible_timestamps(&db),
vec![1_000, 4_000],
"replaying the delete must restore the range, not a full-series tombstone"
);
}
#[test]
fn writing_after_delete_series_recreates_the_series() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
db.insert(&point("a", 1_000)).unwrap();
db.flush().unwrap();
db.delete_series("cpu", &host_tags("a")).unwrap();
assert!(
visible_timestamps(&db).is_empty(),
"the delete must take effect"
);
db.insert(&point("a", 5_000)).unwrap();
assert_eq!(
visible_timestamps(&db),
vec![5_000],
"a write after a delete must be visible, not silently swallowed"
);
assert_eq!(
db.last_value("cpu", &host_tags("a"))
.unwrap()
.map(|p| p.timestamp()),
Some(5_000),
"last_value must see the re-created series too"
);
}
#[test]
fn a_recreated_series_survives_flush_and_restart() {
let tmp = TempDir::new().unwrap();
{
let db = open_db(&tmp);
db.insert(&point("a", 1_000)).unwrap();
db.flush().unwrap();
db.delete_series("cpu", &host_tags("a")).unwrap();
db.insert(&point("a", 5_000)).unwrap();
db.flush().unwrap();
assert_eq!(visible_timestamps(&db), vec![5_000]);
db.close().unwrap();
}
let db = open_db(&tmp);
assert_eq!(
visible_timestamps(&db),
vec![5_000],
"the re-created point must survive replay; the tombstone predates it"
);
}
#[test]
fn an_unranged_delete_still_removes_the_whole_series() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000, 4_000] {
db.insert(&point("a", ts)).unwrap();
db.insert(&point("b", ts)).unwrap();
}
db.flush().unwrap();
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.build()
.unwrap();
db.execute_delete(&req).unwrap();
assert_eq!(
visible_timestamps(&db),
vec![1_000, 2_000, 3_000, 4_000],
"host=b is untouched and keeps all four points"
);
assert_eq!(
db.last_value("cpu", &host_tags("a")).unwrap(),
None,
"host=a is fully deleted"
);
}
#[test]
fn a_delete_survives_wal_truncation_and_restart() {
let tmp = TempDir::new().unwrap();
let config = ChronixConfig::builder()
.data_dir(tmp.path())
.wal_max_file_size(4096)
.build()
.unwrap();
{
let db = Chronix::open(config.clone()).unwrap();
db.insert(&point("doomed", 1_000)).unwrap();
db.flush().unwrap();
db.delete_series("cpu", &host_tags("doomed")).unwrap();
for round in 0..20i64 {
for ts in 0..20i64 {
db.insert(&point("keeper", 10_000 + round * 20 + ts))
.unwrap();
}
db.flush().unwrap();
}
db.close().unwrap();
}
let db = Chronix::open(config).unwrap();
assert_eq!(
db.last_value("cpu", &host_tags("doomed")).unwrap(),
None,
"the delete must outlive the WAL record that carried it"
);
assert!(
db.last_value("cpu", &host_tags("keeper"))
.unwrap()
.is_some(),
"the surviving series must still be there — this is a delete, not a wipe"
);
}
#[test]
fn reclaiming_tombstones_does_not_resurrect_deleted_rows() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000] {
db.insert(&point("a", ts)).unwrap();
db.insert(&point("b", ts)).unwrap();
}
db.flush().unwrap();
db.delete_series("cpu", &host_tags("a")).unwrap();
let before = visible_timestamps(&db);
db.gc_tombstones();
assert_eq!(
visible_timestamps(&db),
before,
"reclaiming tombstones must not make deleted rows visible again"
);
assert_eq!(
db.last_value("cpu", &host_tags("a")).unwrap(),
None,
"host=a stays deleted after a reclaim pass"
);
}
#[test]
fn a_delete_survives_compaction() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for round in 0..4i64 {
for ts in 0..50 {
db.insert(&point("a", round * 1_000 + ts)).unwrap();
db.insert(&point("b", round * 1_000 + ts)).unwrap();
}
db.flush().unwrap();
}
db.delete_series("cpu", &host_tags("a")).unwrap();
db.compact().unwrap();
db.gc_tombstones();
assert_eq!(
db.last_value("cpu", &host_tags("a")).unwrap(),
None,
"host=a must stay deleted through compaction and tombstone reclaim"
);
assert!(
db.last_value("cpu", &host_tags("b")).unwrap().is_some(),
"host=b is untouched"
);
}
#[test]
fn a_ranged_delete_keeps_the_series_in_the_cardinality_budget() {
let tmp = TempDir::new().unwrap();
let db = open_db(&tmp);
for ts in [1_000, 2_000, 3_000, 4_000] {
db.insert(&point("a", ts)).unwrap();
}
db.flush().unwrap();
assert_eq!(db.statistics().series_count, 1);
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.range(2_000, 3_000)
.build()
.unwrap();
db.execute_delete(&req).unwrap();
assert_eq!(
db.statistics().series_count,
1,
"the series still holds data at 1000 and 4000"
);
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.before(3_000)
.build()
.unwrap();
db.execute_delete(&req).unwrap();
assert_eq!(
db.statistics().series_count,
1,
"a delete bounded above must not release a series that outlives it"
);
assert_eq!(
db.last_value("cpu", &host_tags("a"))
.unwrap()
.map(|p| p.timestamp()),
Some(4_000),
"and the surviving point is still readable"
);
let req = db
.delete_builder()
.measurement("cpu")
.tag("host", "a")
.build()
.unwrap();
db.execute_delete(&req).unwrap();
assert_eq!(
db.statistics().series_count,
0,
"an unbounded delete returns the series to the budget"
);
}
#[test]
fn a_write_after_a_delete_is_visible_inside_the_deleted_interval() {
let dir = TempDir::new().unwrap();
let db = open_db(&dir);
for ts in [1000, 2000, 3000] {
db.insert(&point("a", ts)).unwrap();
}
db.flush().unwrap();
db.execute_delete(&chronix::DeleteRequest {
measurement: "cpu".into(),
tag_filters: vec![("host".into(), "a".into())],
time_start: Some(1500),
time_end: Some(2500),
})
.unwrap();
assert_eq!(visible_timestamps(&db), vec![1000, 3000]);
db.backfill(&[point("a", 2000)])
.unwrap()
.into_complete()
.unwrap();
assert_eq!(visible_timestamps(&db), vec![1000, 2000, 3000]);
db.flush().unwrap();
assert_eq!(visible_timestamps(&db), vec![1000, 2000, 3000]);
db.compact().unwrap();
db.gc().unwrap();
assert_eq!(visible_timestamps(&db), vec![1000, 2000, 3000]);
db.close().unwrap();
}
#[test]
fn an_unclean_restart_replays_writes_over_a_tombstone() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let config = |path: &std::path::Path| {
ChronixConfig::builder()
.data_dir(path)
.maintenance_interval(std::time::Duration::from_secs(86_400 * 365))
.build()
.unwrap()
};
if std::env::var("CHRONIX_TOMBSTONE_REPLAY_CHILD").is_ok() {
let dir = std::path::PathBuf::from(std::env::var("CHRONIX_TOMBSTONE_REPLAY_DIR").unwrap());
let db = Chronix::open(config(&dir)).unwrap();
for ts in 0..20i64 {
db.insert(&point("doomed", 1_000 + ts)).unwrap();
db.insert(&point("keeper", 1_000 + ts)).unwrap();
}
db.delete_series("cpu", &host_tags("doomed")).unwrap();
assert_eq!(db.last_value("cpu", &host_tags("doomed")).unwrap(), None);
for ts in 0..20i64 {
db.insert(&point("doomed", 5_000 + ts)).unwrap();
db.insert(&point("keeper", 5_000 + ts)).unwrap();
}
std::process::abort();
}
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"an_unclean_restart_replays_writes_over_a_tombstone",
"--exact",
"--nocapture",
])
.env("CHRONIX_TOMBSTONE_REPLAY_CHILD", "1")
.env("CHRONIX_TOMBSTONE_REPLAY_DIR", &dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert!(!status.success(), "the child must abort, not exit cleanly");
let db = Chronix::open(config(&dir)).unwrap();
assert!(
db.wal_replayed_records() > 0,
"the point of this test is that there was something to replay"
);
let doomed = db
.last_value("cpu", &host_tags("doomed"))
.unwrap()
.expect("a write after a delete re-creates the series");
assert!(
doomed.timestamp() >= 5_000,
"the replayed write is the newest point, not a resurrected one: {}",
doomed.timestamp()
);
let doomed_seen = visible_timestamps_for(&db, "doomed");
assert!(
doomed_seen.iter().all(|ts| *ts >= 5_000),
"the tombstone still masks the deleted range after replay: {doomed_seen:?}"
);
assert_eq!(doomed_seen.len(), 20);
assert_eq!(visible_timestamps_for(&db, "keeper").len(), 40);
db.close().unwrap();
}
fn visible_timestamps_for(db: &Chronix, host: &str) -> Vec<i64> {
let plan = db
.query()
.measurement("cpu")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
let batch = db.execute(&plan).unwrap();
let (Some(time), Some(hosts)) = (
batch.column_by_name(chronix_core::TIME_COLUMN),
batch.column_by_name("host"),
) else {
return Vec::new();
};
let time = time
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.unwrap();
let hosts = hosts
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
let mut out: Vec<i64> = (0..time.len())
.filter(|i| hosts.value(*i) == host)
.map(|i| time.value(i))
.collect();
out.sort_unstable();
out
}