#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeSet;
use std::sync::Arc;
use arrow::array::Array;
use chronix::prelude::*;
use chronix::{Chronix, fields, tags};
use datafusion::prelude::SessionContext;
const GAPS: [usize; 3] = [7, 8, 60];
const ROWS: usize = 120;
fn db_with_gaps() -> (tempfile::TempDir, Arc<Chronix>) {
let dir = tempfile::tempdir().unwrap();
let config = ChronixConfig::builder()
.data_dir(dir.path())
.build()
.unwrap();
let db = Arc::new(Chronix::open(config).unwrap());
let key = SeriesKey::new("m", tags! { "host" => "a" }).unwrap();
let mut points = Vec::new();
for i in 0..ROWS {
let t = i as i64 * 1_000_000_000;
let v = 100.0 + 0.5 * i as f64 + 10.0 * (i as f64 * std::f64::consts::TAU / 12.0).sin();
let w = 50.0 + (i % 7) as f64;
let f = if GAPS.contains(&i) {
fields! { "w" => w }
} else {
fields! { "v" => v, "w" => w }
};
points.push(Point::new(key.clone(), f, t).unwrap());
}
let _ = db.insert_batch(&points).unwrap();
db.flush().unwrap();
(dir, db)
}
async fn col(ctx: &SessionContext, expr: &str) -> Vec<Option<f64>> {
let q = format!("SELECT {expr} OVER (PARTITION BY host ORDER BY _time) FROM m");
let batches = ctx.sql(&q).await.unwrap().collect().await.unwrap();
batches
.iter()
.flat_map(|b| {
let c = b
.column(0)
.as_any()
.downcast_ref::<arrow::array::Float64Array>()
.unwrap();
(0..c.len())
.map(|i| (!c.is_null(i)).then(|| c.value(i)))
.collect::<Vec<_>>()
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Reads {
TheWindow,
ThisRow,
}
use Reads::{TheWindow, ThisRow};
const STATISTICS: &[(&str, &str, Reads)] = &[
("rolling_mean", "rolling_mean(v, 4)", TheWindow),
("rolling_std", "rolling_std(v, 4)", TheWindow),
("rolling_corr", "rolling_corr(v, w, 5)", TheWindow),
("ewm", "ewm(v, 0.3)", TheWindow),
("correlation", "correlation(v, w)", TheWindow),
("cross_correlation", "cross_correlation(v, w, 1)", TheWindow),
("zscore", "zscore(v)", ThisRow),
("stl_trend", "stl_trend(v, 12)", ThisRow),
("stl_seasonal", "stl_seasonal(v, 12)", ThisRow),
("stl_residual", "stl_residual(v, 12)", ThisRow),
("anomaly_score", "anomaly_score(v, 3.0)", ThisRow),
(
"multivariate_anomaly",
"multivariate_anomaly(v, w, 3.0)",
ThisRow,
),
];
#[tokio::test]
async fn a_gap_nulls_its_own_rows_and_no_others() {
let (_dir, db) = db_with_gaps();
let ctx = chronix::sql::create_session_context(db);
for &(name, expr, reads) in STATISTICS {
let out = col(&ctx, expr).await;
assert_eq!(out.len(), ROWS, "{name}: wrong row count");
for &g in &GAPS {
match reads {
ThisRow => assert!(
out[g].is_none(),
"{name} reads row {g}'s own sample, which is missing, but reported {:?}",
out[g]
),
TheWindow => assert!(
out[g].is_some(),
"{name} reads only the window, whose other rows have samples, \
yet row {g} is NULL"
),
}
}
assert!(
out[9].is_some(),
"{name}: still NULL at row 9, after the gap at rows 7-8 closed"
);
assert!(
out[ROWS - 1].is_some(),
"{name}: the gap nulled the rest of the partition — row {} is NULL",
ROWS - 1
);
let expected_nulls = if reads == ThisRow { GAPS.len() } else { 0 };
let nulls = out.iter().filter(|v| v.is_none()).count();
assert!(
nulls <= expected_nulls + 4,
"{name}: {nulls} NULL rows, expected at most {} (warm-up allows 4)",
expected_nulls + 4
);
}
}
#[tokio::test]
async fn row_arithmetic_nulls_only_the_rows_that_read_a_gap() {
let (_dir, db) = db_with_gaps();
let ctx = chronix::sql::create_session_context(db);
for expr in ["diff(v, 1)", "pct_change(v)"] {
let out = col(&ctx, expr).await;
assert!(out[0].is_none(), "{expr}: row 0 has no predecessor");
for &g in &GAPS {
assert!(out[g].is_none(), "{expr}: row {g} is a gap");
assert!(out[g + 1].is_none(), "{expr}: row {} reads the gap", g + 1);
}
assert!(out[10].is_some(), "{expr}: row 10 reads two present values");
assert!(
out[ROWS - 1].is_some(),
"{expr}: partition poisoned to the end"
);
}
}
#[tokio::test]
async fn a_gap_does_not_shift_the_seasonal_phase() {
let (_dir, gapped) = db_with_gaps();
let ctx_gapped = chronix::sql::create_session_context(gapped);
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Chronix::open(
ChronixConfig::builder()
.data_dir(dir.path())
.build()
.unwrap(),
)
.unwrap(),
);
let key = SeriesKey::new("m", tags! { "host" => "a" }).unwrap();
let mut points = Vec::new();
for i in 0..ROWS {
let v = 100.0 + 0.5 * i as f64 + 10.0 * (i as f64 * std::f64::consts::TAU / 12.0).sin();
points.push(
Point::new(
key.clone(),
fields! { "v" => v, "w" => 50.0 + (i % 7) as f64 },
i as i64 * 1_000_000_000,
)
.unwrap(),
);
}
let _ = db.insert_batch(&points).unwrap();
db.flush().unwrap();
let ctx_whole = chronix::sql::create_session_context(db);
let a = col(&ctx_gapped, "stl_seasonal(v, 12)").await;
let b = col(&ctx_whole, "stl_seasonal(v, 12)").await;
let mut worst = 0.0f64;
for i in 0..ROWS {
if let (Some(x), Some(y)) = (a[i], b[i]) {
worst = worst.max((x - y).abs());
}
}
assert!(
worst < 1.5,
"three missing samples moved the seasonal component by {worst:.3}; \
interpolating them costs 0.67 and dropping their rows costs 4.79"
);
}
#[tokio::test]
async fn stl_decompose_survives_a_gap() {
let (_dir, db) = db_with_gaps();
let ctx = chronix::sql::create_session_context(db);
let batches = ctx
.sql(
"SELECT d.trend, d.seasonal, d.residual FROM (SELECT \
stl_decompose(v, 12) OVER (PARTITION BY host ORDER BY _time) AS d FROM m)",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let total: usize = batches
.iter()
.map(arrow::array::RecordBatch::num_rows)
.sum();
assert_eq!(total, ROWS);
for b in &batches {
for c in 0..3 {
let a = b
.column(c)
.as_any()
.downcast_ref::<arrow::array::Float64Array>()
.unwrap();
assert!(
(0..a.len()).any(|i| !a.is_null(i)),
"component {c} is entirely NULL"
);
}
}
}
#[tokio::test]
async fn every_registered_window_function_has_a_null_answer_here() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Chronix::open(
ChronixConfig::builder()
.data_dir(dir.path())
.build()
.unwrap(),
)
.unwrap(),
);
let ctx = chronix::sql::create_session_context(db);
let builtin: BTreeSet<String> = SessionContext::new()
.state()
.window_functions()
.keys()
.map(ToString::to_string)
.collect();
let registered: BTreeSet<String> = ctx
.state()
.window_functions()
.keys()
.map(ToString::to_string)
.filter(|n| !builtin.contains(n))
.collect();
assert!(
registered.len() >= 15,
"expected chronix's own window functions, found {registered:?}"
);
let mut covered: BTreeSet<String> = STATISTICS
.iter()
.map(|(name, _, _)| (*name).to_string())
.collect();
covered.insert("diff".into());
covered.insert("pct_change".into());
covered.insert("stl_decompose".into());
let missing: Vec<&String> = registered.difference(&covered).collect();
assert!(
missing.is_empty(),
"these window functions have no NULL answer in this file: {missing:?}"
);
}