Skip to main content

risingwave_object_store/benchmark/
mod.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
15//! Fixed-work object-store benchmarks and a timed fast-reader interference experiment.
16//! No Meta connection, Kubernetes resources, or production SST discovery is involved.
17
18mod basic;
19mod read;
20mod trace;
21
22pub use trace::{ReadTrace, TraceSummary, TraceWriter};
23// These tests measure real-time/network behavior, not simulated performance.
24#[cfg(all(test, not(madsim)))]
25mod tests;
26
27use std::future::Future;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicUsize, Ordering};
30use std::time::Duration;
31
32use anyhow::{Context, Result, ensure};
33use bytes::Bytes;
34use futures::TryStreamExt;
35use hdrhistogram::Histogram;
36use rand::{RngCore, SeedableRng};
37use risingwave_common::util::tokio_util::sync::CancellationToken;
38use serde::{Deserialize, Serialize};
39use thiserror_ext::AsReport;
40use tokio::task::JoinSet;
41use tokio::time::Instant;
42
43use crate::object::ObjectStoreImpl;
44use crate::object::object_metrics::ObjectStoreMetrics;
45
46/// Sizes are bytes and times are milliseconds, including in the JSON report.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48#[serde(default, deny_unknown_fields)]
49pub struct BenchmarkConfig {
50    /// Optional diagnostic sink; configured separately from serialized workload parameters.
51    #[serde(skip)]
52    pub read_trace: Option<ReadTrace>,
53    pub profile: Profile,
54    pub objects: usize,
55    pub object_size: usize,
56    pub concurrency: usize,
57    pub range_size: usize,
58    pub read_iterations: usize,
59    /// Per-basic-phase wall-time budget; zero selects the original fixed-work counts.
60    pub basic_duration_ms: u64,
61    /// Per-basic-phase cap, including failed attempts. Data preparation is separate.
62    pub basic_max_operations: usize,
63    pub block_size: usize,
64    pub pause_ms: u64,
65    /// Pause after every N complete, nonempty chunks in non-continuous modes.
66    pub pause_every_chunks: usize,
67    /// Maximum pause opportunities per logical read; None is unlimited, Some(0) disables pauses.
68    pub max_pauses: Option<usize>,
69    pub jitter_ms: u64,
70    pub long_pause_every: usize,
71    pub long_pause_ms: u64,
72    pub short_get_size: usize,
73    pub metadata_objects: usize,
74    pub delete_batch_size: usize,
75    pub fast_readers: usize,
76    pub interference_ms: u64,
77    pub seed: u64,
78    pub keep_data: bool,
79    /// Read a retained prefix without replacing or deleting its large objects.
80    pub reuse_data: bool,
81    /// Runs all three fixed-work modes, in this order, on the same ranges.
82    pub read_order: Vec<ReadMode>,
83}
84
85impl Default for BenchmarkConfig {
86    fn default() -> Self {
87        Self {
88            read_trace: None,
89            profile: Profile::All,
90            objects: 16,
91            object_size: 64 << 20,
92            concurrency: 8,
93            range_size: 1 << 20,
94            read_iterations: 16,
95            basic_duration_ms: 30_000,
96            basic_max_operations: 10_000,
97            block_size: 64 << 10,
98            pause_ms: 10_000,
99            pause_every_chunks: 1,
100            max_pauses: None,
101            jitter_ms: 0,
102            long_pause_every: 0,
103            long_pause_ms: 1000,
104            short_get_size: 4 << 20,
105            metadata_objects: 1000,
106            delete_batch_size: 100,
107            fast_readers: 2,
108            interference_ms: 10_000,
109            seed: 42,
110            keep_data: false,
111            reuse_data: false,
112            read_order: vec![
113                ReadMode::Continuous,
114                ReadMode::Intermittent,
115                ReadMode::Chunked,
116            ],
117        }
118    }
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Profile {
124    Basic,
125    Compactor,
126    All,
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ReadMode {
132    Continuous,
133    Intermittent,
134    Chunked,
135}
136
137impl ReadMode {
138    fn name(self) -> &'static str {
139        match self {
140            Self::Continuous => "continuous",
141            Self::Intermittent => "intermittent",
142            Self::Chunked => "chunked",
143        }
144    }
145}
146
147impl BenchmarkConfig {
148    pub fn validate(&self) -> Result<()> {
149        ensure!(
150            self.pause_every_chunks > 0,
151            "pause_every_chunks must be positive"
152        );
153        ensure!(
154            self.basic_max_operations > 0,
155            "basic_max_operations must be positive"
156        );
157        ensure!(
158            Instant::now()
159                .checked_add(Duration::from_millis(self.basic_duration_ms))
160                .is_some(),
161            "basic duration exceeds the platform clock range"
162        );
163        ensure!(
164            self.objects > 0 && self.concurrency > 0,
165            "objects and concurrency must be positive"
166        );
167        ensure!(
168            self.object_size > 0 && self.object_size <= 1 << 30,
169            "object_size must be in 1..=1 GiB (one shared payload is allocated)"
170        );
171        ensure!(
172            self.range_size > 0 && self.range_size <= self.object_size,
173            "range_size must fit in object_size"
174        );
175        ensure!(
176            self.block_size > 0 && self.block_size <= self.object_size,
177            "block_size must fit in object_size"
178        );
179        ensure!(
180            self.short_get_size > 0 && self.short_get_size <= self.object_size,
181            "short_get_size must fit in object_size"
182        );
183        ensure!(
184            self.metadata_objects > 0 && (1..=1000).contains(&self.delete_batch_size),
185            "metadata_objects must be positive and delete_batch_size must be in 1..=1000"
186        );
187        ensure!(
188            self.fast_readers > 0 && self.interference_ms > 0,
189            "fast_readers and interference_ms must be positive"
190        );
191        ensure!(
192            self.pause_ms
193                .checked_add(self.jitter_ms)
194                .and_then(|n| n.checked_add(self.long_pause_ms))
195                .is_some(),
196            "pause duration overflow"
197        );
198        ensure!(
199            self.read_order.len() == 3
200                && [
201                    ReadMode::Continuous,
202                    ReadMode::Intermittent,
203                    ReadMode::Chunked
204                ]
205                .iter()
206                .all(|m| self.read_order.contains(m)),
207            "read_order must contain each read mode exactly once"
208        );
209        ensure!(
210            self.objects.checked_mul(self.object_size).is_some(),
211            "dataset size overflow"
212        );
213        ensure!(
214            self.metadata_objects
215                .checked_mul(4096)
216                .and_then(|n| n.checked_add(self.objects * self.object_size))
217                .is_some(),
218            "total dataset size overflow"
219        );
220        let max_pause = self.pause_ms + self.jitter_ms + self.long_pause_ms;
221        ensure!(
222            Instant::now()
223                .checked_add(Duration::from_millis(max_pause))
224                .is_some()
225                && Instant::now()
226                    .checked_add(Duration::from_millis(self.interference_ms))
227                    .is_some(),
228            "pause or interference duration exceeds the platform clock range"
229        );
230        ensure!(
231            self.read_iterations > 0 && self.objects.checked_mul(self.read_iterations).is_some(),
232            "read_iterations must be positive and the operation count must fit usize"
233        );
234        ensure!(
235            !self.reuse_data || (self.profile == Profile::Compactor && self.keep_data),
236            "reuse_data requires the compactor profile and keep_data"
237        );
238        ensure!(
239            self.profile == Profile::Basic
240                || self
241                    .fast_readers
242                    .checked_add(self.concurrency)
243                    .is_some_and(|n| n <= self.objects),
244            "compactor profile needs objects >= fast_readers + concurrency, so cohorts use distinct objects"
245        );
246        Ok(())
247    }
248}
249
250/// Client-observed latencies, with microsecond resolution and three significant digits.
251#[derive(Debug, Serialize)]
252pub struct Distribution {
253    pub samples: u64,
254    pub mean_ms: f64,
255    pub p50_ms: f64,
256    pub p95_ms: f64,
257    pub p99_ms: f64,
258    pub max_ms: f64,
259}
260
261pub(super) struct Timings(Histogram<u64>);
262
263impl Default for Timings {
264    fn default() -> Self {
265        Self(Histogram::new(3).expect("valid histogram precision"))
266    }
267}
268
269impl Timings {
270    fn record(&mut self, elapsed: Duration) {
271        // The histogram auto-resizes; a u64 microsecond range has bounded memory.
272        self.0
273            .record(elapsed.as_micros().min(u64::MAX as u128) as u64)
274            .expect("auto-resizing histogram");
275    }
276
277    fn merge(&mut self, other: &Self) {
278        self.0.add(&other.0).expect("matching histogram precision");
279    }
280
281    fn summary(&self) -> Option<Distribution> {
282        (!self.0.is_empty()).then(|| Distribution {
283            samples: self.0.len(),
284            mean_ms: self.0.mean() / 1000.0,
285            p50_ms: self.0.value_at_quantile(0.50) as f64 / 1000.0,
286            p95_ms: self.0.value_at_quantile(0.95) as f64 / 1000.0,
287            p99_ms: self.0.value_at_quantile(0.99) as f64 / 1000.0,
288            max_ms: self.0.max() as f64 / 1000.0,
289        })
290    }
291}
292
293#[derive(Default)]
294pub(super) struct Sample {
295    bytes: u64,
296    items: u64,
297    pause: Duration,
298    first_data: Timings,
299    first_poll: Timings,
300    resume_poll: Timings,
301    max_stream_chunk: usize,
302}
303
304pub(super) struct Outcome {
305    sample: Sample,
306    error: Option<anyhow::Error>,
307    interrupted: bool,
308    measured_elapsed: Option<Duration>,
309}
310
311impl Outcome {
312    fn from_result(result: Result<Sample>) -> Self {
313        match result {
314            Ok(sample) => Self {
315                sample,
316                error: None,
317                interrupted: false,
318                measured_elapsed: None,
319            },
320            Err(error) => Self {
321                sample: Sample::default(),
322                error: Some(error),
323                interrupted: false,
324                measured_elapsed: None,
325            },
326        }
327    }
328}
329
330#[derive(Default)]
331pub(super) struct Accumulator {
332    attempted: u64,
333    succeeded: u64,
334    failed: u64,
335    interrupted: u64,
336    bytes: u64,
337    items: u64,
338    pause: Duration,
339    latency: Timings,
340    failed_latency: Timings,
341    first_data: Timings,
342    first_poll: Timings,
343    resume_poll: Timings,
344    max_stream_chunk: usize,
345    errors: Vec<String>,
346}
347
348impl Accumulator {
349    fn add(&mut self, outcome: Outcome, elapsed: Duration) {
350        self.attempted += 1;
351        if !outcome.interrupted {
352            let timings = if outcome.error.is_some() {
353                &mut self.failed_latency
354            } else {
355                &mut self.latency
356            };
357            timings.record(outcome.measured_elapsed.unwrap_or(elapsed));
358        }
359        self.bytes += outcome.sample.bytes;
360        self.items += outcome.sample.items;
361        self.pause += outcome.sample.pause;
362        self.first_data.merge(&outcome.sample.first_data);
363        self.first_poll.merge(&outcome.sample.first_poll);
364        self.resume_poll.merge(&outcome.sample.resume_poll);
365        self.max_stream_chunk = self.max_stream_chunk.max(outcome.sample.max_stream_chunk);
366        if outcome.interrupted {
367            self.interrupted += 1;
368        } else if let Some(error) = outcome.error {
369            self.failed += 1;
370            if self.errors.len() < 8 {
371                self.errors.push(format!("{}", error.as_report()));
372            }
373        } else {
374            self.succeeded += 1;
375        }
376    }
377
378    fn merge(&mut self, other: Self) {
379        self.attempted += other.attempted;
380        self.succeeded += other.succeeded;
381        self.failed += other.failed;
382        self.interrupted += other.interrupted;
383        self.bytes += other.bytes;
384        self.items += other.items;
385        self.pause += other.pause;
386        self.latency.merge(&other.latency);
387        self.failed_latency.merge(&other.failed_latency);
388        self.first_data.merge(&other.first_data);
389        self.first_poll.merge(&other.first_poll);
390        self.resume_poll.merge(&other.resume_poll);
391        self.max_stream_chunk = self.max_stream_chunk.max(other.max_stream_chunk);
392        self.errors
393            .extend(other.errors.into_iter().take(8 - self.errors.len()));
394    }
395
396    fn report(
397        self,
398        name: String,
399        elapsed: Duration,
400        expected: Option<usize>,
401        cancelled: bool,
402    ) -> PhaseReport {
403        let incomplete = expected.is_some_and(|n| n as u64 != self.attempted);
404        let seconds = elapsed.as_secs_f64();
405        PhaseReport {
406            item_unit: match name.as_str() {
407                "preflight" => "functional_checks",
408                "list" => "enumerated_objects",
409                "batch_delete" => "deleted_objects",
410                "multipart_put" | "prepare_data" | "prepare_metadata" => "uploaded_objects",
411                "validate_retained_data" => "validated_objects",
412                _ => "completed_object_reads",
413            }
414            .into(),
415            operation_unit: operation_unit(&name).into(),
416            name,
417            status: if cancelled {
418                "cancelled"
419            } else if self.failed > 0 || incomplete {
420                "failed"
421            } else {
422                "completed"
423            }
424            .into(),
425            elapsed_ms: seconds * 1000.0,
426            wall_elapsed_ms: seconds * 1000.0,
427            setup_elapsed_ms: 0.0,
428            setup_operations: 0,
429            budget: None,
430            stop_reason: if cancelled { "cancelled" } else { "fixed_work" }.into(),
431            started_unix_ms: 0,
432            drain_ms: 0.0,
433            peak_inflight_logical_operations: 0,
434            attempted: self.attempted,
435            succeeded: self.succeeded,
436            failed: self.failed,
437            interrupted: self.interrupted,
438            final_error_rate: (self.succeeded + self.failed > 0)
439                .then(|| self.failed as f64 / (self.succeeded + self.failed) as f64),
440            bytes: self.bytes,
441            items: self.items,
442            mib_per_sec: if seconds > 0.0 {
443                self.bytes as f64 / (1 << 20) as f64 / seconds
444            } else {
445                0.0
446            },
447            logical_ops_per_sec: if seconds > 0.0 {
448                self.succeeded as f64 / seconds
449            } else {
450                0.0
451            },
452            items_per_sec: if seconds > 0.0 {
453                self.items as f64 / seconds
454            } else {
455                0.0
456            },
457            active_pause_ms: self.pause.as_secs_f64() * 1000.0,
458            latency: self.latency.summary(),
459            failed_latency: self.failed_latency.summary(),
460            first_data: self.first_data.summary(),
461            first_poll: self.first_poll.summary(),
462            resume_poll: self.resume_poll.summary(),
463            max_stream_chunk_bytes: self.max_stream_chunk,
464            interference: None,
465            errors: self.errors,
466        }
467    }
468}
469
470fn operation_unit(name: &str) -> &'static str {
471    match name {
472        "multipart_put" | "prepare_data" => "complete_object_upload",
473        "range_get" => "ranged_get",
474        "block_get" => "block_get",
475        "list" => "complete_prefix_scan",
476        "batch_delete" => "delete_batch",
477        "preflight" => "functional_check",
478        "prepare_metadata" => "small_object_upload",
479        "validate_retained_data" => "object_metadata_check",
480        _ => "object_read",
481    }
482}
483
484#[derive(Debug, Serialize)]
485pub struct MeasurementBudget {
486    pub duration_ms: u64,
487    pub duration_basis: String,
488    pub max_operations: usize,
489}
490
491/// Per-cohort context for a timed interference comparison, not a performance verdict.
492#[derive(Debug, Serialize)]
493pub struct InterferenceCohort {
494    pub comparison_valid: bool,
495    pub invalid_reasons: Vec<String>,
496    pub expected_workers: usize,
497    pub failed_workers: usize,
498    /// Sum of worker lifetimes within the observation window, excluding readiness/drain.
499    pub active_worker_seconds: f64,
500    pub object_pool_start: usize,
501    pub object_pool_objects: usize,
502    /// Eligible object capacity, not unique bytes actually read during the window.
503    pub object_pool_bytes: u64,
504    /// Distinct objects targeted by attempts, including initial readiness requests.
505    pub requested_objects: usize,
506    pub range_bytes: usize,
507}
508
509#[derive(Debug, Serialize)]
510pub struct PhaseReport {
511    pub name: String,
512    pub operation_unit: String,
513    pub item_unit: String,
514    pub status: String,
515    /// Rate denominator: measured wall time. Basic phases finish in-flight I/O;
516    /// interference phases exclude their separately reported drain.
517    /// DELETE replenishment between rounds is excluded.
518    pub elapsed_ms: f64,
519    pub wall_elapsed_ms: f64,
520    pub setup_elapsed_ms: f64,
521    pub setup_operations: u64,
522    pub budget: Option<MeasurementBudget>,
523    pub stop_reason: String,
524    pub started_unix_ms: u128,
525    /// Cleanup/drain after the timed interference window, excluded from its rates.
526    pub drain_ms: f64,
527    pub peak_inflight_logical_operations: usize,
528    pub attempted: u64,
529    pub succeeded: u64,
530    pub failed: u64,
531    /// Administratively stopped at the observation-window boundary, not I/O failures.
532    pub interrupted: u64,
533    pub final_error_rate: Option<f64>,
534    /// Application bytes, including progress from failed streaming operations.
535    pub bytes: u64,
536    pub items: u64,
537    pub mib_per_sec: f64,
538    pub logical_ops_per_sec: f64,
539    pub items_per_sec: f64,
540    pub active_pause_ms: f64,
541    /// Successful logical operations only; includes intentional pauses.
542    pub latency: Option<Distribution>,
543    pub failed_latency: Option<Distribution>,
544    /// Stream initialization through receipt of the first complete nonempty chunk.
545    pub first_data: Option<Distribution>,
546    /// First next-chunk await, excluding explicit stream opening; may include lazy initialization.
547    pub first_poll: Option<Distribution>,
548    /// Subsequent next-chunk awaits, excluding the first item, pauses and EOF.
549    /// In continuous mode these are subsequent reads without a pause.
550    pub resume_poll: Option<Distribution>,
551    pub max_stream_chunk_bytes: usize,
552    pub interference: Option<InterferenceCohort>,
553    pub errors: Vec<String>,
554}
555
556#[derive(Debug, Serialize)]
557pub struct BenchmarkReport {
558    pub schema_version: u32,
559    pub profile_version: u32,
560    pub prefix: String,
561    pub config: BenchmarkConfig,
562    pub planned_dataset_bytes: u64,
563    pub phases: Vec<PhaseReport>,
564    pub cleanup_status: String,
565    pub cleanup_errors: Vec<String>,
566    pub cancelled: bool,
567    pub backpressure_verified: bool,
568    pub notes: Vec<String>,
569}
570
571impl BenchmarkReport {
572    pub fn succeeded(&self) -> bool {
573        !self.cancelled
574            && self.phases.iter().all(|p| {
575                p.status == "completed"
576                    && p.interference.as_ref().is_none_or(|c| c.comparison_valid)
577            })
578            && matches!(self.cleanup_status.as_str(), "completed" | "kept")
579    }
580}
581
582/// Human-readable diagnostics on stderr; stdout remains a single JSON document.
583pub fn terminal_summary(report: &BenchmarkReport) -> String {
584    use std::fmt::Write;
585    let mut text = String::new();
586    writeln!(
587        text,
588        "\nObject-store benchmark (schema {}, profile {})",
589        report.schema_version, report.profile_version
590    )
591    .unwrap();
592    writeln!(
593        text,
594        "{:<46} {:<10} {:>9} {:>10} {:>10} {:>10} {:>10} {:>7}",
595        "Phase / logical operation",
596        "Status",
597        "Samples",
598        "MiB/s",
599        "Ops/s",
600        "p50 ms",
601        "p99 ms",
602        "Errors"
603    )
604    .unwrap();
605    for phase in &report.phases {
606        let percentile = |f: fn(&Distribution) -> f64| {
607            phase
608                .latency
609                .as_ref()
610                .map_or_else(|| "-".into(), |d| format!("{:.2}", f(d)))
611        };
612        writeln!(
613            text,
614            "{:<46} {:<10} {:>9} {:>10.2} {:>10.2} {:>10} {:>10} {:>7}",
615            phase.name,
616            phase.status,
617            phase.latency.as_ref().map_or(0, |d| d.samples),
618            phase.mib_per_sec,
619            phase.logical_ops_per_sec,
620            percentile(|d| d.p50_ms),
621            percentile(|d| d.p99_ms),
622            phase.failed
623        )
624        .unwrap();
625        writeln!(text, "  unit={}; measured={:.1} ms; wall={:.1} ms; stop={}; items={} {} ({:.2}/s); interrupted={}",
626            phase.operation_unit, phase.elapsed_ms, phase.wall_elapsed_ms, phase.stop_reason,
627            phase.items, phase.item_unit, phase.items_per_sec, phase.interrupted).unwrap();
628        if phase.setup_operations > 0 {
629            writeln!(
630                text,
631                "  excluded setup: {} uploads, {:.1} ms",
632                phase.setup_operations, phase.setup_elapsed_ms
633            )
634            .unwrap();
635        }
636        if phase.first_data.is_some()
637            || phase.first_poll.is_some()
638            || phase.resume_poll.is_some()
639            || phase.active_pause_ms > 0.0
640        {
641            writeln!(
642                text,
643                "  active pause across readers={:.1} ms",
644                phase.active_pause_ms
645            )
646            .unwrap();
647            for (name, timing) in [
648                ("first_data", &phase.first_data),
649                ("first_poll", &phase.first_poll),
650                ("resume_poll", &phase.resume_poll),
651            ] {
652                if let Some(d) = timing {
653                    writeln!(
654                        text,
655                        "  {name}: n={}; p95={:.2} ms; p99={:.2} ms; max={:.2} ms",
656                        d.samples, d.p95_ms, d.p99_ms, d.max_ms
657                    )
658                    .unwrap();
659                } else {
660                    writeln!(text, "  {name}: n=0; p95=-; p99=-; max=-").unwrap();
661                }
662            }
663        }
664        if let Some(c) = &phase.interference {
665            writeln!(text, "  comparison={}; workers={}; failed_workers={}; active_worker_seconds={:.3}; pool={} objects / {} bytes; requested_objects={}; range={} bytes",
666                if c.comparison_valid { "valid" } else { "invalid" }, c.expected_workers,
667                c.failed_workers, c.active_worker_seconds, c.object_pool_objects,
668                c.object_pool_bytes, c.requested_objects, c.range_bytes).unwrap();
669            for reason in &c.invalid_reasons {
670                writeln!(text, "  invalid comparison: {reason}").unwrap();
671            }
672        }
673    }
674    writeln!(
675        text,
676        "Cleanup: {}; prefix: {}",
677        report.cleanup_status, report.prefix
678    )
679    .unwrap();
680    writeln!(text, "Latency samples are successful logical operations, not HTTP requests or PUTPARTs. Small samples do not establish a stable p99.").unwrap();
681    writeln!(
682        text,
683        "Cache state and server-side backpressure are unverified. Full details are in JSON.\n"
684    )
685    .unwrap();
686    text
687}
688
689#[derive(Clone, Default)]
690pub(super) struct Inflight {
691    active: Arc<AtomicUsize>,
692    peak: Arc<AtomicUsize>,
693}
694
695struct InflightGuard(Arc<AtomicUsize>);
696
697impl Drop for InflightGuard {
698    fn drop(&mut self) {
699        self.0.fetch_sub(1, Ordering::Relaxed);
700    }
701}
702
703impl Inflight {
704    fn enter(&self) -> InflightGuard {
705        let active = self.active.fetch_add(1, Ordering::Relaxed) + 1;
706        self.peak.fetch_max(active, Ordering::Relaxed);
707        InflightGuard(self.active.clone())
708    }
709
710    fn peak(&self) -> usize {
711        self.peak.load(Ordering::Relaxed)
712    }
713}
714
715pub(super) async fn jobs<F, Fut>(
716    name: &str,
717    count: usize,
718    concurrency: usize,
719    token: &CancellationToken,
720    execute: F,
721) -> PhaseReport
722where
723    F: Fn(usize) -> Fut + Send + Sync + 'static,
724    Fut: Future<Output = Outcome> + Send + 'static,
725{
726    eprintln!("benchmark: {name} ({count} logical operations, {concurrency} workers)");
727    let started_unix_ms = unix_ms();
728    let (stats, elapsed, peak) = execute_jobs(count, concurrency, token, None, execute).await;
729    let mut report = stats.report(name.into(), elapsed, Some(count), token.is_cancelled());
730    report.started_unix_ms = started_unix_ms;
731    report.peak_inflight_logical_operations = peak;
732    report
733}
734
735/// Stop scheduling at the deadline; finish in-flight operations before returning.
736async fn execute_jobs<F, Fut>(
737    count: usize,
738    concurrency: usize,
739    token: &CancellationToken,
740    deadline: Option<Instant>,
741    execute: F,
742) -> (Accumulator, Duration, usize)
743where
744    F: Fn(usize) -> Fut + Send + Sync + 'static,
745    Fut: Future<Output = Outcome> + Send + 'static,
746{
747    let started = Instant::now();
748    let execute = Arc::new(execute);
749    let inflight = Inflight::default();
750    let mut tasks = JoinSet::new();
751    for worker in 0..concurrency.min(count) {
752        let execute = execute.clone();
753        let token = token.clone();
754        let inflight = inflight.clone();
755        tasks.spawn(async move {
756            let mut stats = Accumulator::default();
757            for index in (worker..count).step_by(concurrency) {
758                if token.is_cancelled() || deadline.is_some_and(|d| Instant::now() >= d) {
759                    break;
760                }
761                let started = Instant::now();
762                let guard = inflight.enter();
763                let outcome = execute(index).await;
764                drop(guard);
765                stats.add(outcome, started.elapsed());
766                tokio::task::yield_now().await;
767            }
768            stats
769        });
770    }
771    let mut stats = Accumulator::default();
772    while let Some(result) = tasks.join_next().await {
773        match result {
774            Ok(worker) => stats.merge(worker),
775            Err(error) => {
776                stats.failed += 1;
777                stats
778                    .errors
779                    .push(format!("worker failed: {}", error.as_report()));
780            }
781        }
782    }
783    (stats, started.elapsed(), inflight.peak())
784}
785
786pub(super) fn unix_ms() -> u128 {
787    std::time::SystemTime::now()
788        .duration_since(std::time::UNIX_EPOCH)
789        .unwrap_or_default()
790        .as_millis()
791}
792
793/// Counts only retries exposed by RW's outer monitoring layer, not SDK retries.
794pub fn observed_retries(metrics: &ObjectStoreMetrics) -> u64 {
795    use prometheus::core::Collector;
796    metrics
797        .request_retry_count
798        .collect()
799        .iter()
800        .flat_map(|family| family.get_metric())
801        .map(|metric| {
802            metric
803                .get_counter()
804                .as_ref()
805                .map_or(0, |counter| counter.value() as u64)
806        })
807        .sum()
808}
809
810/// Only objects whose keys are constructed here are ever deleted.
811pub async fn run(
812    store: Arc<ObjectStoreImpl>,
813    config: BenchmarkConfig,
814    prefix: String,
815    token: CancellationToken,
816) -> Result<BenchmarkReport> {
817    config.validate()?;
818    ensure!(
819        prefix.starts_with("risectl-bench/")
820            && prefix.ends_with('/')
821            && prefix.len() > "risectl-bench/".len(),
822        "expected a unique risectl-bench/<run-id>/ prefix"
823    );
824    let config = Arc::new(config);
825    let basic = config.profile != Profile::Compactor;
826    let compactor = config.profile != Profile::Basic;
827    let large_keys = Arc::new(
828        (0..config.objects)
829            .map(|i| format!("{prefix}data/{i:08}"))
830            .collect::<Vec<_>>(),
831    );
832    let metadata_keys = Arc::new(if basic {
833        (0..config.metadata_objects)
834            .map(|i| format!("{prefix}metadata/{i:08}"))
835            .collect::<Vec<_>>()
836    } else {
837        vec![]
838    });
839    let mut phases = vec![];
840
841    let ready = if config.reuse_data {
842        true
843    } else {
844        phases.push(basic::preflight(store.clone(), &prefix, &token).await);
845        phases.last().is_some_and(|p| p.status == "completed")
846    };
847    if config.reuse_data {
848        let s = store.clone();
849        let keys = large_keys.clone();
850        let size = config.object_size;
851        phases.push(
852            jobs(
853                "validate_retained_data",
854                keys.len(),
855                config.concurrency,
856                &token,
857                move |i| {
858                    let s = s.clone();
859                    let key = keys[i].clone();
860                    async move {
861                        Outcome::from_result(
862                            async {
863                                let metadata = s.metadata(&key).await?;
864                                ensure!(
865                                    metadata.total_size == size,
866                                    "retained object {key} has size {}, expected {size}",
867                                    metadata.total_size
868                                );
869                                Ok(Sample {
870                                    items: 1,
871                                    ..Default::default()
872                                })
873                            }
874                            .await,
875                        )
876                    }
877                },
878            )
879            .await,
880        );
881    } else if ready && !token.is_cancelled() {
882        // Prepare payload before measuring PUT; share immutable bytes between workers.
883        let mut payload = vec![0; config.object_size];
884        rand::rngs::StdRng::seed_from_u64(config.seed).fill_bytes(&mut payload);
885        let payload = Bytes::from(payload);
886        let s = store.clone();
887        let keys = large_keys.clone();
888        let upload_payload = payload.clone();
889        phases.push(
890            jobs(
891                "prepare_data",
892                keys.len(),
893                config.concurrency,
894                &token,
895                move |i| {
896                    let s = s.clone();
897                    let key = keys[i].clone();
898                    let payload = payload.clone();
899                    async move {
900                        Outcome::from_result(
901                            async {
902                                let mut writer = s.streaming_upload(&key).await?;
903                                for part in payload.chunks(1 << 20) {
904                                    writer.write_bytes(Bytes::copy_from_slice(part)).await?;
905                                }
906                                writer.finish().await?;
907                                Ok(Sample {
908                                    bytes: payload.len() as u64,
909                                    items: 1,
910                                    ..Default::default()
911                                })
912                            }
913                            .await,
914                        )
915                    }
916                },
917            )
918            .await,
919        );
920        if basic && phases.last().is_some_and(|p| p.status == "completed") && !token.is_cancelled()
921        {
922            phases.push(
923                basic::uploads(
924                    store.clone(),
925                    large_keys.clone(),
926                    upload_payload,
927                    config.clone(),
928                    &token,
929                )
930                .await,
931            );
932        }
933    }
934    let data_ready = phases
935        .iter()
936        .find(|p| p.name == "prepare_data" || p.name == "validate_retained_data")
937        .is_some_and(|p| p.status == "completed");
938    if basic && data_ready && !token.is_cancelled() {
939        phases
940            .extend(basic::reads(store.clone(), large_keys.clone(), config.clone(), &token).await);
941    }
942    if compactor && data_ready && !token.is_cancelled() {
943        for mode in &config.read_order {
944            if token.is_cancelled() {
945                break;
946            }
947            let s = store.clone();
948            let keys = large_keys.clone();
949            let cfg = config.clone();
950            let mode = *mode;
951            let cancel = token.clone();
952            phases.push(
953                jobs(
954                    &format!("compactor_{}", mode.name()),
955                    config.objects,
956                    config.concurrency,
957                    &token,
958                    move |i| {
959                        read::read_object(
960                            s.clone(),
961                            keys[i].clone(),
962                            cfg.clone(),
963                            mode,
964                            i,
965                            cancel.clone(),
966                        )
967                    },
968                )
969                .await,
970            );
971        }
972        if !token.is_cancelled() {
973            phases.extend(
974                read::interference(store.clone(), large_keys.clone(), config.clone(), &token).await,
975            );
976        }
977    }
978    let mut metadata_started = false;
979    if basic && data_ready && !token.is_cancelled() {
980        metadata_started = true;
981        let s = store.clone();
982        let keys = metadata_keys.clone();
983        phases.push(
984            jobs(
985                "prepare_metadata",
986                keys.len(),
987                config.concurrency,
988                &token,
989                move |i| {
990                    let s = s.clone();
991                    let key = keys[i].clone();
992                    async move {
993                        Outcome::from_result(
994                            async {
995                                s.upload(&key, Bytes::from(vec![i as u8; 4096])).await?;
996                                Ok(Sample {
997                                    bytes: 4096,
998                                    items: 1,
999                                    ..Default::default()
1000                                })
1001                            }
1002                            .await,
1003                        )
1004                    }
1005                },
1006            )
1007            .await,
1008        );
1009        if phases.last().is_some_and(|p| p.status == "completed") {
1010            phases.extend(
1011                basic::metadata(store.clone(), metadata_keys.clone(), config.clone(), &token).await,
1012            );
1013        }
1014    }
1015
1016    let mut expected = vec![if config.reuse_data {
1017        "validate_retained_data"
1018    } else {
1019        "prepare_data"
1020    }];
1021    if basic {
1022        expected.extend([
1023            "multipart_put",
1024            "range_get",
1025            "block_get",
1026            "prepare_metadata",
1027            "list",
1028            "batch_delete",
1029        ]);
1030    }
1031    if compactor {
1032        expected.extend([
1033            "compactor_continuous",
1034            "compactor_intermittent",
1035            "compactor_chunked",
1036            "interference_fast_only/fast",
1037            "interference_continuous_competitors/fast",
1038            "interference_continuous_competitors/competitors",
1039            "interference_slow_competitors/fast",
1040            "interference_slow_competitors/competitors",
1041        ]);
1042    }
1043    for name in expected {
1044        if !phases.iter().any(|phase| phase.name == name) {
1045            let mut skipped =
1046                Accumulator::default().report(name.into(), Duration::ZERO, None, false);
1047            skipped.status = "skipped".into();
1048            skipped
1049                .errors
1050                .push("prerequisite failed or cancellation requested".into());
1051            phases.push(skipped);
1052        }
1053    }
1054    let mut cleanup_errors = vec![];
1055    if !config.keep_data {
1056        // Ignore cancellation for cleanup; never scan or delete the bucket as a whole.
1057        let keys: Vec<_> = large_keys
1058            .iter()
1059            .chain(metadata_keys.iter().take(if metadata_started {
1060                metadata_keys.len()
1061            } else {
1062                0
1063            }))
1064            .cloned()
1065            .collect();
1066        for batch in keys.chunks(config.delete_batch_size) {
1067            if let Err(error) = store
1068                .delete_objects(batch)
1069                .await
1070                .context("cleanup test objects")
1071            {
1072                cleanup_errors.push(format!("{}", error.as_report()));
1073            }
1074        }
1075    }
1076    Ok(BenchmarkReport {
1077        schema_version: 5,
1078        profile_version: 5,
1079        prefix,
1080        planned_dataset_bytes: (config.objects * config.object_size) as u64 + metadata_keys.len() as u64 * 4096,
1081        config: (*config).clone(),
1082        phases,
1083        cleanup_status: if config.keep_data { "kept" } else if cleanup_errors.is_empty() { "completed" } else { "failed" }.into(),
1084        cleanup_errors,
1085        cancelled: token.is_cancelled(),
1086        backpressure_verified: false,
1087        notes: vec![
1088            "Logical operations use the RW client; SDK/internal retries and HTTP request counts are not fully observed.".into(),
1089            "Basic phases stop scheduling at their wall-time or operation limit, then finish in-flight I/O. DELETE replenishment consumes the wall-time budget but is excluded from measured rates; setup and measured time are separate.".into(),
1090            "Upload latency is a complete streaming upload, not PUTPART latency. Successful and failed logical-operation latency are separate; interrupted operations have no completed latency.".into(),
1091            "Quantiles include sample counts and are descriptive, not evidence of a stable tail with a small sample.".into(),
1092            "LIST measures complete RW enumeration and may include metadata HEAD requests; it is not HTTP LIST requests/sec.".into(),
1093            "Cleanup deletes known keys; failed/cancelled multipart uploads may require server lifecycle cleanup.".into(),
1094            "Cache state is uncontrolled. Read order is recorded; intentional pauses are included in end-to-end latency.".into(),
1095            "Interference cohorts rotate disjoint object pools and reset deterministic request sequences for each comparison. Pool bytes are eligible capacity, not unique bytes read; requested object counts include readiness. Worker-seconds measure lifetimes within the observation window, not time spent in I/O. A worker failure invalidates both sides of a comparison.".into(),
1096            "Compactor reads consume whole stream chunks; non-continuous modes pause at configured chunk boundaries up to max_pauses. Chunk sizes are transport-dependent (short_get_size in chunked mode). first_poll measures the first next-chunk await (possibly lazy initialization); resume_poll measures subsequent next-chunk awaits, excluding pauses and EOF (continuous mode has no pause). Both may complete from HTTP/TCP buffers; it is not network-only latency. Readiness consumes and excludes one whole chunk before the interference window.".into(),
1097            "No automatic performance pass/fail or server-side backpressure claim is made.".into(),
1098        ],
1099    })
1100}