1mod basic;
19mod read;
20mod trace;
21
22pub use trace::{ReadTrace, TraceSummary, TraceWriter};
23#[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#[derive(Clone, Debug, Serialize, Deserialize)]
48#[serde(default, deny_unknown_fields)]
49pub struct BenchmarkConfig {
50 #[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 pub basic_duration_ms: u64,
61 pub basic_max_operations: usize,
63 pub block_size: usize,
64 pub pause_ms: u64,
65 pub pause_every_chunks: usize,
67 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 pub reuse_data: bool,
81 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#[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 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#[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 pub active_worker_seconds: f64,
500 pub object_pool_start: usize,
501 pub object_pool_objects: usize,
502 pub object_pool_bytes: u64,
504 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 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 pub drain_ms: f64,
527 pub peak_inflight_logical_operations: usize,
528 pub attempted: u64,
529 pub succeeded: u64,
530 pub failed: u64,
531 pub interrupted: u64,
533 pub final_error_rate: Option<f64>,
534 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 pub latency: Option<Distribution>,
543 pub failed_latency: Option<Distribution>,
544 pub first_data: Option<Distribution>,
546 pub first_poll: Option<Distribution>,
548 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
582pub 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
735async 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
793pub 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
810pub 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 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 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}