1use thiserror_ext::AsReport;
16
17use super::*;
18
19fn limits(config: &BenchmarkConfig, fixed_count: usize) -> (usize, Duration) {
20 let duration = Duration::from_millis(config.basic_duration_ms);
21 let count = if duration.is_zero() {
22 fixed_count.min(config.basic_max_operations)
23 } else {
24 config.basic_max_operations
25 };
26 (count, duration)
27}
28
29fn finish(
30 stats: Accumulator,
31 name: &str,
32 elapsed: Duration,
33 count: usize,
34 fixed_count: usize,
35 duration: Duration,
36 token: &CancellationToken,
37) -> PhaseReport {
38 let attempts = stats.attempted;
39 let mut report = stats.report(
40 name.into(),
41 elapsed,
42 duration.is_zero().then_some(count),
43 token.is_cancelled(),
44 );
45 if attempts == 0 && !token.is_cancelled() {
46 report.status = "failed".into();
47 report
48 .errors
49 .push("measurement ended without any operations".into());
50 }
51 report.budget = Some(MeasurementBudget {
52 duration_ms: duration.as_millis() as u64,
53 max_operations: count,
54 duration_basis: "phase_wall_time".into(),
55 });
56 report.stop_reason = if token.is_cancelled() {
57 "cancelled"
58 } else if duration.is_zero() && count == fixed_count && attempts >= count as u64 {
59 "fixed_work"
60 } else if attempts >= count as u64 {
61 "operation_limit"
62 } else {
63 "duration"
64 }
65 .into();
66 report
67}
68
69async fn measure<F, Fut>(
70 name: &str,
71 fixed_count: usize,
72 concurrency: usize,
73 config: &BenchmarkConfig,
74 token: &CancellationToken,
75 execute: F,
76) -> PhaseReport
77where
78 F: Fn(usize) -> Fut + Send + Sync + 'static,
79 Fut: Future<Output = Outcome> + Send + 'static,
80{
81 let (count, duration) = limits(config, fixed_count);
82 eprintln!(
83 "benchmark: {name} ({} ms or {count} operations, {concurrency} workers)",
84 duration.as_millis()
85 );
86 let started_unix_ms = unix_ms();
87 let deadline = (!duration.is_zero()).then(|| Instant::now() + duration);
88 let (stats, elapsed, peak) = execute_jobs(count, concurrency, token, deadline, execute).await;
89 let mut report = finish(stats, name, elapsed, count, fixed_count, duration, token);
90 report.started_unix_ms = started_unix_ms;
91 report.peak_inflight_logical_operations = peak;
92 report
93}
94
95fn upload_index(index: usize, objects: usize, workers: usize) -> usize {
98 let worker = index % workers;
99 let owned = (objects - 1 - worker) / workers + 1;
100 worker + (index / workers % owned) * workers
101}
102
103pub(super) async fn uploads(
104 store: Arc<ObjectStoreImpl>,
105 keys: Arc<Vec<String>>,
106 payload: Bytes,
107 config: Arc<BenchmarkConfig>,
108 token: &CancellationToken,
109) -> PhaseReport {
110 let workers = config.concurrency.min(keys.len());
111 measure(
112 "multipart_put",
113 keys.len(),
114 workers,
115 &config,
116 token,
117 move |i| {
118 let store = store.clone();
119 let key = keys[upload_index(i, keys.len(), workers)].clone();
120 let payload = payload.clone();
121 async move {
122 Outcome::from_result(
123 async {
124 let mut writer = store.streaming_upload(&key).await?;
125 for part in payload.chunks(1 << 20) {
126 writer.write_bytes(Bytes::copy_from_slice(part)).await?;
127 }
128 writer.finish().await?;
129 Ok(Sample {
130 bytes: payload.len() as u64,
131 items: 1,
132 ..Default::default()
133 })
134 }
135 .await,
136 )
137 }
138 },
139 )
140 .await
141}
142
143pub(super) async fn reads(
144 store: Arc<ObjectStoreImpl>,
145 keys: Arc<Vec<String>>,
146 config: Arc<BenchmarkConfig>,
147 token: &CancellationToken,
148) -> Vec<PhaseReport> {
149 let mut phases = vec![];
150 for (name, size) in [
151 ("range_get", config.range_size),
152 ("block_get", config.block_size),
153 ] {
154 let store = store.clone();
155 let keys = keys.clone();
156 let cfg = config.clone();
157 phases.push(
158 measure(
159 name,
160 config.objects * config.read_iterations,
161 config.concurrency,
162 &config,
163 token,
164 move |i| {
165 let store = store.clone();
166 let key = keys[i % keys.len()].clone();
167 let cfg = cfg.clone();
168 async move {
169 Outcome::from_result(
170 async {
171 let offset = read::offset(cfg.seed, i, cfg.object_size - size);
172 let data = store.read(&key, offset..offset + size).await?;
173 ensure!(
174 data.len() == size,
175 "unexpected GET length: {} != {size}",
176 data.len()
177 );
178 Ok(Sample {
179 bytes: data.len() as u64,
180 items: 1,
181 ..Default::default()
182 })
183 }
184 .await,
185 )
186 }
187 },
188 )
189 .await,
190 );
191 }
192 phases
193}
194
195pub(super) async fn metadata(
196 store: Arc<ObjectStoreImpl>,
197 keys: Arc<Vec<String>>,
198 config: Arc<BenchmarkConfig>,
199 token: &CancellationToken,
200) -> Vec<PhaseReport> {
201 let s = store.clone();
202 let prefix = keys[0].rsplit_once('/').unwrap().0.to_owned() + "/";
203 let expected = keys.len();
204 let list = measure(
205 "list",
206 config.concurrency,
207 config.concurrency,
208 &config,
209 token,
210 move |_| {
211 let s = s.clone();
212 let prefix = prefix.clone();
213 async move {
214 Outcome::from_result(
215 async {
216 let mut stream = s.list(&prefix, None, None).await?;
217 let mut count = 0;
218 while stream.try_next().await?.is_some() {
219 count += 1;
220 }
221 ensure!(
222 count == expected,
223 "LIST returned {count} objects, expected {expected}"
224 );
225 Ok(Sample {
226 items: count as u64,
227 ..Default::default()
228 })
229 }
230 .await,
231 )
232 }
233 },
234 )
235 .await;
236 vec![list, deletes(store, keys, config, token).await]
237}
238
239async fn deletes(
240 store: Arc<ObjectStoreImpl>,
241 keys: Arc<Vec<String>>,
242 config: Arc<BenchmarkConfig>,
243 token: &CancellationToken,
244) -> PhaseReport {
245 let batch = config.delete_batch_size;
246 let batches = keys.len().div_ceil(batch);
247 let (limit, duration) = limits(&config, batches);
248 eprintln!(
249 "benchmark: batch_delete ({} wall ms or {limit} batches; replenishment excluded from rates)",
250 duration.as_millis()
251 );
252 let started_unix_ms = unix_ms();
253 let wall_started = Instant::now();
254 let mut elapsed = Duration::ZERO;
255 let mut setup_elapsed = Duration::ZERO;
256 let mut setup_operations = 0;
257 let mut setup_errors = vec![];
258 let mut peak = 0;
259 let mut stats = Accumulator::default();
260 let mut previous_count = 0;
261 while stats.attempted < limit as u64
262 && !token.is_cancelled()
263 && (duration.is_zero() || wall_started.elapsed() < duration)
264 {
265 if previous_count > 0 {
269 let s = store.clone();
270 let keys = keys.clone();
271 let (setup, cost, _) = execute_jobs(
272 previous_count,
273 config.concurrency,
274 token,
275 (!duration.is_zero()).then_some(wall_started + duration),
276 move |i| {
277 let s = s.clone();
278 let key = keys[i].clone();
279 async move {
280 Outcome::from_result(
281 async {
282 s.upload(&key, Bytes::from(vec![i as u8; 4096])).await?;
283 Ok(Sample::default())
284 }
285 .await,
286 )
287 }
288 },
289 )
290 .await;
291 setup_elapsed += cost;
292 setup_operations += setup.attempted;
293 if setup.failed > 0 {
294 setup_errors = setup.errors;
295 break;
296 }
297 if setup.attempted != previous_count as u64
298 || (!duration.is_zero() && wall_started.elapsed() >= duration)
299 {
300 break;
303 }
304 }
305 let count = batches.min(limit - stats.attempted as usize);
306 let deadline = (!duration.is_zero()).then_some(wall_started + duration);
307 let s = store.clone();
308 let deleted = keys.clone();
309 let (round, cost, round_peak) =
310 execute_jobs(count, config.concurrency, token, deadline, move |i| {
311 let s = s.clone();
312 let keys = deleted[i * batch..((i + 1) * batch).min(deleted.len())].to_vec();
313 async move {
314 Outcome::from_result(
315 async {
316 s.delete_objects(&keys).await?;
317 Ok(Sample {
318 items: keys.len() as u64,
319 ..Default::default()
320 })
321 }
322 .await,
323 )
324 }
325 })
326 .await;
327 let failed = round.failed > 0;
328 let attempts = round.attempted;
329 elapsed += cost;
330 peak = peak.max(round_peak);
331 stats.merge(round);
332 if failed || attempts < count as u64 || duration.is_zero() {
335 break;
336 }
337 previous_count = (count * batch).min(keys.len());
338 }
339 let mut report = finish(
340 stats,
341 "batch_delete",
342 elapsed,
343 limit,
344 batches,
345 duration,
346 token,
347 );
348 report.started_unix_ms = started_unix_ms;
349 report.wall_elapsed_ms = wall_started.elapsed().as_secs_f64() * 1000.0;
350 report.setup_elapsed_ms = setup_elapsed.as_secs_f64() * 1000.0;
351 report.setup_operations = setup_operations;
352 report.peak_inflight_logical_operations = peak;
353 if !setup_errors.is_empty() {
354 report.status = if token.is_cancelled() {
355 "cancelled"
356 } else {
357 "failed"
358 }
359 .into();
360 report.stop_reason = "setup_failed".into();
361 report.errors.extend(
362 setup_errors
363 .into_iter()
364 .map(|e| format!("DELETE replenishment: {e}")),
365 );
366 } else if report.failed > 0 {
367 report.stop_reason = "io_failure".into();
368 }
369 report
370}
371
372pub(super) async fn preflight(
375 store: Arc<ObjectStoreImpl>,
376 prefix: &str,
377 token: &CancellationToken,
378) -> PhaseReport {
379 let key = format!("{prefix}check/probe");
380 let prefix = format!("{prefix}check/");
381 jobs("preflight", 1, 1, token, move |_| {
382 let store = store.clone();
383 let key = key.clone();
384 let prefix = prefix.clone();
385 async move {
386 let result = async {
387 let data = Bytes::from((0..4096).map(|i| (i % 251) as u8).collect::<Vec<_>>());
388 store
389 .upload(&key, data.clone())
390 .await
391 .context("preflight PUT")?;
392 ensure!(
393 store
394 .metadata(&key)
395 .await
396 .context("preflight HEAD")?
397 .total_size
398 == data.len(),
399 "preflight HEAD returned an incorrect size"
400 );
401 ensure!(
402 store
403 .read(&key, 0..data.len())
404 .await
405 .context("preflight GET")?
406 == data,
407 "preflight GET returned incorrect content"
408 );
409 for range in [0..1, 17..83, 4095..4096] {
410 ensure!(
411 store
412 .read(&key, range.clone())
413 .await
414 .context("preflight Range GET")?
415 == data.slice(range),
416 "preflight Range GET returned incorrect content"
417 );
418 }
419 let listed = store
420 .list(&prefix, None, None)
421 .await
422 .context("preflight LIST")?
423 .try_collect::<Vec<_>>()
424 .await?;
425 ensure!(
426 listed.len() == 1 && listed[0].key == key,
427 "preflight LIST returned incorrect keys"
428 );
429 store
430 .delete_objects(std::slice::from_ref(&key))
431 .await
432 .context("preflight DELETE")?;
433 ensure!(
434 store
435 .list(&prefix, None, None)
436 .await?
437 .try_next()
438 .await?
439 .is_none(),
440 "preflight object is still listed after DELETE"
441 );
442 Ok(Sample {
443 items: 1,
444 ..Default::default()
445 })
446 }
447 .await;
448 let cleanup = store
451 .delete_objects(&[key])
452 .await
453 .context("preflight probe cleanup");
454 Outcome::from_result(match (result, cleanup) {
455 (Ok(sample), Ok(())) => Ok(sample),
456 (Err(error), Ok(())) => Err(error),
457 (Ok(_), Err(error)) => Err(error),
458 (Err(error), Err(cleanup)) => {
459 Err(error.context(format!("{}", cleanup.as_report())))
460 }
461 })
462 }
463 })
464 .await
465}
466
467#[cfg(all(test, not(madsim)))]
468mod tests {
469 use super::upload_index;
470
471 #[test]
472 fn uploads_have_disjoint_worker_keys() {
473 for objects in 1..18 {
474 for workers in 1..=objects {
475 let mut owners = vec![None; objects];
476 for index in 0..objects * workers * 3 {
477 let key = upload_index(index, objects, workers);
478 let worker = index % workers;
479 assert!(owners[key].is_none_or(|owner| owner == worker));
480 owners[key] = Some(worker);
481 }
482 assert!(owners.iter().all(Option::is_some));
483 }
484 }
485 }
486
487 #[tokio::test(start_paused = true)]
488 async fn stop_reasons_distinguish_fixed_work_caps_time_and_cancellation() {
489 use super::*;
490 for (duration, cap, cancelled, expected, succeeded) in [
491 (0, 100, false, "fixed_work", 4),
492 (0, 4, false, "fixed_work", 4),
493 (0, 2, false, "operation_limit", 2),
494 (25, 100, false, "duration", 3),
495 (25, 2, false, "operation_limit", 2),
496 (0, 100, true, "cancelled", 0),
497 ] {
498 let cfg = BenchmarkConfig {
499 basic_duration_ms: duration,
500 basic_max_operations: cap,
501 ..Default::default()
502 };
503 let token = CancellationToken::new();
504 if cancelled {
505 token.cancel();
506 }
507 let phase = measure("range_get", 4, 1, &cfg, &token, |_| async {
508 tokio::time::sleep(Duration::from_millis(10)).await;
509 Outcome::from_result(Ok(Sample::default()))
510 })
511 .await;
512 assert_eq!(phase.stop_reason, expected, "{phase:?}");
513 assert_eq!(phase.succeeded, succeeded);
514 }
515 }
516
517 #[tokio::test(start_paused = true)]
518 async fn duration_stops_scheduling_but_finishes_inflight_work() {
519 use super::*;
520 let cfg = BenchmarkConfig {
521 basic_duration_ms: 10,
522 basic_max_operations: 100,
523 ..Default::default()
524 };
525 let phase = measure(
526 "multipart_put",
527 1,
528 2,
529 &cfg,
530 &CancellationToken::new(),
531 |_| async {
532 tokio::time::sleep(Duration::from_millis(30)).await;
533 Outcome::from_result(Ok(Sample {
534 items: 1,
535 ..Default::default()
536 }))
537 },
538 )
539 .await;
540 assert_eq!(phase.status, "completed");
541 assert_eq!(phase.succeeded, 2);
542 assert!(phase.elapsed_ms >= 30.0);
543 assert_eq!(phase.stop_reason, "duration");
544 assert_eq!(phase.interrupted, 0);
545 assert_eq!(phase.peak_inflight_logical_operations, 2);
546 }
547}