|
| 1 | +//! End-to-end integration tests: precompute engine output equivalence |
| 2 | +//! with ArroYo sketch format. |
| 3 | +//! |
| 4 | +//! Each test: |
| 5 | +//! 1. Starts a PrecomputeEngine backed by a CapturingOutputSink |
| 6 | +//! 2. Sends Prometheus remote write samples via HTTP (Snappy-compressed protobuf) |
| 7 | +//! 3. Advances the watermark past the window boundary to close it |
| 8 | +//! 4. Drains captured outputs and verifies equivalence with ArroYo-format accumulators |
| 9 | +
|
| 10 | +use flate2::{write::GzEncoder, Compression}; |
| 11 | +use prost::Message; |
| 12 | +use serde_json::json; |
| 13 | +use sketch_db_common::aggregation_config::AggregationConfig; |
| 14 | +use sketch_core::kll::KllSketch; |
| 15 | +use std::collections::HashMap; |
| 16 | +use std::io::Write; |
| 17 | +use std::sync::Arc; |
| 18 | + |
| 19 | +use query_engine_rust::data_model::{PrecomputedOutput, StreamingConfig}; |
| 20 | +use query_engine_rust::drivers::ingest::prometheus_remote_write::{ |
| 21 | + Label, Sample, TimeSeries, WriteRequest, |
| 22 | +}; |
| 23 | +use query_engine_rust::precompute_engine::config::{LateDataPolicy, PrecomputeEngineConfig}; |
| 24 | +use query_engine_rust::precompute_engine::output_sink::CapturingOutputSink; |
| 25 | +use query_engine_rust::precompute_engine::PrecomputeEngine; |
| 26 | +use query_engine_rust::precompute_operators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; |
| 27 | +use query_engine_rust::precompute_operators::multiple_sum_accumulator::MultipleSumAccumulator; |
| 28 | + |
| 29 | +// ─── helpers ──────────────────────────────────────────────────────────────── |
| 30 | + |
| 31 | +fn make_agg_config( |
| 32 | + id: u64, |
| 33 | + metric: &str, |
| 34 | + agg_type: &str, |
| 35 | + agg_sub_type: &str, |
| 36 | + window_secs: u64, |
| 37 | + slide_secs: u64, |
| 38 | + grouping: Vec<&str>, |
| 39 | +) -> AggregationConfig { |
| 40 | + let window_type = if slide_secs == 0 || slide_secs == window_secs { |
| 41 | + "tumbling" |
| 42 | + } else { |
| 43 | + "sliding" |
| 44 | + }; |
| 45 | + AggregationConfig::new( |
| 46 | + id, |
| 47 | + agg_type.to_string(), |
| 48 | + agg_sub_type.to_string(), |
| 49 | + HashMap::new(), |
| 50 | + promql_utilities::data_model::key_by_label_names::KeyByLabelNames::new( |
| 51 | + grouping.iter().map(|s| s.to_string()).collect(), |
| 52 | + ), |
| 53 | + promql_utilities::data_model::key_by_label_names::KeyByLabelNames::new(vec![]), |
| 54 | + promql_utilities::data_model::key_by_label_names::KeyByLabelNames::new(vec![]), |
| 55 | + String::new(), |
| 56 | + window_secs, |
| 57 | + metric.to_string(), |
| 58 | + metric.to_string(), |
| 59 | + None, |
| 60 | + None, |
| 61 | + Some(window_secs), |
| 62 | + Some(slide_secs), |
| 63 | + Some(window_type.to_string()), |
| 64 | + None, |
| 65 | + None, |
| 66 | + ) |
| 67 | +} |
| 68 | + |
| 69 | +fn make_timeseries(metric: &str, extra_labels: Vec<(&str, &str)>, ts_ms: i64, value: f64) -> TimeSeries { |
| 70 | + let mut labels = vec![Label { |
| 71 | + name: "__name__".into(), |
| 72 | + value: metric.into(), |
| 73 | + }]; |
| 74 | + for (k, v) in extra_labels { |
| 75 | + labels.push(Label { |
| 76 | + name: k.into(), |
| 77 | + value: v.into(), |
| 78 | + }); |
| 79 | + } |
| 80 | + TimeSeries { |
| 81 | + labels, |
| 82 | + samples: vec![Sample { |
| 83 | + value, |
| 84 | + timestamp: ts_ms, |
| 85 | + }], |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +fn build_remote_write_body(timeseries: Vec<TimeSeries>) -> Vec<u8> { |
| 90 | + let write_req = WriteRequest { timeseries }; |
| 91 | + let proto_bytes = write_req.encode_to_vec(); |
| 92 | + snap::raw::Encoder::new() |
| 93 | + .compress_vec(&proto_bytes) |
| 94 | + .expect("snappy compress failed") |
| 95 | +} |
| 96 | + |
| 97 | +async fn send_remote_write(client: &reqwest::Client, port: u16, timeseries: Vec<TimeSeries>) { |
| 98 | + let body = build_remote_write_body(timeseries); |
| 99 | + let resp = client |
| 100 | + .post(format!("http://localhost:{port}/api/v1/write")) |
| 101 | + .header("Content-Type", "application/x-protobuf") |
| 102 | + .header("Content-Encoding", "snappy") |
| 103 | + .body(body) |
| 104 | + .send() |
| 105 | + .await |
| 106 | + .expect("HTTP send failed"); |
| 107 | + assert!( |
| 108 | + resp.status().as_u16() == 204, |
| 109 | + "ingest returned unexpected status {}", |
| 110 | + resp.status() |
| 111 | + ); |
| 112 | +} |
| 113 | + |
| 114 | +fn engine_config(port: u16) -> PrecomputeEngineConfig { |
| 115 | + PrecomputeEngineConfig { |
| 116 | + num_workers: 2, |
| 117 | + ingest_port: port, |
| 118 | + allowed_lateness_ms: 0, |
| 119 | + max_buffer_per_series: 10_000, |
| 120 | + flush_interval_ms: 100, |
| 121 | + channel_buffer_size: 10_000, |
| 122 | + pass_raw_samples: false, |
| 123 | + raw_mode_aggregation_id: 0, |
| 124 | + late_data_policy: LateDataPolicy::Drop, |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +fn gzip_hex(bytes: &[u8]) -> String { |
| 129 | + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); |
| 130 | + encoder.write_all(bytes).unwrap(); |
| 131 | + hex::encode(encoder.finish().unwrap()) |
| 132 | +} |
| 133 | + |
| 134 | +// ─── test 1: DatasketchesKLL output matches ArroYo KLL ────────────────────── |
| 135 | + |
| 136 | +/// Full e2e: send KLL samples through the HTTP ingest → PrecomputeEngine stack, |
| 137 | +/// then verify the emitted DatasketchesKLLAccumulator matches what ArroYo's |
| 138 | +/// KllSketch::aggregate_kll would produce for the same values. |
| 139 | +#[tokio::test] |
| 140 | +async fn e2e_kll_output_matches_arroyo() { |
| 141 | + let port = 19400u16; |
| 142 | + let agg_id = 1u64; |
| 143 | + let window_secs = 10u64; |
| 144 | + let k = 20u16; |
| 145 | + |
| 146 | + let mut kll_config = make_agg_config(agg_id, "latency", "DatasketchesKLL", "", window_secs, 0, vec![]); |
| 147 | + kll_config |
| 148 | + .parameters |
| 149 | + .insert("K".to_string(), serde_json::Value::from(k as u64)); |
| 150 | + |
| 151 | + let mut agg_map = HashMap::new(); |
| 152 | + agg_map.insert(agg_id, kll_config); |
| 153 | + let streaming_config = Arc::new(StreamingConfig::new(agg_map.clone())); |
| 154 | + |
| 155 | + let sink = Arc::new(CapturingOutputSink::new()); |
| 156 | + let engine = PrecomputeEngine::new(engine_config(port), streaming_config, sink.clone()); |
| 157 | + tokio::spawn(async move { |
| 158 | + let _ = engine.run().await; |
| 159 | + }); |
| 160 | + // Wait for the HTTP server to bind |
| 161 | + tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; |
| 162 | + |
| 163 | + let client = reqwest::Client::new(); |
| 164 | + let values = [10.0f64, 20.0, 30.0]; |
| 165 | + |
| 166 | + // Three samples inside window [0ms, 10_000ms) |
| 167 | + for (i, &v) in values.iter().enumerate() { |
| 168 | + let ts_ms = (i as i64 + 1) * 1_000; |
| 169 | + send_remote_write(&client, port, vec![make_timeseries("latency", vec![], ts_ms, v)]).await; |
| 170 | + } |
| 171 | + |
| 172 | + // Advance watermark past window end to trigger close |
| 173 | + send_remote_write(&client, port, vec![make_timeseries("latency", vec![], 15_000, 0.0)]).await; |
| 174 | + |
| 175 | + // Wait for flush |
| 176 | + tokio::time::sleep(tokio::time::Duration::from_millis(600)).await; |
| 177 | + |
| 178 | + let captured = sink.drain(); |
| 179 | + assert_eq!( |
| 180 | + captured.len(), |
| 181 | + 1, |
| 182 | + "expected exactly one closed window output; got {}", |
| 183 | + captured.len() |
| 184 | + ); |
| 185 | + |
| 186 | + let (handcrafted_output, handcrafted_acc_box) = &captured[0]; |
| 187 | + let handcrafted_acc = handcrafted_acc_box |
| 188 | + .as_any() |
| 189 | + .downcast_ref::<DatasketchesKLLAccumulator>() |
| 190 | + .expect("captured accumulator should be DatasketchesKLLAccumulator"); |
| 191 | + |
| 192 | + // Build the ArroYo-format equivalent and deserialize it |
| 193 | + let arroyo_bytes = KllSketch::aggregate_kll(k, &values).expect("KllSketch::aggregate_kll failed"); |
| 194 | + let arroyo_json = json!({ |
| 195 | + "aggregation_id": agg_id, |
| 196 | + "window": { "start": "1970-01-01T00:00:00", "end": "1970-01-01T00:00:10" }, |
| 197 | + "key": "", |
| 198 | + "precompute": gzip_hex(&arroyo_bytes), |
| 199 | + }); |
| 200 | + let streaming_config_for_deser = StreamingConfig::new(agg_map); |
| 201 | + let (_arroyo_output, arroyo_acc_box) = |
| 202 | + PrecomputedOutput::deserialize_from_json_arroyo(&arroyo_json, &streaming_config_for_deser) |
| 203 | + .expect("ArroYo KLL deserialization failed"); |
| 204 | + let arroyo_acc = arroyo_acc_box |
| 205 | + .as_any() |
| 206 | + .downcast_ref::<DatasketchesKLLAccumulator>() |
| 207 | + .expect("ArroYo payload should deserialize to DatasketchesKLLAccumulator"); |
| 208 | + |
| 209 | + // Window metadata |
| 210 | + assert_eq!(handcrafted_output.aggregation_id, agg_id); |
| 211 | + assert_eq!(handcrafted_output.start_timestamp, 0); |
| 212 | + assert_eq!(handcrafted_output.end_timestamp, window_secs * 1_000); |
| 213 | + |
| 214 | + // Sketch contents |
| 215 | + assert_eq!(handcrafted_acc.inner.k, arroyo_acc.inner.k, "KLL k mismatch"); |
| 216 | + assert_eq!( |
| 217 | + handcrafted_acc.inner.sketch.get_n(), |
| 218 | + arroyo_acc.inner.sketch.get_n(), |
| 219 | + "KLL sample count mismatch" |
| 220 | + ); |
| 221 | + for q in [0.0f64, 0.25, 0.5, 0.75, 1.0] { |
| 222 | + assert_eq!( |
| 223 | + handcrafted_acc.get_quantile(q), |
| 224 | + arroyo_acc.get_quantile(q), |
| 225 | + "KLL quantile {q} mismatch" |
| 226 | + ); |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +// ─── test 2: MultipleSum output matches ArroYo MultipleSum ────────────────── |
| 231 | + |
| 232 | +/// Full e2e: send MultipleSum samples (grouped by "host") through the HTTP |
| 233 | +/// ingest → PrecomputeEngine stack, then verify the emitted |
| 234 | +/// MultipleSumAccumulator matches the ArroYo MessagePack-encoded sums map. |
| 235 | +#[tokio::test] |
| 236 | +async fn e2e_multiple_sum_output_matches_arroyo() { |
| 237 | + let port = 19401u16; |
| 238 | + let agg_id = 2u64; |
| 239 | + let window_secs = 10u64; |
| 240 | + |
| 241 | + let config = make_agg_config(agg_id, "cpu", "MultipleSum", "sum", window_secs, 0, vec!["host"]); |
| 242 | + let mut agg_map = HashMap::new(); |
| 243 | + agg_map.insert(agg_id, config); |
| 244 | + let streaming_config = Arc::new(StreamingConfig::new(agg_map.clone())); |
| 245 | + |
| 246 | + let sink = Arc::new(CapturingOutputSink::new()); |
| 247 | + let engine = PrecomputeEngine::new(engine_config(port), streaming_config, sink.clone()); |
| 248 | + tokio::spawn(async move { |
| 249 | + let _ = engine.run().await; |
| 250 | + }); |
| 251 | + tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; |
| 252 | + |
| 253 | + let client = reqwest::Client::new(); |
| 254 | + |
| 255 | + // Three samples for host=A inside window [0ms, 10_000ms): sum = 1+2+3 = 6 |
| 256 | + for (ts, v) in [(1_000i64, 1.0f64), (5_000, 2.0), (9_000, 3.0)] { |
| 257 | + send_remote_write( |
| 258 | + &client, |
| 259 | + port, |
| 260 | + vec![make_timeseries("cpu", vec![("host", "A")], ts, v)], |
| 261 | + ) |
| 262 | + .await; |
| 263 | + } |
| 264 | + |
| 265 | + // Advance watermark to close the window |
| 266 | + send_remote_write( |
| 267 | + &client, |
| 268 | + port, |
| 269 | + vec![make_timeseries("cpu", vec![("host", "A")], 15_000, 0.0)], |
| 270 | + ) |
| 271 | + .await; |
| 272 | + |
| 273 | + tokio::time::sleep(tokio::time::Duration::from_millis(600)).await; |
| 274 | + |
| 275 | + let captured = sink.drain(); |
| 276 | + assert_eq!( |
| 277 | + captured.len(), |
| 278 | + 1, |
| 279 | + "expected one closed window output; got {}", |
| 280 | + captured.len() |
| 281 | + ); |
| 282 | + |
| 283 | + let (handcrafted_output, handcrafted_acc_box) = &captured[0]; |
| 284 | + let handcrafted_acc = handcrafted_acc_box |
| 285 | + .as_any() |
| 286 | + .downcast_ref::<MultipleSumAccumulator>() |
| 287 | + .expect("captured accumulator should be MultipleSumAccumulator"); |
| 288 | + |
| 289 | + // Build the ArroYo-format equivalent and deserialize it |
| 290 | + let mut expected_sums: HashMap<String, f64> = HashMap::new(); |
| 291 | + expected_sums.insert("A".to_string(), 6.0); |
| 292 | + let arroyo_bytes = rmp_serde::to_vec(&expected_sums).expect("msgpack encoding failed"); |
| 293 | + let arroyo_json = json!({ |
| 294 | + "aggregation_id": agg_id, |
| 295 | + "window": { "start": "1970-01-01T00:00:00", "end": "1970-01-01T00:00:10" }, |
| 296 | + "key": "A", |
| 297 | + "precompute": gzip_hex(&arroyo_bytes), |
| 298 | + }); |
| 299 | + let streaming_config_for_deser = StreamingConfig::new(agg_map); |
| 300 | + let (_arroyo_output, arroyo_acc_box) = |
| 301 | + PrecomputedOutput::deserialize_from_json_arroyo(&arroyo_json, &streaming_config_for_deser) |
| 302 | + .expect("ArroYo MultipleSum deserialization failed"); |
| 303 | + let arroyo_acc = arroyo_acc_box |
| 304 | + .as_any() |
| 305 | + .downcast_ref::<MultipleSumAccumulator>() |
| 306 | + .expect("ArroYo payload should deserialize to MultipleSumAccumulator"); |
| 307 | + |
| 308 | + // Window metadata |
| 309 | + assert_eq!(handcrafted_output.aggregation_id, agg_id); |
| 310 | + assert_eq!(handcrafted_output.start_timestamp, 0); |
| 311 | + assert_eq!(handcrafted_output.end_timestamp, window_secs * 1_000); |
| 312 | + |
| 313 | + // Accumulator contents |
| 314 | + assert_eq!( |
| 315 | + handcrafted_acc.sums, |
| 316 | + arroyo_acc.sums, |
| 317 | + "MultipleSum sums map mismatch" |
| 318 | + ); |
| 319 | +} |
0 commit comments