Skip to main content

risingwave_object_store/benchmark/
read.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
36// Stable deterministic mixing for offsets/jitter; not a cryptographic generator.
37pub(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
80/// Consume whole SDK stream chunks without an `AsyncRead` adapter or background draining task.
81pub(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
132// Each request has a unique ID, including repeated reads in interference phases.
133// Errors remain in the redacted summary; trace files contain status, not SDK error
134// strings or credentials. Offsets include the readiness chunk excluded by metrics.
135struct 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
202// Consume the complete item returned by the stream. In particular, do not retain
203// a partially consumed Bytes in an AsyncRead adapter across a pause. An await can
204// still complete immediately when the HTTP transport or kernel has buffered data.
205async 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        // Measure before enqueueing diagnostics. Record even empty items, EOF,
222        // failed awaits and cancelled awaits, which histograms intentionally omit.
223        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        // Drop the entire item before pausing or polling the stream again.
255        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    // Initialization may be lazy. First-data timing includes opening the stream
271    // and receiving its first whole chunk, with no separate one-byte probe.
272    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        // Even an initialization error joins readiness, so peers cannot deadlock.
307        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        // The entire readiness chunk was consumed before the observation window.
327        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        // Check EOF without a byte probe or an additional pause.
382        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    // Validation guarantees at least one distinct object per concurrent worker.
416    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
425/// Each worker cycles its own strided partition. The sequence restarts per comparison.
426fn 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
460/// The fast cohort is identical in all three experiments. Competitors either
461/// consume continuously or intermittently. Both cohorts run through the window.
462pub(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
501/// Run one complete comparison; request cursors and readiness are local to this window.
502pub(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        // Empty items must not count toward the pause frequency.
716        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        // Long pauses retain their chunk-index meaning, but only eligible
759        // boundaries can sleep; the limit also caps long-pause opportunities.
760        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                // Cancellation must also release a worker that never reaches readiness.
810                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        // Exercise early EOF, an oversized first/last chunk and extra data after
904        // the requested length. No byte adapter may hide an oversized tail.
905        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                // Each comparison starts at request zero, independently of timing/mode.
952                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        // The first four bytes must be consumed together. The next measured
997        // await polls the delayed stream item, with no buffered tail to return.
998        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}