#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use chronix::{Pipeline, PipelineConfig};
use chronix_streaming::signal::error::Result as SignalResult;
use chronix_streaming::signal::{DeliveryChannel, Severity, SignalEvent};
struct Recorder {
name: String,
seen: Arc<AtomicUsize>,
}
impl DeliveryChannel for Recorder {
fn name(&self) -> &str {
&self.name
}
fn deliver(&self, _event: &SignalEvent) -> SignalResult<()> {
self.seen.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
fn signal_for(trigger: &str, targets: &[&str]) -> SignalEvent {
SignalEvent {
event_id: "e1".into(),
trigger_id: trigger.into(),
trigger_name: trigger.into(),
measurement: "cpu".into(),
tags: Default::default(),
timestamp: 0,
signal_type: "threshold".into(),
severity: Severity::Warning,
value: 1.0,
metadata: Default::default(),
delivery_targets: targets.iter().map(|s| (*s).to_string()).collect(),
}
}
const FLUSH: std::time::Duration = std::time::Duration::from_secs(5);
#[test]
fn a_signal_goes_only_to_the_channels_its_trigger_named() {
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "chan-a".into(),
seen: a.clone(),
}));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "chan-b".into(),
seen: b.clone(),
}));
pipeline
.delivery_router()
.deliver(&signal_for("t1", &["chan-a"]));
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.load(Ordering::SeqCst), 0, "chan-b was not asked for");
pipeline
.delivery_router()
.deliver(&signal_for("t2", &["chan-b"]));
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.load(Ordering::SeqCst), 1);
}
#[test]
fn a_signal_with_no_targets_goes_to_every_channel() {
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
for (name, seen) in [("chan-a", &a), ("chan-b", &b)] {
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: name.into(),
seen: seen.clone(),
}));
}
pipeline
.delivery_router()
.deliver(&signal_for("alert", &[]));
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.load(Ordering::SeqCst), 1);
}
#[test]
fn a_webhook_trigger_needs_a_configured_signing_secret() {
let pipeline = Pipeline::with_config(PipelineConfig::default());
let err = pipeline
.execute_signal_sql(
"CREATE TRIGGER t ON cpu WHEN value > 1.0 \
DELIVER webhook('https://alerts.example.com/hook')",
)
.expect_err("an unsignable webhook must be refused, not accepted and dropped");
assert!(
err.to_string().contains("webhook_signing_secrets"),
"the error must name what is missing, got: {err}"
);
}
#[test]
fn a_webhook_trigger_registers_a_channel_named_by_its_url() {
let pipeline = Pipeline::with_config(PipelineConfig {
webhook_signing_secrets: vec!["s3cret".into()],
..PipelineConfig::default()
});
pipeline
.execute_signal_sql(
"CREATE TRIGGER t1 ON cpu WHEN value > 1.0 \
DELIVER webhook('https://alerts.example.com/a')",
)
.expect("a signable webhook trigger must be accepted");
assert!(
pipeline
.delivery_router()
.has_channel("webhook:https://alerts.example.com/a")
);
let before = pipeline.delivery_router().channel_count();
pipeline
.execute_signal_sql(
"CREATE TRIGGER t2 ON cpu WHEN value > 2.0 \
DELIVER webhook('https://alerts.example.com/b')",
)
.unwrap();
assert_eq!(
pipeline.delivery_router().channel_count(),
before + 1,
"a second URL is a second channel"
);
pipeline
.execute_signal_sql(
"CREATE TRIGGER t3 ON cpu WHEN value > 3.0 \
DELIVER webhook('https://alerts.example.com/a')",
)
.unwrap();
assert_eq!(
pipeline.delivery_router().channel_count(),
before + 1,
"the same URL must not build a second channel"
);
}
#[test]
fn broker_delivery_is_refused_with_the_alternative() {
let pipeline = Pipeline::with_config(PipelineConfig::default());
let err = pipeline
.execute_signal_sql("CREATE TRIGGER t ON cpu WHEN value > 1.0 DELIVER nats('a.b')")
.expect_err("a channel that does not exist must be refused");
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn a_write_reaches_the_channel_the_trigger_named() {
use chronix_core::FieldValue;
use chronix_streaming::cdc::CdcEvent;
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
let hot = Arc::new(AtomicUsize::new(0));
let unused = Arc::new(AtomicUsize::new(0));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "log".into(),
seen: hot.clone(),
}));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "unused".into(),
seen: unused.clone(),
}));
pipeline
.execute_signal_sql("CREATE TRIGGER hot_cpu ON cpu WHEN value > 90.0 DELIVER log")
.expect("the trigger must be accepted");
let mut fields = std::collections::BTreeMap::new();
fields.insert("value".to_string(), FieldValue::F64(95.0));
let fired = pipeline.process_cdc_event(&CdcEvent::PointWritten {
measurement: "cpu".into(),
tags: std::collections::BTreeMap::new(),
fields,
timestamp: 1_000_000_000,
seq: 1,
});
assert_eq!(fired, 1, "the trigger must fire on a breaching write");
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(
hot.load(Ordering::SeqCst),
1,
"the signal must reach the channel the DELIVER clause named"
);
assert_eq!(unused.load(Ordering::SeqCst), 0, "and no other channel");
let mut fields = std::collections::BTreeMap::new();
fields.insert("value".to_string(), FieldValue::F64(10.0));
pipeline.process_cdc_event(&CdcEvent::PointWritten {
measurement: "cpu".into(),
tags: std::collections::BTreeMap::new(),
fields,
timestamp: 2_000_000_000,
seq: 2,
});
assert_eq!(hot.load(Ordering::SeqCst), 1);
}
fn point_event(namespace: Option<&str>, value: f64, seq: u64) -> chronix_streaming::cdc::CdcEvent {
use chronix_core::FieldValue;
let mut tags = std::collections::BTreeMap::new();
if let Some(ns) = namespace {
tags.insert(chronix_core::NAMESPACE_TAG.to_string(), ns.to_string());
}
let mut fields = std::collections::BTreeMap::new();
fields.insert("value".to_string(), FieldValue::F64(value));
chronix_streaming::cdc::CdcEvent::PointWritten {
measurement: "cpu".into(),
tags,
fields,
timestamp: seq as i64 * 1_000_000_000,
seq,
}
}
#[test]
fn a_scoped_trigger_only_sees_its_own_namespace() {
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
let seen = Arc::new(AtomicUsize::new(0));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "log".into(),
seen: seen.clone(),
}));
pipeline
.execute_signal_sql_scoped(
"CREATE TRIGGER hot ON cpu WHEN value > 90.0 DELIVER log",
Some("tenant-a"),
)
.unwrap();
pipeline.process_cdc_event(&point_event(Some("tenant-b"), 99.0, 1));
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(
seen.load(Ordering::SeqCst),
0,
"a trigger must not fire on another tenant's data"
);
pipeline.process_cdc_event(&point_event(Some("tenant-a"), 99.0, 2));
assert!(pipeline.delivery_router().flush(FLUSH));
assert_eq!(seen.load(Ordering::SeqCst), 1);
}
#[test]
fn trigger_names_are_per_namespace() {
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "log".into(),
seen: Arc::new(AtomicUsize::new(0)),
}));
for ns in ["tenant-a", "tenant-b"] {
pipeline
.execute_signal_sql_scoped(
"CREATE TRIGGER hot ON cpu WHEN value > 90.0 DELIVER log",
Some(ns),
)
.unwrap_or_else(|e| panic!("{ns} must be able to name its trigger `hot`: {e}"));
}
for ns in ["tenant-a", "tenant-b"] {
let result = pipeline
.execute_signal_sql_scoped("SHOW TRIGGERS", Some(ns))
.unwrap();
let chronix_streaming::signal::SqlResult::Triggers(triggers) = result else {
panic!("expected a trigger listing");
};
assert_eq!(triggers.len(), 1, "{ns} must see only its own trigger");
assert_eq!(triggers[0].name, "hot", "the tenant's own name comes back");
}
pipeline
.execute_signal_sql_scoped("DROP TRIGGER hot", Some("tenant-a"))
.unwrap();
let result = pipeline
.execute_signal_sql_scoped("SHOW TRIGGERS", Some("tenant-b"))
.unwrap();
let chronix_streaming::signal::SqlResult::Triggers(triggers) = result else {
panic!("expected a trigger listing");
};
assert_eq!(triggers.len(), 1, "tenant-b's trigger must survive");
}
#[test]
fn a_tenant_cannot_drop_another_tenants_trigger() {
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "log".into(),
seen: Arc::new(AtomicUsize::new(0)),
}));
pipeline
.execute_signal_sql_scoped(
"CREATE TRIGGER hot ON cpu WHEN value > 90.0 DELIVER log",
Some("tenant-a"),
)
.unwrap();
assert!(
pipeline
.execute_signal_sql_scoped("DROP TRIGGER hot", Some("tenant-b"))
.is_err(),
"tenant-b must not be able to drop tenant-a's trigger"
);
assert!(
pipeline
.execute_signal_sql_scoped("ALTER TRIGGER hot DISABLE", Some("tenant-b"))
.is_err(),
"nor disable it"
);
}
#[test]
fn a_restored_trigger_keeps_its_delivery_channel() {
let dir = tempfile::tempdir().unwrap();
let catalog = dir.path().join("triggers.json");
let config = || PipelineConfig {
trigger_catalog_path: Some(catalog.clone()),
webhook_signing_secrets: vec!["s3cret".into()],
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
};
{
let pipeline = Pipeline::with_config(config());
pipeline
.execute_signal_sql(
"CREATE TRIGGER t ON cpu WHEN value > 1.0 \
DELIVER webhook('https://alerts.example.com/hook')",
)
.unwrap();
assert!(
pipeline
.delivery_router()
.has_channel("webhook:https://alerts.example.com/hook")
);
}
let reopened = Pipeline::with_config(config());
let listed = reopened.execute_signal_sql("SHOW TRIGGERS").unwrap();
let chronix_streaming::signal::SqlResult::Triggers(triggers) = listed else {
panic!("expected a trigger listing");
};
assert_eq!(triggers.len(), 1, "the trigger must be restored");
assert!(
reopened
.delivery_router()
.has_channel("webhook:https://alerts.example.com/hook"),
"and so must the channel it delivers to"
);
}
#[test]
fn a_signal_is_stored_even_when_delivery_fails() {
struct AlwaysFails;
impl DeliveryChannel for AlwaysFails {
fn name(&self) -> &str {
"log"
}
fn deliver(&self, _event: &SignalEvent) -> SignalResult<()> {
Err(chronix_streaming::signal::SignalError::InvalidConfig(
"no".into(),
))
}
}
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
pipeline
.delivery_router()
.add_channel(Box::new(AlwaysFails));
pipeline
.execute_signal_sql("CREATE TRIGGER hot ON cpu WHEN value > 90.0 DELIVER log")
.unwrap();
pipeline.process_cdc_event(&point_event(None, 99.0, 1));
assert_eq!(
pipeline.signal_store().all().len(),
1,
"the signal must be recorded even though every delivery attempt failed"
);
}
#[test]
fn a_trigger_can_watch_a_rollup_tier() {
use chronix::prelude::*;
use std::time::Duration;
const MIN: i64 = 60_000_000_000;
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Chronix::open(
ChronixConfig::builder()
.data_dir(dir.path())
.shard_duration(Duration::from_secs(600))
.build()
.unwrap(),
)
.unwrap(),
);
db.create_rollup(
RollupBuilder::new()
.name("r")
.source("raw")
.target("raw_1m")
.bucket(TimeBucket::fixed_ns(MIN))
.aggregation(RollupAggFn::Avg)
.group_by("h")
.build()
.unwrap(),
)
.unwrap();
let pipeline = Pipeline::with_config(PipelineConfig {
enable_log_delivery: false,
enable_metric_delivery: false,
..PipelineConfig::default()
});
let seen = Arc::new(AtomicUsize::new(0));
pipeline.delivery_router().add_channel(Box::new(Recorder {
name: "log".into(),
seen: seen.clone(),
}));
pipeline
.execute_signal_sql("CREATE TRIGGER hot_avg ON raw_1m WHEN v_avg > 100.0 DELIVER log")
.expect("a trigger on a rollup target must be accepted");
let mut points = Vec::new();
for i in 0..60i64 {
points.push(
Point::new(
SeriesKey::new("raw", chronix::tags! { "h" => "a" }).unwrap(),
chronix::fields! { "v" => 500.0 + i as f64 },
i * 1_000_000_000,
)
.unwrap(),
);
}
db.insert_batch(&points).unwrap().into_complete().unwrap();
db.insert(
&Point::new(
SeriesKey::new("raw", chronix::tags! { "h" => "a" }).unwrap(),
chronix::fields! { "v" => 1.0 },
40 * MIN,
)
.unwrap(),
)
.unwrap();
db.flush().unwrap();
let mut sub = db.event_bus().try_subscribe().expect("subscribe");
let written = db.materialise_rollups().unwrap();
assert!(written > 0, "the rollup must have materialised something");
let mut fired = 0;
while let Some(event) = sub.try_recv() {
fired += pipeline.process_cdc_event(&event);
}
assert!(pipeline.delivery_router().flush(FLUSH));
assert!(fired > 0, "a trigger on a rollup tier must fire");
assert!(
seen.load(Ordering::SeqCst) > 0,
"and its signal must reach the channel it named"
);
}