#![allow(clippy::unwrap_used, clippy::expect_used)]
use arrow::array::{Float64Array, Int64Array};
use arrow::record_batch::RecordBatch;
use chronix::prelude::*;
use chronix::{Chronix, fields, tags};
use chronix_query::aggregate::AggFn;
const SEC: i64 = 1_000_000_000;
fn open(dir: &tempfile::TempDir) -> Chronix {
let config = ChronixConfig::builder()
.data_dir(dir.path())
.wal_fsync_policy(chronix_core::FsyncPolicy::Periodic(
std::time::Duration::from_secs(1),
))
.build()
.unwrap();
Chronix::open(config).unwrap()
}
fn buckets(batches: &[RecordBatch]) -> Vec<(i64, f64)> {
let mut out = Vec::new();
for b in batches {
let ts = b
.column(b.schema().index_of(chronix_core::TIME_COLUMN).unwrap())
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
let vi = (0..b.num_columns())
.find(|i| b.schema().field(*i).name() != chronix_core::TIME_COLUMN)
.unwrap();
let v = b
.column(vi)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
for i in 0..b.num_rows() {
out.push((ts.value(i), v.value(i)));
}
}
out
}
#[test]
fn downsample_emits_one_row_per_interval_across_chunk_boundaries() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
let key = SeriesKey::new("m", tags! { "h" => "a" }).unwrap();
const POINTS: i64 = 70_000;
let batch: Vec<Point> = (0..POINTS)
.map(|i| Point::new(key.clone(), fields! { "v" => 1.0 }, i * SEC).unwrap())
.collect();
db.insert_batch(&batch).unwrap().into_complete().unwrap();
let plan = db
.query()
.measurement("m")
.range(0, i64::MAX)
.downsample(
TimeBucket::fixed(std::time::Duration::from_secs(60)),
AggFn::Sum,
)
.build()
.unwrap();
let rows = buckets(&db.execute_stream(&plan).unwrap());
let mut seen: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
for (ts, _) in &rows {
*seen.entry(*ts).or_default() += 1;
}
let dupes: Vec<_> = seen.iter().filter(|(_, n)| **n > 1).collect();
assert!(
dupes.is_empty(),
"{} interval(s) split across chunk boundaries, e.g. {:?}",
dupes.len(),
dupes.iter().take(3).collect::<Vec<_>>()
);
let full_minutes = POINTS / 60;
for (ts, v) in &rows {
if *ts / (60 * SEC) < full_minutes {
assert!(
(*v - 60.0).abs() < f64::EPSILON,
"bucket at {ts} summed to {v}, expected 60"
);
}
}
let total: f64 = rows.iter().map(|(_, v)| v).sum();
assert!(
(total - POINTS as f64).abs() < 1e-6,
"downsample lost or duplicated points: {total} vs {POINTS}"
);
}
#[test]
fn downsample_is_correct_across_segment_buckets() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
let key = SeriesKey::new("m", tags! { "h" => "a" }).unwrap();
const PER_HOUR: i64 = 3_600;
for hour in 0..3i64 {
let batch: Vec<Point> = (0..PER_HOUR)
.map(|i| {
Point::new(
key.clone(),
fields! { "v" => 1.0 },
hour * 3_600 * SEC + i * SEC,
)
.unwrap()
})
.collect();
db.insert_batch(&batch).unwrap().into_complete().unwrap();
db.flush().unwrap();
}
let plan = db
.query()
.measurement("m")
.range(0, i64::MAX)
.downsample(
TimeBucket::fixed(std::time::Duration::from_secs(60)),
AggFn::Sum,
)
.build()
.unwrap();
let rows = buckets(&db.execute_stream(&plan).unwrap());
assert_eq!(
rows.len(),
3 * 60,
"expected one row per minute over 3 hours"
);
for (ts, v) in &rows {
assert!(
(*v - 60.0).abs() < f64::EPSILON,
"bucket at {ts} summed to {v}, expected 60"
);
}
}
#[cfg(feature = "sql")]
#[test]
fn the_native_plan_and_sql_agree_about_a_transition_day() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
let start = 1_743_206_400_i64 * SEC; let key = SeriesKey::new("dst", tags! { "host" => "a" }).unwrap();
for h in 0..48 {
let ts = start + h * 3600 * SEC;
db.insert(&Point::new(key.clone(), fields! { "v" => 1.0_f64 }, ts).unwrap())
.unwrap();
}
db.flush().unwrap();
let bucket = TimeBucket::parse("1d", Some("Europe/Berlin")).unwrap();
let plan = db
.query()
.measurement("dst")
.downsample(bucket, AggFn::Count)
.build()
.unwrap();
let native = buckets(&[db.execute(&plan).unwrap()]);
let sql = db
.sql(
"SELECT time_bucket('1d', _time, 'Europe/Berlin') AS b, COUNT(v) AS c \
FROM dst GROUP BY b ORDER BY b",
)
.unwrap();
let mut from_sql: Vec<(i64, f64)> = Vec::new();
for batch in &sql {
let ts = batch
.column(0)
.as_any()
.downcast_ref::<arrow::array::TimestampNanosecondArray>()
.unwrap();
let c = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
for i in 0..batch.num_rows() {
#[allow(clippy::cast_precision_loss)]
from_sql.push((ts.value(i), c.value(i) as f64));
}
}
assert_eq!(
native, from_sql,
"the native downsample and time_bucket() must bucket identically"
);
let counts: Vec<f64> = native.iter().map(|(_, c)| *c).collect();
assert!(
counts.contains(&23.0),
"the Berlin day of the spring transition holds 23 hourly points, got {counts:?}"
);
}
#[test]
fn a_native_monthly_bucket_follows_the_calendar() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
let feb1 = 1_738_368_000_i64 * SEC; let key = SeriesKey::new("cal", tags! { "host" => "a" }).unwrap();
for d in 0..59 {
let ts = feb1 + d * 86_400 * SEC;
db.insert(&Point::new(key.clone(), fields! { "v" => 1.0_f64 }, ts).unwrap())
.unwrap();
}
db.flush().unwrap();
let plan = db
.query()
.measurement("cal")
.downsample(TimeBucket::parse("1mo", None).unwrap(), AggFn::Count)
.build()
.unwrap();
let got = buckets(&[db.execute(&plan).unwrap()]);
let counts: Vec<f64> = got.iter().map(|(_, c)| *c).collect();
assert_eq!(counts, vec![28.0, 31.0], "got {got:?}");
}