Skip to content

Commit 8028dba

Browse files
zzylolclaude
andauthored
test: E2E throughput/latency tests + sliding window bug fix (#229)
* Add standalone precompute engine to replace Arroyo streaming pipeline Implements a single-node multi-threaded precompute engine as a new module and binary target within QueryEngineRust. The engine ingests Prometheus remote write samples, buffers them per-series with out-of-order handling, detects closed tumbling/sliding windows via event-time watermarks, feeds samples into accumulator wrappers for all existing sketch types, and emits PrecomputedOutput directly to the store. New modules: config, series_buffer, window_manager, accumulator_factory, series_router, worker, output_sink, and the PrecomputeEngine orchestrator. The binary supports embedded store + query HTTP server for single-process deployment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Wire up DatasketchesKLL in accumulator factory and add E2E test Handle top-level aggregation types (DatasketchesKLL, Sum, Min, Max, etc.) directly in the factory match, fixing the fallback to Sum that broke quantile queries. Also preserve the K parameter in KllAccumulatorUpdater::reset() instead of hardcoding 200. Add test_e2e_precompute binary that validates the full ingest -> precompute -> store -> query pipeline end-to-end. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add 1000-sample batch latency test to E2E precompute test Sends 10 series × 100 samples in a single HTTP request to the raw-mode engine and verifies all 1000 samples land in the store. Prints client RTT and per-series e2e_latency_us at debug level. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add 10M-sample throughput test to E2E precompute test Sends 1000 requests × 10000 samples (50 distinct series) to the raw-mode engine and polls until all samples are stored. Reports both send throughput and e2e throughput including drain time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update * Fix sliding window aggregation: feed samples into all overlapping windows Previously each sample was assigned to only one window via window_start_for(), which is incorrect for sliding windows where window_size > slide_interval. Added window_starts_containing() that returns all window starts whose range covers the timestamp, and use it in the worker aggregation loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove duplicate [[bin]] entries in Cargo.toml from rebase merge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply cargo fmt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use std::io::Error::other per clippy lint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unnecessary cast per clippy lint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 11f348a commit 8028dba

5 files changed

Lines changed: 251 additions & 23 deletions

File tree

asap-query-engine/src/bin/test_e2e_precompute.rs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
275275
// Pick aggregation_id = 1 to match the existing streaming config.
276276
let raw_agg_id: u64 = 1;
277277
let raw_engine_config = PrecomputeEngineConfig {
278-
num_workers: 1,
278+
num_workers: 4,
279279
ingest_port: RAW_INGEST_PORT,
280280
allowed_lateness_ms: 5000,
281281
max_buffer_per_series: 10000,
@@ -334,6 +334,138 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
334334
}
335335
println!(" Raw mode test PASSED");
336336

337+
// -----------------------------------------------------------------------
338+
// BATCH LATENCY TEST
339+
// Send 1000 samples in a single HTTP request to measure realistic e2e
340+
// latency. Uses the raw-mode engine to avoid window-close dependencies.
341+
// -----------------------------------------------------------------------
342+
println!("\n=== Batch latency test: 1000 samples in one request ===");
343+
344+
// Build a single WriteRequest with 1000 TimeSeries entries spread across
345+
// 10 series × 100 timestamps each, so routing fans out to workers.
346+
let mut batch_timeseries = Vec::with_capacity(1000);
347+
for series_idx in 0..10 {
348+
let label_val = format!("batch_{series_idx}");
349+
for t in 0..100 {
350+
let ts = 200_000 + series_idx * 1000 + t; // unique ts per sample
351+
let val = (series_idx * 100 + t) as f64;
352+
batch_timeseries.push(make_sample("fake_metric", &label_val, ts, val));
353+
}
354+
}
355+
let batch_body = build_remote_write_body(batch_timeseries);
356+
println!(
357+
" Payload size: {} bytes (snappy-compressed)",
358+
batch_body.len()
359+
);
360+
361+
let t0 = std::time::Instant::now();
362+
let resp = client
363+
.post(format!("http://localhost:{RAW_INGEST_PORT}/api/v1/write"))
364+
.header("Content-Type", "application/x-protobuf")
365+
.header("Content-Encoding", "snappy")
366+
.body(batch_body)
367+
.send()
368+
.await?;
369+
let client_rtt = t0.elapsed();
370+
println!(
371+
" HTTP response: {} in {:.3}ms",
372+
resp.status().as_u16(),
373+
client_rtt.as_secs_f64() * 1000.0,
374+
);
375+
376+
// Wait for all workers to finish processing
377+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
378+
379+
// Verify samples landed in the store
380+
let batch_results =
381+
store.query_precomputed_output("fake_metric", raw_agg_id, 200_000, 210_000)?;
382+
let batch_buckets: usize = batch_results.values().map(|v| v.len()).sum();
383+
println!(" Stored {batch_buckets} buckets from batch (expected 1000)");
384+
assert!(
385+
batch_buckets >= 1000,
386+
"Expected at least 1000 raw samples in store, got {batch_buckets}"
387+
);
388+
println!(" Batch latency test PASSED");
389+
390+
// -----------------------------------------------------------------------
391+
// THROUGHPUT TEST
392+
// Send many requests back-to-back and measure sustained throughput
393+
// (samples/sec). Uses the raw-mode engine for a clean measurement.
394+
// -----------------------------------------------------------------------
395+
println!("\n=== Throughput test: 1000 requests × 10000 samples ===");
396+
397+
let num_requests = 1000u64;
398+
let samples_per_request = 10_000u64;
399+
let total_samples = num_requests * samples_per_request;
400+
401+
// Pre-build all request bodies so serialization doesn't count against throughput
402+
let mut bodies = Vec::with_capacity(num_requests as usize);
403+
for req_idx in 0..num_requests {
404+
let mut timeseries = Vec::with_capacity(samples_per_request as usize);
405+
for s in 0..samples_per_request {
406+
let series_label = format!("tp_{}", s % 50); // 50 distinct series
407+
let ts = 300_000 + req_idx as i64 * 10_000 + s as i64;
408+
timeseries.push(make_sample("fake_metric", &series_label, ts, s as f64));
409+
}
410+
bodies.push(build_remote_write_body(timeseries));
411+
}
412+
413+
let throughput_start = std::time::Instant::now();
414+
415+
for (i, body) in bodies.into_iter().enumerate() {
416+
let resp = client
417+
.post(format!("http://localhost:{RAW_INGEST_PORT}/api/v1/write"))
418+
.header("Content-Type", "application/x-protobuf")
419+
.header("Content-Encoding", "snappy")
420+
.body(body)
421+
.send()
422+
.await?;
423+
if resp.status() != reqwest::StatusCode::NO_CONTENT {
424+
eprintln!(" Request {i} failed: {}", resp.status());
425+
}
426+
}
427+
428+
let send_elapsed = throughput_start.elapsed();
429+
println!(
430+
" All {} requests sent in {:.1}ms",
431+
num_requests,
432+
send_elapsed.as_secs_f64() * 1000.0,
433+
);
434+
435+
// Poll until workers drain or timeout after 60s
436+
let max_ts = 300_000u64 + num_requests * 10_000 + samples_per_request;
437+
let drain_deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
438+
let mut tp_buckets: usize;
439+
loop {
440+
let tp_results =
441+
store.query_precomputed_output("fake_metric", raw_agg_id, 300_000, max_ts)?;
442+
tp_buckets = tp_results.values().map(|v| v.len()).sum();
443+
if tp_buckets as u64 >= total_samples || std::time::Instant::now() > drain_deadline {
444+
break;
445+
}
446+
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
447+
}
448+
let total_elapsed = throughput_start.elapsed();
449+
450+
let send_throughput = total_samples as f64 / send_elapsed.as_secs_f64();
451+
let e2e_throughput = tp_buckets as f64 / total_elapsed.as_secs_f64();
452+
println!(" Stored {tp_buckets}/{total_samples} samples");
453+
println!(
454+
" Send throughput: {:.0} samples/sec ({:.1}ms for {total_samples} samples)",
455+
send_throughput,
456+
send_elapsed.as_secs_f64() * 1000.0,
457+
);
458+
println!(
459+
" E2E throughput: {:.0} samples/sec ({:.1}ms until all stored)",
460+
e2e_throughput,
461+
total_elapsed.as_secs_f64() * 1000.0,
462+
);
463+
assert!(
464+
tp_buckets as u64 >= total_samples,
465+
"Expected at least {total_samples} samples in store, got {tp_buckets}"
466+
);
467+
println!(" Throughput test PASSED");
468+
337469
println!("\n=== E2E test complete ===");
338470

339471
Ok(())

asap-query-engine/src/precompute_engine/mod.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,16 +167,20 @@ async fn handle_ingest(State(state): State<Arc<IngestState>>, body: Bytes) -> St
167167
.push((s.timestamp_ms, s.value));
168168
}
169169

170-
// Route each series batch to the correct worker
171-
for (series_key, batch) in by_series {
172-
if let Err(e) = state
173-
.router
174-
.route(series_key, batch, ingest_received_at)
175-
.await
176-
{
177-
warn!("Routing error for {}: {}", series_key, e);
178-
return StatusCode::INTERNAL_SERVER_ERROR;
179-
}
170+
// Convert to owned keys for batch routing
171+
let by_series_owned: HashMap<String, Vec<(i64, f64)>> = by_series
172+
.into_iter()
173+
.map(|(k, v)| (k.to_string(), v))
174+
.collect();
175+
176+
// Route all series to workers concurrently
177+
if let Err(e) = state
178+
.router
179+
.route_batch(by_series_owned, ingest_received_at)
180+
.await
181+
{
182+
warn!("Batch routing error: {}", e);
183+
return StatusCode::INTERNAL_SERVER_ERROR;
180184
}
181185

182186
StatusCode::NO_CONTENT

asap-query-engine/src/precompute_engine/series_router.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use futures::future::try_join_all;
2+
use std::collections::HashMap;
13
use std::time::Instant;
24
use tokio::sync::mpsc;
35
use xxhash_rust::xxh64::xxh64;
@@ -52,6 +54,50 @@ impl SeriesRouter {
5254
Ok(())
5355
}
5456

57+
/// Route a pre-grouped batch of series to workers concurrently.
58+
///
59+
/// Groups messages by target worker, then sends to each worker in parallel
60+
/// (messages within a single worker are sent sequentially to preserve ordering).
61+
pub async fn route_batch(
62+
&self,
63+
by_series: HashMap<String, Vec<(i64, f64)>>,
64+
ingest_received_at: Instant,
65+
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
66+
// Group messages by target worker index
67+
let mut per_worker: HashMap<usize, Vec<WorkerMessage>> = HashMap::new();
68+
for (series_key, samples) in by_series {
69+
let worker_idx = self.worker_for(&series_key);
70+
per_worker
71+
.entry(worker_idx)
72+
.or_default()
73+
.push(WorkerMessage::Samples {
74+
series_key,
75+
samples,
76+
ingest_received_at,
77+
});
78+
}
79+
80+
// Send to each worker concurrently
81+
try_join_all(per_worker.into_iter().map(|(worker_idx, messages)| {
82+
let sender = &self.senders[worker_idx];
83+
async move {
84+
for msg in messages {
85+
sender
86+
.send(msg)
87+
.await
88+
.map_err(|e| format!("Failed to send to worker {}: {}", worker_idx, e))?;
89+
}
90+
Ok::<(), String>(())
91+
}
92+
}))
93+
.await
94+
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
95+
Box::new(std::io::Error::other(e))
96+
})?;
97+
98+
Ok(())
99+
}
100+
55101
/// Broadcast a flush signal to all workers.
56102
pub async fn broadcast_flush(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
57103
for (i, sender) in self.senders.iter().enumerate() {

asap-query-engine/src/precompute_engine/window_manager.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ impl WindowManager {
7272
closed
7373
}
7474

75+
/// Return all window starts whose window `[start, start + window_size_ms)`
76+
/// contains the given timestamp. For tumbling windows this returns exactly
77+
/// one start; for sliding windows it returns `ceil(window_size / slide)`
78+
/// starts.
79+
pub fn window_starts_containing(&self, timestamp_ms: i64) -> Vec<i64> {
80+
let mut starts = Vec::new();
81+
let mut start = self.window_start_for(timestamp_ms);
82+
while start + self.window_size_ms > timestamp_ms {
83+
starts.push(start);
84+
start -= self.slide_interval_ms;
85+
}
86+
starts
87+
}
88+
7589
/// Return the window `[start, end)` boundaries for a given window start.
7690
pub fn window_bounds(&self, window_start: i64) -> (i64, i64) {
7791
(window_start, window_start + self.window_size_ms)
@@ -150,4 +164,34 @@ mod tests {
150164
let closed = wm.closed_windows(15_000, 35_000);
151165
assert_eq!(closed, vec![0]);
152166
}
167+
168+
#[test]
169+
fn test_window_starts_containing_tumbling() {
170+
// 60s tumbling windows — each sample belongs to exactly one window
171+
let wm = WindowManager::new(60, 0);
172+
let mut starts = wm.window_starts_containing(15_000);
173+
starts.sort();
174+
assert_eq!(starts, vec![0]);
175+
176+
let mut starts = wm.window_starts_containing(60_000);
177+
starts.sort();
178+
assert_eq!(starts, vec![60_000]);
179+
}
180+
181+
#[test]
182+
fn test_window_starts_containing_sliding() {
183+
// 30s window, 10s slide — each sample belongs to 3 windows
184+
let wm = WindowManager::new(30, 10);
185+
186+
// t=15_000 belongs to [0, 30_000), [10_000, 40_000)
187+
// and [-10_000, 20_000) which starts negative — still returned
188+
let mut starts = wm.window_starts_containing(15_000);
189+
starts.sort();
190+
assert_eq!(starts, vec![-10_000, 0, 10_000]);
191+
192+
// t=30_000 belongs to [10_000, 40_000), [20_000, 50_000), [30_000, 60_000)
193+
let mut starts = wm.window_starts_containing(30_000);
194+
starts.sort();
195+
assert_eq!(starts, vec![10_000, 20_000, 30_000]);
196+
}
153197
}

asap-query-engine/src/precompute_engine/worker.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,20 @@ impl Worker {
221221
continue; // already dropped
222222
}
223223

224-
let window_start = agg_state.window_manager.window_start_for(ts);
225-
226-
let updater = agg_state
227-
.active_windows
228-
.entry(window_start)
229-
.or_insert_with(|| create_accumulator_updater(&agg_state.config));
230-
231-
if updater.is_keyed() {
232-
let key = extract_key_from_series(series_key, &agg_state.config);
233-
updater.update_keyed(&key, val, ts);
234-
} else {
235-
updater.update_single(val, ts);
224+
let window_starts = agg_state.window_manager.window_starts_containing(ts);
225+
226+
for window_start in window_starts {
227+
let updater = agg_state
228+
.active_windows
229+
.entry(window_start)
230+
.or_insert_with(|| create_accumulator_updater(&agg_state.config));
231+
232+
if updater.is_keyed() {
233+
let key = extract_key_from_series(series_key, &agg_state.config);
234+
updater.update_keyed(&key, val, ts);
235+
} else {
236+
updater.update_single(val, ts);
237+
}
236238
}
237239
}
238240

0 commit comments

Comments
 (0)