Skip to main content

risingwave_object_store/benchmark/
basic.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 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
95/// Each worker owns a disjoint subset, including when object count is not a
96/// multiple of concurrency. Repeated uploads cannot overwrite another worker.
97fn 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        // All deletes from the preceding round have finished. Replenish outside
266        // measurement, so every measured DELETE targets existing objects and PUTs
267        // do not compete with the DELETE cohort.
268        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                // The wall-time budget or shutdown can stop replenishment too.
301                // Partially replenished keys remain known to final cleanup.
302                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        // A partial round is only possible at a deadline/cancellation, so no next
333        // round can accidentally issue DELETE on a missing key.
334        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
372/// A tiny fresh-prefix compatibility check before allocating/uploading large data.
373/// Retained-data runs bypass this write check and validate their existing objects.
374pub(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            // Retry cleanup even when a functional assertion failed. The probe is
449            // never intentionally retained, including with --keep-data.
450            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}