1use std::collections::{HashMap, HashSet};
16use std::future::Future;
17use std::ops::Range;
18use std::sync::{Arc, OnceLock};
19use std::time::Duration;
20
21use anyhow::ensure;
22use bytes::Bytes;
23use futures::StreamExt;
24use futures_async_stream::try_stream;
25use risingwave_common::util::tokio_util::sync::CancellationToken;
26use thiserror_ext::AsReport;
27use tokio::sync::Barrier;
28use tokio::task::JoinSet;
29use tokio::time::Instant;
30
31use super::{
32 Accumulator, BenchmarkConfig, InterferenceCohort, Outcome, PhaseReport, ReadMode, Sample,
33};
34use crate::object::{ObjectDataStream, ObjectError, ObjectStoreImpl};
35
36pub(super) fn offset(seed: u64, index: usize, maximum: usize) -> usize {
38 let mut x = seed
39 .wrapping_add(index as u64)
40 .wrapping_add(0x9e3779b97f4a7c15);
41 x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
42 x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
43 ((x ^ (x >> 31)) as u128 % (maximum as u128 + 1)) as usize
44}
45
46#[derive(Debug, thiserror::Error)]
47#[error("read interrupted by cancellation or observation-window boundary")]
48struct Interrupted;
49
50struct Request {
51 phase: String,
52 key: String,
53 start: usize,
54 length: usize,
55 mode: ReadMode,
56 reader_id: usize,
57 ready: Option<(Arc<Barrier>, Arc<OnceLock<Instant>>)>,
58}
59
60#[try_stream(ok = Bytes, error = ObjectError)]
61async fn short_gets(
62 store: Arc<ObjectStoreImpl>,
63 key: String,
64 base: usize,
65 length: usize,
66 chunk: usize,
67) {
68 for start in (base..base + length).step_by(chunk) {
69 let end = start.saturating_add(chunk).min(base + length);
70 let data = store.read(&key, start..end).await?;
71 if data.len() != end - start {
72 Err(ObjectError::internal(
73 "short ranged GET returned unexpected length",
74 ))?;
75 }
76 yield data;
77 }
78}
79
80pub(super) async fn read_object(
82 store: Arc<ObjectStoreImpl>,
83 key: String,
84 config: Arc<BenchmarkConfig>,
85 mode: ReadMode,
86 reader_id: usize,
87 token: CancellationToken,
88) -> Outcome {
89 let length = config.object_size;
90 read_request(
91 store,
92 Request {
93 phase: format!("compactor_{}", mode.name()),
94 key,
95 start: 0,
96 length,
97 mode,
98 reader_id,
99 ready: None,
100 },
101 config,
102 token,
103 )
104 .await
105}
106
107async fn read_request(
108 store: Arc<ObjectStoreImpl>,
109 request: Request,
110 config: Arc<BenchmarkConfig>,
111 token: CancellationToken,
112) -> Outcome {
113 let response = async {
114 match request.mode {
115 ReadMode::Chunked => Ok(short_gets(
116 store,
117 request.key.clone(),
118 request.start,
119 request.length,
120 config.short_get_size,
121 )
122 .boxed()),
123 _ => Ok(store
124 .streaming_read(&request.key, request.start..request.start + request.length)
125 .await?
126 .into_stream()),
127 }
128 };
129 read_response(response, &request, &config, &token).await
130}
131
132struct RequestTrace<'a> {
136 sink: Option<&'a super::ReadTrace>,
137 id: u64,
138 offset: usize,
139}
140
141impl<'a> RequestTrace<'a> {
142 fn new(request: &Request, config: &'a BenchmarkConfig) -> Self {
143 let sink = config.read_trace.as_ref();
144 let id = sink.map_or(0, |sink| sink.request_id());
145 if let Some(sink) = sink {
146 sink.record(super::trace::Record {
147 request_id: id,
148 event: "read_start",
149 started_unix_us: super::trace::unix_us(),
150 elapsed_us: 0,
151 offset: request.start,
152 bytes: 0,
153 status: "started",
154 request: Some(super::trace::RequestInfo {
155 phase: request.phase.clone(),
156 key: request.key.clone(),
157 reader_id: request.reader_id,
158 mode: request.mode,
159 range_start: request.start,
160 range_length: request.length,
161 }),
162 });
163 }
164 Self {
165 sink,
166 id,
167 offset: request.start,
168 }
169 }
170
171 fn timestamp(&self) -> u128 {
172 if self.sink.is_some() {
173 super::trace::unix_us()
174 } else {
175 0
176 }
177 }
178
179 fn record(
180 &self,
181 event: &'static str,
182 started_unix_us: u128,
183 elapsed: Duration,
184 bytes: usize,
185 status: &'static str,
186 ) {
187 if let Some(sink) = self.sink {
188 sink.record(super::trace::Record {
189 request_id: self.id,
190 event,
191 started_unix_us,
192 elapsed_us: elapsed.as_micros(),
193 offset: self.offset,
194 bytes,
195 status,
196 request: None,
197 });
198 }
199 }
200}
201
202async fn next_chunk(
206 stream: &mut ObjectDataStream,
207 sample: &mut Sample,
208 first_poll: bool,
209 trace: &mut RequestTrace<'_>,
210 token: &CancellationToken,
211) -> anyhow::Result<Option<usize>> {
212 let started = Instant::now();
213 loop {
214 let started_unix_us = trace.timestamp();
215 let next_started = Instant::now();
216 let result = tokio::select! {
217 biased;
218 _ = token.cancelled() => Err(anyhow::Error::from(Interrupted)),
219 next = stream.next() => next.transpose().map_err(anyhow::Error::from),
220 };
221 let elapsed = next_started.elapsed();
224 let sample_elapsed = started.elapsed();
225 let (size, status) = match &result {
226 Ok(Some(bytes)) => (bytes.len(), if bytes.is_empty() { "empty" } else { "data" }),
227 Ok(None) => (0, "eof"),
228 Err(error) => (
229 0,
230 if error.is::<Interrupted>() {
231 "cancelled"
232 } else {
233 "error"
234 },
235 ),
236 };
237 trace.record("next", started_unix_us, elapsed, size, status);
238 trace.offset += size;
239 let next = result?;
240 let Some(bytes) = next else {
241 return Ok(None);
242 };
243 if bytes.is_empty() {
244 continue;
245 }
246 let size = bytes.len();
247 sample.bytes += size as u64;
248 sample.max_stream_chunk = sample.max_stream_chunk.max(size);
249 if first_poll {
250 sample.first_poll.record(sample_elapsed);
251 } else {
252 sample.resume_poll.record(sample_elapsed);
253 }
254 return Ok(Some(size));
256 }
257}
258
259async fn read_response(
260 response: impl Future<Output = Result<ObjectDataStream, ObjectError>>,
261 request: &Request,
262 config: &BenchmarkConfig,
263 token: &CancellationToken,
264) -> Outcome {
265 let mut trace = RequestTrace::new(request, config);
266 let read_started_unix_us = trace.timestamp();
267 let mut sample = Sample::default();
268 let first_started = Instant::now();
269 let mut measured_started = None;
270 let initialized = async {
273 let open_unix_us = trace.timestamp();
274 let open_started = Instant::now();
275 let opened = tokio::select! {
276 biased;
277 _ = token.cancelled() => Err(anyhow::Error::from(Interrupted)),
278 result = response => result.map_err(anyhow::Error::from),
279 };
280 trace.record(
281 "open",
282 open_unix_us,
283 open_started.elapsed(),
284 0,
285 match &opened {
286 Ok(_) => "ok",
287 Err(e) if e.is::<Interrupted>() => "cancelled",
288 Err(_) => "error",
289 },
290 );
291 let mut stream = opened?;
292 let size = next_chunk(&mut stream, &mut sample, true, &mut trace, token).await?;
293 let size = size.ok_or_else(|| anyhow::anyhow!("unexpected EOF before first chunk"))?;
294 ensure!(
295 size <= request.length,
296 "GET returned more bytes than the requested range"
297 );
298 sample.first_data.record(first_started.elapsed());
299 Ok::<_, anyhow::Error>((stream, request.length - size))
300 }
301 .await;
302
303 if let Some((barrier, start)) = &request.ready {
304 let ready_unix_us = trace.timestamp();
305 let ready_started = Instant::now();
306 tokio::select! {
308 _ = token.cancelled() => {},
309 _ = barrier.wait() => {},
310 }
311 while start.get().is_none() && !token.is_cancelled() {
312 tokio::task::yield_now().await;
313 }
314 trace.record(
315 "readiness",
316 ready_unix_us,
317 ready_started.elapsed(),
318 0,
319 if token.is_cancelled() {
320 "cancelled"
321 } else {
322 "ready"
323 },
324 );
325 measured_started = start.get().copied();
326 sample.bytes = 0;
328 sample.first_data = Default::default();
329 sample.first_poll = Default::default();
330 sample.resume_poll = Default::default();
331 }
332
333 let result = async {
334 let (mut stream, mut remaining) = initialized?;
335 let mut chunks = 1;
336 let mut pauses = 0;
337 while remaining > 0 {
338 if request.mode != ReadMode::Continuous
339 && chunks % config.pause_every_chunks == 0
340 && config.max_pauses.is_none_or(|limit| pauses < limit)
341 {
342 pauses += 1;
343 let jitter = offset(
344 config.seed ^ request.reader_id as u64,
345 chunks,
346 config.jitter_ms as usize,
347 ) as u64;
348 let long = if config.long_pause_every > 0 && chunks % config.long_pause_every == 0 {
349 config.long_pause_ms
350 } else {
351 0
352 };
353 let pause = Duration::from_millis(config.pause_ms + jitter + long);
354 if !pause.is_zero() {
355 let pause_unix_us = trace.timestamp();
356 let started = Instant::now();
357 tokio::select! {
358 biased;
359 _ = token.cancelled() => {
360 trace.record("pause", pause_unix_us, started.elapsed(), 0, "cancelled");
361 sample.pause += started.elapsed();
362 return Err(Interrupted.into());
363 },
364 _ = tokio::time::sleep(pause) => {},
365 }
366 trace.record("pause", pause_unix_us, started.elapsed(), 0, "ok");
367 sample.pause += started.elapsed();
368 }
369 }
370 let size = next_chunk(&mut stream, &mut sample, false, &mut trace, token).await?;
371 let size = size.ok_or_else(|| {
372 anyhow::anyhow!("unexpected EOF with {remaining} bytes remaining")
373 })?;
374 ensure!(
375 size <= remaining,
376 "GET returned more bytes than the requested range"
377 );
378 remaining -= size;
379 chunks += 1;
380 }
381 ensure!(
383 next_chunk(&mut stream, &mut sample, false, &mut trace, token)
384 .await?
385 .is_none(),
386 "GET returned more bytes than the requested range"
387 );
388 sample.items = 1;
389 Ok::<_, anyhow::Error>(())
390 }
391 .await;
392 let interrupted = result.as_ref().is_err_and(|e| e.is::<Interrupted>());
393 trace.record(
394 "read_end",
395 read_started_unix_us,
396 first_started.elapsed(),
397 0,
398 if interrupted {
399 "cancelled"
400 } else if result.is_err() {
401 "error"
402 } else {
403 "ok"
404 },
405 );
406 Outcome {
407 sample,
408 error: result.err().filter(|_| !interrupted),
409 interrupted,
410 measured_elapsed: measured_started.map(|start| start.elapsed()),
411 }
412}
413
414fn cohort_pool(config: &BenchmarkConfig, is_fast: bool) -> Range<usize> {
415 let fast_objects = (config.objects as u128 * config.fast_readers as u128
417 / (config.fast_readers + config.concurrency) as u128) as usize;
418 if is_fast {
419 0..fast_objects
420 } else {
421 fast_objects..config.objects
422 }
423}
424
425fn interference_request(
427 config: &BenchmarkConfig,
428 worker: usize,
429 iteration: usize,
430) -> (usize, Range<usize>) {
431 let is_fast = worker < config.fast_readers;
432 let pool = cohort_pool(config, is_fast);
433 let (local_worker, workers, length) = if is_fast {
434 (worker, config.fast_readers, config.range_size)
435 } else {
436 (
437 worker - config.fast_readers,
438 config.concurrency,
439 config.object_size,
440 )
441 };
442 let owned = (pool.len() - 1 - local_worker) / workers + 1;
443 let object = pool.start + local_worker + (iteration % owned) * workers;
444 let start = offset(
445 config.seed ^ worker as u64,
446 iteration,
447 config.object_size - length,
448 );
449 (object, start..start + length)
450}
451
452#[derive(Default)]
453struct Cohort {
454 stats: Accumulator,
455 objects: HashSet<usize>,
456 active: Duration,
457 failed_workers: usize,
458}
459
460pub(super) async fn interference(
463 store: Arc<ObjectStoreImpl>,
464 keys: Arc<Vec<String>>,
465 config: Arc<BenchmarkConfig>,
466 shutdown: &CancellationToken,
467) -> Vec<PhaseReport> {
468 let mut reports = vec![];
469 for (name, competitors, mode) in [
470 ("fast_only", 0, ReadMode::Continuous),
471 (
472 "continuous_competitors",
473 config.concurrency,
474 ReadMode::Continuous,
475 ),
476 (
477 "slow_competitors",
478 config.concurrency,
479 ReadMode::Intermittent,
480 ),
481 ] {
482 if shutdown.is_cancelled() {
483 break;
484 }
485 reports.extend(
486 interference_comparison(
487 store.clone(),
488 keys.clone(),
489 config.clone(),
490 shutdown,
491 name,
492 competitors,
493 mode,
494 )
495 .await,
496 );
497 }
498 reports
499}
500
501pub(super) async fn interference_comparison(
503 store: Arc<ObjectStoreImpl>,
504 keys: Arc<Vec<String>>,
505 config: Arc<BenchmarkConfig>,
506 shutdown: &CancellationToken,
507 name: &str,
508 competitors: usize,
509 mode: ReadMode,
510) -> Vec<PhaseReport> {
511 let mut reports = vec![];
512 eprintln!(
513 "benchmark: interference/{name} ({} fast + {competitors} competitors)",
514 config.fast_readers
515 );
516 let end_window = shutdown.child_token();
517 let barrier = Arc::new(Barrier::new(config.fast_readers + competitors + 1));
518 let clock = Arc::new(OnceLock::new());
519 let mut tasks = JoinSet::new();
520 let mut worker_cohorts = HashMap::new();
521 let fast_inflight = super::Inflight::default();
522 let competitor_inflight = super::Inflight::default();
523 for id in 0..config.fast_readers + competitors {
524 let phase = format!(
525 "interference_{name}/{}",
526 if id < config.fast_readers {
527 "fast"
528 } else {
529 "competitors"
530 }
531 );
532 let is_fast = id < config.fast_readers;
533 let inflight = if is_fast {
534 fast_inflight.clone()
535 } else {
536 competitor_inflight.clone()
537 };
538 let s = store.clone();
539 let cfg = config.clone();
540 let token = end_window.clone();
541 let keys = keys.clone();
542 let ready = barrier.clone();
543 let clock = clock.clone();
544 let task = tasks.spawn(async move {
545 let mut stats = Accumulator::default();
546 let mut objects = HashSet::new();
547 let mut iteration = 0usize;
548 if is_fast {
549 tokio::select! { _ = ready.wait() => {}, _ = token.cancelled() => {} }
550 while clock.get().is_none() && !token.is_cancelled() {
551 tokio::task::yield_now().await;
552 }
553 }
554 let mut initial = !is_fast;
555 while !token.is_cancelled() {
556 let (object, range) = interference_request(&cfg, id, iteration);
557 objects.insert(object);
558 iteration = iteration.wrapping_add(1);
559 let request = Request {
560 phase: phase.clone(),
561 key: keys[object].clone(),
562 start: range.start,
563 length: range.len(),
564 mode: if is_fast { ReadMode::Continuous } else { mode },
565 reader_id: id,
566 ready: initial.then(|| (ready.clone(), clock.clone())),
567 };
568 initial = false;
569 let started = Instant::now();
570 let guard = inflight.enter();
571 let outcome = read_request(s.clone(), request, cfg.clone(), token.clone()).await;
572 drop(guard);
573 let failed = outcome.error.is_some();
574 stats.add(outcome, started.elapsed());
575 if failed {
576 break;
577 }
578 tokio::task::yield_now().await;
579 }
580 (stats, objects, Instant::now())
581 });
582 worker_cohorts.insert(task.id(), is_fast);
583 }
584 tokio::select! {
585 _ = barrier.wait() => {},
586 _ = shutdown.cancelled() => {},
587 }
588 let started = Instant::now();
589 let started_unix_ms = super::unix_ms();
590 let _ = clock.set(started);
591 tokio::select! {
592 _ = tokio::time::sleep(Duration::from_millis(config.interference_ms)) => {},
593 _ = shutdown.cancelled() => {},
594 }
595 let elapsed = started.elapsed();
596 end_window.cancel();
597 let mut fast = Cohort::default();
598 let mut slow = Cohort::default();
599 while let Some(result) = tasks.join_next_with_id().await {
600 let id = match &result {
601 Ok((id, _)) => *id,
602 Err(error) => error.id(),
603 };
604 let cohort = if worker_cohorts[&id] {
605 &mut fast
606 } else {
607 &mut slow
608 };
609 match result {
610 Ok((_, (worker, objects, finished))) => {
611 cohort.failed_workers += usize::from(worker.failed > 0);
612 cohort.active += finished.saturating_duration_since(started).min(elapsed);
613 cohort.objects.extend(objects);
614 cohort.stats.merge(worker);
615 }
616 Err(error) => {
617 cohort.failed_workers += 1;
618 cohort.stats.errors.push(format!(
619 "worker failed; lifetime unavailable: {}",
620 error.as_report()
621 ));
622 }
623 }
624 }
625 let drain_ms = started.elapsed().saturating_sub(elapsed).as_secs_f64() * 1000.0;
626 let mut invalid_reasons = vec![];
627 if shutdown.is_cancelled() {
628 invalid_reasons.push("observation window cancelled".into());
629 }
630 for (name, cohort) in [("fast", &fast), ("competitors", &slow)] {
631 if cohort.failed_workers > 0 {
632 invalid_reasons.push(format!("{name}: {} workers failed", cohort.failed_workers));
633 }
634 }
635 if fast.stats.succeeded == 0 {
636 invalid_reasons.push("fast cohort completed no reads".into());
637 }
638 for (is_fast, cohort) in [(true, fast), (false, slow)] {
639 if !is_fast && competitors == 0 {
640 continue;
641 }
642 let pool = cohort_pool(&config, is_fast);
643 let no_fast_reads = is_fast && cohort.stats.succeeded == 0;
644 let mut report = cohort.stats.report(
645 format!(
646 "interference_{name}/{}",
647 if is_fast { "fast" } else { "competitors" }
648 ),
649 elapsed,
650 None,
651 shutdown.is_cancelled(),
652 );
653 if !shutdown.is_cancelled() && (cohort.failed_workers > 0 || no_fast_reads) {
654 report.status = "failed".into();
655 if no_fast_reads {
656 report.errors.push("fast cohort completed no reads".into());
657 }
658 }
659 report.interference = Some(InterferenceCohort {
660 comparison_valid: invalid_reasons.is_empty(),
661 invalid_reasons: invalid_reasons.clone(),
662 expected_workers: if is_fast {
663 config.fast_readers
664 } else {
665 competitors
666 },
667 failed_workers: cohort.failed_workers,
668 active_worker_seconds: cohort.active.as_secs_f64(),
669 object_pool_start: pool.start,
670 object_pool_objects: pool.len(),
671 object_pool_bytes: (pool.len() * config.object_size) as u64,
672 requested_objects: cohort.objects.len(),
673 range_bytes: if is_fast {
674 config.range_size
675 } else {
676 config.object_size
677 },
678 });
679 report.drain_ms = drain_ms;
680 report.wall_elapsed_ms += drain_ms;
681 report.stop_reason = if shutdown.is_cancelled() {
682 "cancelled"
683 } else {
684 "observation_window"
685 }
686 .into();
687 report.started_unix_ms = started_unix_ms;
688 report.peak_inflight_logical_operations = if is_fast {
689 fast_inflight.peak()
690 } else {
691 competitor_inflight.peak()
692 };
693 reports.push(report);
694 }
695 reports
696}
697
698#[cfg(all(test, not(madsim)))]
699mod tests {
700 use super::*;
701
702 fn test_request() -> Request {
703 Request {
704 phase: "test".into(),
705 key: "object".into(),
706 start: 0,
707 length: 5,
708 mode: ReadMode::Intermittent,
709 reader_id: 0,
710 ready: None,
711 }
712 }
713
714 fn five_chunks() -> ObjectDataStream {
715 futures::stream::iter((0..10).map(|i| {
717 Ok(if i % 2 == 0 {
718 Bytes::from_static(b"x")
719 } else {
720 Bytes::new()
721 })
722 }))
723 .boxed()
724 }
725
726 #[tokio::test(start_paused = true)]
727 async fn pause_frequency_and_limit_apply_to_whole_nonfinal_chunks() {
728 for (every, limit, expected_ms) in [
729 (1, None, 40),
730 (1, Some(0), 0),
731 (1, Some(1), 10),
732 (1, Some(2), 20),
733 (2, None, 20),
734 (2, Some(1), 10),
735 (5, None, 0),
736 (6, None, 0),
737 ] {
738 let config = BenchmarkConfig {
739 pause_every_chunks: every,
740 max_pauses: limit,
741 pause_ms: 10,
742 ..Default::default()
743 };
744 let start = Instant::now();
745 let outcome = read_response(
746 async { Ok(five_chunks()) },
747 &test_request(),
748 &config,
749 &CancellationToken::new(),
750 )
751 .await;
752 assert!(outcome.error.is_none());
753 assert_eq!(outcome.sample.bytes, 5);
754 assert_eq!(outcome.sample.items, 1);
755 assert_eq!(outcome.sample.pause, Duration::from_millis(expected_ms));
756 assert_eq!(start.elapsed(), Duration::from_millis(expected_ms));
757 }
758 let config = BenchmarkConfig {
761 pause_every_chunks: 2,
762 max_pauses: Some(1),
763 pause_ms: 10,
764 long_pause_every: 2,
765 long_pause_ms: 20,
766 jitter_ms: 7,
767 ..Default::default()
768 };
769 let outcome = read_response(
770 async { Ok(five_chunks()) },
771 &test_request(),
772 &config,
773 &CancellationToken::new(),
774 )
775 .await;
776 assert_eq!(
777 outcome.sample.pause,
778 Duration::from_millis(30 + offset(config.seed, 2, 7) as u64)
779 );
780 }
781
782 #[tokio::test(start_paused = true)]
783 async fn pending_stream_open_is_interrupted_by_shutdown_or_window_end() {
784 for cancel_run in [true, false] {
785 let run = CancellationToken::new();
786 let window = run.child_token();
787 let cancel = if cancel_run {
788 run.clone()
789 } else {
790 window.clone()
791 };
792 let (opened, opening) = tokio::sync::oneshot::channel();
793 let response = async {
794 opened.send(()).unwrap();
795 std::future::pending::<Result<ObjectDataStream, ObjectError>>().await
796 };
797 let canceller = tokio::spawn(async move {
798 opening.await.unwrap();
799 tokio::time::sleep(Duration::from_millis(10)).await;
800 cancel.cancel();
801 });
802 let request = Request {
803 phase: "test".into(),
804 key: "object".into(),
805 start: 0,
806 length: 10,
807 mode: ReadMode::Intermittent,
808 reader_id: 0,
809 ready: Some((Arc::new(Barrier::new(2)), Arc::new(OnceLock::new()))),
811 };
812 let outcome = tokio::time::timeout(
813 Duration::from_secs(1),
814 read_response(response, &request, &BenchmarkConfig::default(), &window),
815 )
816 .await
817 .expect("a pending stream open must not delay cancellation");
818 canceller.await.unwrap();
819 assert_eq!(run.is_cancelled(), cancel_run);
820 assert!(outcome.interrupted);
821 assert!(outcome.error.is_none());
822 assert_eq!(outcome.sample.bytes, 0);
823 let mut stats = Accumulator::default();
824 stats.add(outcome, Duration::from_millis(10));
825 assert_eq!(stats.interrupted, 1);
826 assert_eq!(stats.failed, 0);
827 assert!(stats.latency.summary().is_none());
828 assert!(stats.failed_latency.summary().is_none());
829 }
830 }
831
832 #[tokio::test]
833 async fn stream_open_errors_remain_failures_without_cancellation() {
834 let outcome = read_response(
835 async { Err(ObjectError::internal("stream open failed")) },
836 &Request {
837 phase: "test".into(),
838 key: "object".into(),
839 start: 0,
840 length: 10,
841 mode: ReadMode::Continuous,
842 reader_id: 0,
843 ready: None,
844 },
845 &BenchmarkConfig::default(),
846 &CancellationToken::new(),
847 )
848 .await;
849 assert!(!outcome.interrupted);
850 assert!(outcome.error.is_some());
851 let mut stats = Accumulator::default();
852 stats.add(outcome, Duration::from_millis(10));
853 assert_eq!(stats.failed, 1);
854 assert_eq!(stats.interrupted, 0);
855 assert_eq!(stats.failed_latency.summary().unwrap().samples, 1);
856 }
857
858 #[tokio::test(start_paused = true)]
859 async fn readiness_excludes_the_whole_first_chunk() {
860 let barrier = Arc::new(Barrier::new(2));
861 let clock = Arc::new(OnceLock::new());
862 let ready = barrier.clone();
863 let start = clock.clone();
864 let coordinator = tokio::spawn(async move {
865 ready.wait().await;
866 start.set(Instant::now()).unwrap();
867 });
868 let stream = futures::stream::iter([
869 Ok(Bytes::from_static(b"0123")),
870 Ok(Bytes::from_static(b"456789")),
871 ])
872 .boxed();
873 let outcome = read_response(
874 async { Ok(stream) },
875 &Request {
876 phase: "test".into(),
877 key: "object".into(),
878 start: 0,
879 length: 10,
880 mode: ReadMode::Intermittent,
881 reader_id: 0,
882 ready: Some((barrier, clock)),
883 },
884 &BenchmarkConfig {
885 pause_ms: 10,
886 ..Default::default()
887 },
888 &CancellationToken::new(),
889 )
890 .await;
891 coordinator.await.unwrap();
892 assert!(outcome.error.is_none());
893 assert_eq!(outcome.sample.bytes, 6);
894 assert_eq!(outcome.sample.items, 1);
895 assert!(outcome.sample.first_data.summary().is_none());
896 assert!(outcome.sample.first_poll.summary().is_none());
897 assert_eq!(outcome.sample.resume_poll.summary().unwrap().samples, 1);
898 assert_eq!(outcome.sample.pause, Duration::from_millis(10));
899 }
900
901 #[tokio::test]
902 async fn whole_chunk_reads_reject_truncated_and_oversized_bodies() {
903 for sizes in [vec![4], vec![11], vec![4, 7], vec![10, 1]] {
906 let total: usize = sizes.iter().sum();
907 let stream =
908 futures::stream::iter(sizes.into_iter().map(|size| Ok(Bytes::from(vec![0; size]))))
909 .boxed();
910 let outcome = read_response(
911 async { Ok(stream) },
912 &Request {
913 phase: "test".into(),
914 key: "object".into(),
915 start: 0,
916 length: 10,
917 mode: ReadMode::Continuous,
918 reader_id: 0,
919 ready: None,
920 },
921 &BenchmarkConfig::default(),
922 &CancellationToken::new(),
923 )
924 .await;
925 assert!(outcome.error.is_some());
926 assert!(!outcome.interrupted);
927 assert_eq!(outcome.sample.items, 0);
928 assert_eq!(outcome.sample.bytes, total as u64);
929 }
930 }
931
932 #[test]
933 fn rotating_sequences_cover_disjoint_pools_and_reset_reproducibly() {
934 let mut config = BenchmarkConfig {
935 fast_readers: 2,
936 concurrency: 2,
937 ..Default::default()
938 };
939 for objects in [4, 5, 20, 21] {
940 config.objects = objects;
941 let sequence = |worker| {
942 (0..objects * 2)
943 .map(|i| interference_request(&config, worker, i))
944 .collect::<Vec<_>>()
945 };
946 let mut fast = HashSet::new();
947 let mut competitors = HashSet::new();
948 let mut owners = HashMap::new();
949 for worker in 0..4 {
950 let requests = sequence(worker);
951 assert_eq!(requests, sequence(worker));
953 let own: HashSet<_> = requests.iter().map(|(object, _)| *object).collect();
954 for object in &own {
955 assert!(owners.insert(*object, worker).is_none());
956 }
957 let pool = if worker < 2 {
958 &mut fast
959 } else {
960 &mut competitors
961 };
962 pool.extend(own);
963 let length = if worker < 2 {
964 config.range_size
965 } else {
966 config.object_size
967 };
968 assert!(
969 requests
970 .iter()
971 .all(|(_, range)| range.len() == length && range.end <= config.object_size)
972 );
973 if worker < 2 {
974 assert!(requests.iter().any(|(_, range)| range.start > 0));
975 assert!(requests.windows(2).any(|pair| pair[0].1 != pair[1].1));
976 }
977 }
978 assert!(fast.is_disjoint(&competitors));
979 assert_eq!(fast.len(), objects / 2);
980 assert_eq!(competitors.len(), objects - objects / 2);
981 assert_eq!(owners.len(), objects);
982 }
983 }
984
985 #[tokio::test(start_paused = true)]
986 async fn whole_chunks_are_consumed_before_pausing_and_polling_again() {
987 let directory = tempfile::tempdir().unwrap();
988 let path = directory.path().join("trace.jsonl");
989 let writer = super::super::TraceWriter::create(&path).unwrap();
990 let config = BenchmarkConfig {
991 object_size: 10,
992 read_trace: Some(writer.trace()),
993 pause_ms: 10,
994 ..Default::default()
995 };
996 let stream = futures::stream::once(async {
999 tokio::time::sleep(Duration::from_millis(20)).await;
1000 Ok(Bytes::from_static(b"0123"))
1001 })
1002 .chain(futures::stream::once(async {
1003 tokio::time::sleep(Duration::from_millis(100)).await;
1004 Ok(Bytes::from_static(b"456789"))
1005 }))
1006 .boxed();
1007 let outcome = read_response(
1008 async {
1009 tokio::time::sleep(Duration::from_millis(30)).await;
1010 Ok(stream)
1011 },
1012 &Request {
1013 phase: "test".into(),
1014 key: "object".into(),
1015 start: 0,
1016 length: 10,
1017 mode: ReadMode::Intermittent,
1018 reader_id: 0,
1019 ready: None,
1020 },
1021 &config,
1022 &CancellationToken::new(),
1023 )
1024 .await;
1025 assert!(outcome.error.is_none());
1026 assert_eq!(outcome.sample.bytes, 10);
1027 let first = outcome.sample.first_data.summary().unwrap();
1028 let resume_poll = outcome.sample.resume_poll.summary().unwrap();
1029 assert!((resume_poll.max_ms - 100.0).abs() < 0.1);
1030 let first_poll = outcome.sample.first_poll.summary().unwrap();
1031 assert_eq!(first_poll.samples, 1);
1032 assert!((first_poll.max_ms - 20.0).abs() < 0.1);
1033 assert!((first.max_ms - 50.0).abs() < 0.1);
1034 assert!(first.max_ms < resume_poll.max_ms);
1035 assert_eq!(resume_poll.samples, 1);
1036 assert_eq!(outcome.sample.max_stream_chunk, 6);
1037 let pause_ms = outcome.sample.pause.as_secs_f64() * 1000.0;
1038 assert_eq!(pause_ms, 10.0);
1039 let trace_summary = writer.finish().unwrap();
1040 assert_eq!(trace_summary.records_dropped, 0);
1041 let rows: Vec<serde_json::Value> = std::fs::read_to_string(path)
1042 .unwrap()
1043 .lines()
1044 .map(|line| serde_json::from_str(line).unwrap())
1045 .collect();
1046 assert_eq!(rows.len() as u64, trace_summary.records_written);
1047 assert_eq!(rows[0]["event"], "read_start");
1048 assert_eq!(rows[0]["request"]["key"], "object");
1049 assert_eq!(rows.last().unwrap()["status"], "ok");
1050 assert!(
1051 rows.iter()
1052 .all(|row| row["request_id"] == rows[0]["request_id"])
1053 );
1054 let next: Vec<_> = rows.iter().filter(|row| row["event"] == "next").collect();
1055 assert_eq!(next.len(), 3);
1056 assert_eq!(next[0]["offset"], 0);
1057 assert_eq!(next[0]["bytes"], 4);
1058 assert_eq!(next[0]["elapsed_us"], 20_000);
1059 assert_eq!(next[1]["offset"], 4);
1060 assert_eq!(next[1]["bytes"], 6);
1061 assert_eq!(next[1]["elapsed_us"], 100_000);
1062 assert_eq!(next[2]["offset"], 10);
1063 assert_eq!(next[2]["status"], "eof");
1064 let pause = rows.iter().find(|row| row["event"] == "pause").unwrap();
1065 assert_eq!(pause["elapsed_us"], 10_000);
1066 assert_eq!(
1067 rows.iter().find(|row| row["event"] == "open").unwrap()["elapsed_us"],
1068 30_000
1069 );
1070
1071 let mut stats = Accumulator::default();
1072 stats.add(outcome, Duration::from_millis(130));
1073 let report = super::super::BenchmarkReport {
1074 schema_version: 5,
1075 profile_version: 5,
1076 prefix: "test/".into(),
1077 config,
1078 planned_dataset_bytes: 10,
1079 phases: vec![stats.report(
1080 "compactor_intermittent".into(),
1081 Duration::from_millis(130),
1082 None,
1083 false,
1084 )],
1085 cleanup_status: "completed".into(),
1086 cleanup_errors: vec![],
1087 cancelled: false,
1088 backpressure_verified: false,
1089 notes: vec![],
1090 };
1091 let summary = super::super::terminal_summary(&report);
1092 assert!(summary.contains("first_data: n=1; p95="));
1093 assert!(summary.contains("first_poll: n=1; p95="));
1094 assert!(summary.contains("resume_poll: n=1; p95="));
1095 assert!(summary.contains(&format!("max={:.2} ms", resume_poll.max_ms)));
1096 assert!(summary.contains(&format!("active pause across readers={pause_ms:.1} ms")));
1097 }
1098 #[tokio::test(start_paused = true)]
1099 async fn trace_preserves_empty_error_and_cancelled_awaits() {
1100 let directory = tempfile::tempdir().unwrap();
1101 let path = directory.path().join("trace.jsonl");
1102 let writer = super::super::TraceWriter::create(&path).unwrap();
1103 let config = BenchmarkConfig {
1104 read_trace: Some(writer.trace()),
1105 ..Default::default()
1106 };
1107 for cancel in [false, true] {
1108 let token = CancellationToken::new();
1109 let canceller = token.clone();
1110 let stream = futures::stream::iter([Ok(Bytes::new()), Ok(Bytes::from_static(b"ab"))])
1111 .chain(futures::stream::once(async move {
1112 if cancel {
1113 canceller.cancel();
1114 std::future::pending::<()>().await;
1115 }
1116 Err(ObjectError::internal("private SDK error text"))
1117 }))
1118 .boxed();
1119 let outcome = read_response(
1120 async { Ok(stream) },
1121 &Request {
1122 phase: "test".into(),
1123 key: "object".into(),
1124 start: 7,
1125 length: 4,
1126 mode: ReadMode::Continuous,
1127 reader_id: 0,
1128 ready: None,
1129 },
1130 &config,
1131 &token,
1132 )
1133 .await;
1134 assert_eq!(outcome.interrupted, cancel);
1135 assert_eq!(outcome.sample.bytes, 2);
1136 }
1137 assert_eq!(writer.finish().unwrap().records_dropped, 0);
1138 let text = std::fs::read_to_string(path).unwrap();
1139 assert!(!text.contains("private SDK error text"));
1140 let rows: Vec<serde_json::Value> = text
1141 .lines()
1142 .map(|line| serde_json::from_str(line).unwrap())
1143 .collect();
1144 for (id, expected) in [(0, "error"), (1, "cancelled")] {
1145 let next: Vec<_> = rows
1146 .iter()
1147 .filter(|row| row["request_id"] == id && row["event"] == "next")
1148 .collect();
1149 assert_eq!(next.len(), 3);
1150 assert_eq!(next[0]["status"], "empty");
1151 assert_eq!(next[0]["offset"], 7);
1152 assert_eq!(next[1]["offset"], 7);
1153 assert_eq!(next[2]["offset"], 9);
1154 assert_eq!(next[2]["status"], expected);
1155 }
1156 }
1157}