Skip to main content

mz_persist/
gcs.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Implementation of [Blob] backed by Google Cloud Storage.
11
12use std::fmt::{Debug, Formatter};
13use std::future::Future;
14use std::sync::Arc;
15use std::time::Duration;
16
17use anyhow::{Context, anyhow};
18use async_trait::async_trait;
19use bytes::{Bytes, BytesMut};
20use futures_util::future::{try_join_all, try_join3};
21use google_cloud_gax::error::rpc::Code;
22use google_cloud_gax::options::RequestOptionsBuilder;
23use google_cloud_gax::paginator::ItemPaginator;
24use google_cloud_storage::client::{Storage, StorageControl};
25use google_cloud_storage::model_ext::ReadRange;
26use google_cloud_storage::read_object::ReadObjectResponse;
27use uuid::Uuid;
28
29use mz_ore::bytes::SegmentedBytes;
30use mz_ore::cast::CastFrom;
31use mz_ore::metrics::MetricsRegistry;
32
33use crate::cfg::BlobKnobs;
34use crate::location::{Blob, BlobMetadata, Determinate, ExternalError};
35use crate::metrics::S3BlobMetrics;
36
37/// The size of each concurrent range read in [GcsBlob::get], matching the
38/// part size S3 multipart uploads use.
39const GET_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
40
41/// Configuration for opening a [GcsBlob]
42#[derive(Clone, Debug)]
43pub struct GcsBlobConfig {
44    metrics: S3BlobMetrics,
45    knobs: Arc<dyn BlobKnobs>,
46    storage_client: Storage,
47    storage_control_client: StorageControl,
48    bucket: String,
49    prefix: String,
50}
51
52impl GcsBlobConfig {
53    const EXTERNAL_TESTS_GCS_BUCKET: &'static str = "MZ_PERSIST_EXTERNAL_STORAGE_TEST_GCS_BUCKET";
54
55    /// Returns a new [GcsBlobConfig] for use in production.
56    ///
57    /// Stores objects in the given bucket prepended with the (possibly empty)
58    /// prefix. GCS credentials must be available in the process or environment.
59    pub async fn new(
60        bucket: String,
61        prefix: String,
62        knobs: Box<dyn BlobKnobs>,
63        metrics: S3BlobMetrics,
64    ) -> Result<Self, ExternalError> {
65        let storage_client = Storage::builder()
66            .build()
67            .await
68            .map_err(|e| anyhow!("GCS Storage builder error: {}", e))?;
69        let storage_control_client = StorageControl::builder()
70            .build()
71            .await
72            .map_err(|e| anyhow!("GCS StorageControl builder error: {}", e))?;
73
74        Ok(GcsBlobConfig {
75            metrics,
76            knobs: Arc::from(knobs),
77            storage_client,
78            storage_control_client,
79            bucket,
80            prefix,
81        })
82    }
83
84    /// Returns a new [GcsBlobConfig] for use in tests.
85    pub async fn new_for_test() -> Result<Option<Self>, ExternalError> {
86        struct TestBlobKnobs;
87        impl Debug for TestBlobKnobs {
88            fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89                f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
90            }
91        }
92        impl BlobKnobs for TestBlobKnobs {
93            fn operation_timeout(&self) -> Duration {
94                Duration::from_secs(30)
95            }
96
97            fn operation_attempt_timeout(&self) -> Duration {
98                Duration::from_secs(10)
99            }
100
101            fn connect_timeout(&self) -> Duration {
102                Duration::from_secs(5)
103            }
104
105            fn read_timeout(&self) -> Duration {
106                Duration::from_secs(5)
107            }
108
109            fn is_cc_active(&self) -> bool {
110                false
111            }
112        }
113
114        let bucket = match std::env::var(Self::EXTERNAL_TESTS_GCS_BUCKET) {
115            Ok(bucket) => bucket,
116            Err(_) => {
117                if mz_ore::env::is_var_truthy("CI") {
118                    panic!("CI is supposed to run this test but something has gone wrong!");
119                }
120                return Ok(None);
121            }
122        };
123        let prefix = Uuid::new_v4().to_string();
124        let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
125        Ok(Some(
126            GcsBlobConfig::new(bucket, prefix, Box::new(TestBlobKnobs), metrics).await?,
127        ))
128    }
129}
130
131/// Implementation of [Blob] backed by GCS
132#[derive(Debug)]
133pub struct GcsBlob {
134    metrics: S3BlobMetrics,
135    knobs: Arc<dyn BlobKnobs>,
136    storage_client: Storage,
137    storage_control_client: StorageControl,
138    bucket: String,
139    prefix: String,
140}
141
142impl GcsBlob {
143    /// Opens the given location for non-exclusive read-write access.
144    pub async fn open(config: GcsBlobConfig) -> Result<Self, ExternalError> {
145        let ret = GcsBlob {
146            metrics: config.metrics,
147            knobs: config.knobs,
148            storage_client: config.storage_client,
149            storage_control_client: config.storage_control_client,
150            bucket: config.bucket,
151            prefix: config.prefix,
152        };
153        // Connect before returning success. Listing fails if the bucket is
154        // missing or we lack access to it, while reading a missing key would
155        // succeed against a missing bucket too.
156        ret.with_timeout("open", async {
157            ret.metrics.list_objects.inc();
158            ret.storage_control_client
159                .list_objects()
160                .set_parent(ret.formatted_bucket_path())
161                .set_prefix(format!("{}/", ret.prefix))
162                .set_page_size(1)
163                .with_attempt_timeout(ret.knobs.operation_attempt_timeout())
164                .send()
165                .await
166                .map_err(|e| ret.error("ListObjects", e))
167        })
168        .await?;
169        Ok(ret)
170    }
171
172    fn get_path(&self, key: &str) -> String {
173        format!("{}/{}", self.prefix, key)
174    }
175
176    /// Returns the bucket path formatted for gRPC API calls.
177    /// The gRPC API requires bucket names in the format `projects/_/buckets/{bucket}`.
178    fn formatted_bucket_path(&self) -> String {
179        format!("projects/_/buckets/{}", self.bucket)
180    }
181
182    /// Fails `op` if it takes longer than the operation timeout, read anew on
183    /// each call.
184    ///
185    /// NOTE: The SDK has no per-attempt timeout for `read_object` and
186    /// `write_object` (google-cloud-storage 1.20.0 leaves it as a TODO in
187    /// `perform_upload/unbuffered.rs`), nor a way to set connect or read
188    /// timeouts, so this is the only bound on a stalled data transfer.
189    async fn with_timeout<T>(
190        &self,
191        op: &str,
192        fut: impl Future<Output = Result<T, ExternalError>>,
193    ) -> Result<T, ExternalError> {
194        let timeout = self.knobs.operation_timeout();
195        match tokio::time::timeout(timeout, fut).await {
196            Ok(res) => res,
197            Err(_) => {
198                self.metrics.operation_timeouts.inc();
199                Err(ExternalError::from(anyhow!(
200                    "GCS {op} timed out after {timeout:?}"
201                )))
202            }
203        }
204    }
205
206    /// Converts an SDK error from the call `op` and counts it.
207    fn error(&self, op: &str, e: google_cloud_storage::Error) -> ExternalError {
208        let code = if let Some(status) = e.status() {
209            status.code.name().to_string()
210        } else if let Some(code) = e.http_status_code() {
211            format!("HTTP{code}")
212        } else if e.is_timeout() {
213            "Timeout".to_string()
214        } else if e.is_connect() {
215            "Connect".to_string()
216        } else if e.is_authentication() {
217            "Authentication".to_string()
218        } else if e.is_io() || e.is_transport() {
219            "Transport".to_string()
220        } else {
221            "Unknown".to_string()
222        };
223        self.metrics
224            .error_counts
225            .with_label_values(&[op, &code])
226            .inc();
227        ExternalError::from(anyhow!("GCS {op} error: {e}"))
228    }
229
230    async fn get_inner(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
231        let path = self.get_path(key);
232        let bucket = self.formatted_bucket_path();
233
234        // A single stream over one HTTP/1.1 connection is slow for large
235        // objects, so read in concurrent chunks. The first read also reports
236        // the object's size and generation. Later reads pin that generation,
237        // so a concurrent overwrite cannot mix two versions.
238        self.metrics.get_part.inc();
239        let first = match self
240            .storage_client
241            .read_object(&bucket, &path)
242            .set_read_range(ReadRange::head(GET_CHUNK_SIZE))
243            .send()
244            .await
245        {
246            Ok(stream) => stream,
247            Err(e) if is_not_found(&e) => return Ok(None),
248            // GCS rejects any range of an empty object.
249            Err(e) if is_range_not_satisfiable(&e) => return Ok(Some(SegmentedBytes::default())),
250            Err(e) => return Err(self.error("ReadObject", e)),
251        };
252        let size = u64::try_from(first.object().size).context("GCS sent negative size value")?;
253        let generation = first.object().generation;
254        // The SDK skips checksum validation for range reads, so check the
255        // whole object's CRC32C ourselves. GCS sends it only when the range
256        // covers the whole object, so larger objects fetch it from the
257        // object's metadata, concurrently with the remaining chunks.
258        let first_crc32c = first.object().checksums.as_ref().and_then(|c| c.crc32c);
259        let expected_crc32c = async {
260            let crc32c = match first_crc32c {
261                Some(crc32c) => Some(crc32c),
262                None => self
263                    .storage_control_client
264                    .get_object()
265                    .set_bucket(&bucket)
266                    .set_object(&path)
267                    .set_generation(generation)
268                    .with_attempt_timeout(self.knobs.operation_attempt_timeout())
269                    .send()
270                    .await
271                    .map_err(|e| self.error("GetObject", e))?
272                    .checksums
273                    .and_then(|c| c.crc32c),
274            };
275            crc32c.ok_or_else(|| ExternalError::from(anyhow!("GCS object {path} has no crc32c")))
276        };
277
278        let rest = (GET_CHUNK_SIZE..size)
279            .step_by(usize::cast_from(GET_CHUNK_SIZE))
280            .map(|offset| {
281                let count = GET_CHUNK_SIZE.min(size - offset);
282                let read = self
283                    .storage_client
284                    .read_object(&bucket, &path)
285                    .set_generation(generation)
286                    .set_read_range(ReadRange::segment(offset, count));
287                async move {
288                    let stream = read.send().await.map_err(|e| self.error("ReadObject", e))?;
289                    self.read_body(stream, count).await
290                }
291            });
292        let (first, rest, expected_crc32c) = try_join3(
293            self.read_body(first, GET_CHUNK_SIZE.min(size)),
294            try_join_all(rest),
295            expected_crc32c,
296        )
297        .await?;
298
299        let mut segments = Vec::with_capacity(rest.len() + 1);
300        let mut fetched = 0;
301        let mut actual_crc32c = 0;
302        for (segment, crc) in std::iter::once(first).chain(rest) {
303            fetched += u64::cast_from(segment.len());
304            actual_crc32c = crc32c::crc32c_combine(actual_crc32c, crc, segment.len());
305            segments.push(segment);
306        }
307        if fetched != size {
308            return Err(anyhow!("GCS ReadObject {path} returned {fetched} of {size} bytes").into());
309        }
310        if actual_crc32c != expected_crc32c {
311            return Err(anyhow!(
312                "GCS ReadObject {path} checksum mismatch: expected crc32c {expected_crc32c:#010x}, got {actual_crc32c:#010x}"
313            )
314            .into());
315        }
316        Ok(Some(SegmentedBytes::from(segments)))
317    }
318
319    /// Collects a read into one allocation, presized to `capacity` bytes, and
320    /// returns it with its CRC32C.
321    async fn read_body(
322        &self,
323        mut stream: ReadObjectResponse,
324        capacity: u64,
325    ) -> Result<(Bytes, u32), ExternalError> {
326        let mut buf = BytesMut::with_capacity(usize::cast_from(capacity));
327        let mut crc = 0;
328        while let Some(data) = stream.next().await {
329            let data = data.map_err(|e| self.error("ReadObject", e))?;
330            // Hashing as data arrives overlaps it with the transfer.
331            crc = crc32c::crc32c_append(crc, &data);
332            buf.extend_from_slice(&data);
333        }
334        Ok((buf.freeze(), crc))
335    }
336
337    async fn list_keys_and_metadata_inner(
338        &self,
339        key_prefix: &str,
340        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
341    ) -> Result<(), ExternalError> {
342        let blob_key_prefix = self.get_path(key_prefix);
343        let strippable_root_prefix = format!("{}/", self.prefix);
344
345        let mut stream = self
346            .storage_control_client
347            .list_objects()
348            .set_parent(&self.formatted_bucket_path())
349            .set_prefix(blob_key_prefix)
350            .with_attempt_timeout(self.knobs.operation_attempt_timeout())
351            .by_item();
352        self.metrics.list_objects.inc();
353        while let Some(response) = stream.next().await {
354            let obj = response.map_err(|e| self.error("ListObjects", e))?;
355            if let Some(key) = obj.name.strip_prefix(&strippable_root_prefix) {
356                let size_in_bytes =
357                    u64::try_from(obj.size).context("GCS sent negative size value")?;
358                f(BlobMetadata { key, size_in_bytes })
359            } else {
360                return Err(ExternalError::from(anyhow!(
361                    "found key with invalid prefix: {}",
362                    obj.name
363                )));
364            }
365        }
366        Ok(())
367    }
368
369    async fn set_inner(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
370        let path = self.get_path(key);
371        self.metrics.set_single.inc();
372        self.storage_client
373            .write_object(&self.formatted_bucket_path(), path, value)
374            .send_unbuffered()
375            .await
376            .map_err(|e| self.error("WriteObject", e))?;
377        Ok(())
378    }
379
380    async fn delete_inner(&self, key: &str) -> Result<Option<usize>, ExternalError> {
381        let path = self.get_path(key);
382
383        // GCS deletes don't report the object size, so fetch it first. Making
384        // the delete conditional on the fetched generation keeps the reported
385        // size accurate.
386        self.metrics.delete_head.inc();
387        let obj = match self
388            .storage_control_client
389            .get_object()
390            .set_bucket(&self.formatted_bucket_path())
391            .set_object(&path)
392            .with_attempt_timeout(self.knobs.operation_attempt_timeout())
393            .send()
394            .await
395        {
396            Ok(obj) => obj,
397            Err(e) if is_not_found(&e) => return Ok(None),
398            Err(e) => return Err(self.error("GetObject", e)),
399        };
400        let size = usize::try_from(obj.size).context("GCS sent negative size value")?;
401        self.metrics.delete_object.inc();
402        match self
403            .storage_control_client
404            .delete_object()
405            .set_bucket(&self.formatted_bucket_path())
406            .set_object(path)
407            // NOTE: `set_generation` would permanently delete that generation,
408            // even in a versioned bucket, leaving nothing for `restore`.
409            .set_if_generation_match(obj.generation)
410            .with_attempt_timeout(self.knobs.operation_attempt_timeout())
411            .send()
412            .await
413        {
414            Ok(()) => Ok(Some(size)),
415            // Raced with a concurrent delete.
416            Err(e) if is_not_found(&e) => Ok(None),
417            Err(e) => Err(self.error("DeleteObject", e)),
418        }
419    }
420
421    async fn restore_inner(&self, key: &str) -> Result<(), ExternalError> {
422        let path = self.get_path(key);
423
424        match self
425            .storage_control_client
426            .get_object()
427            .set_bucket(&self.formatted_bucket_path())
428            .set_object(&path)
429            .with_attempt_timeout(self.knobs.operation_attempt_timeout())
430            .send()
431            .await
432        {
433            Ok(_) => return Ok(()),
434            Err(e) if is_not_found(&e) => {}
435            Err(e) => return Err(self.error("GetObject", e)),
436        }
437
438        // Restoring relies on object versioning: a delete leaves the object's
439        // data behind as a noncurrent generation, which we copy back to a live
440        // object. Soft-deleted objects are not considered.
441        let mut stream = self
442            .storage_control_client
443            .list_objects()
444            .set_parent(&self.formatted_bucket_path())
445            .set_prefix(&path)
446            .set_versions(true)
447            .with_attempt_timeout(self.knobs.operation_attempt_timeout())
448            .by_item();
449        let mut latest_generation = None;
450        while let Some(obj) = stream.next().await {
451            let obj = obj.map_err(|e| self.error("ListObjects", e))?;
452            // The prefix also matches longer keys.
453            if obj.name == path && latest_generation < Some(obj.generation) {
454                latest_generation = Some(obj.generation);
455            }
456        }
457        let Some(generation) = latest_generation else {
458            return Err(Determinate::new(anyhow!(
459                "unable to restore {key} in GCS: no valid version exists"
460            ))
461            .into());
462        };
463
464        // A rewrite may take several calls, each resuming from the previous
465        // call's token.
466        let mut rewrite_token = String::new();
467        loop {
468            let response = match self
469                .storage_control_client
470                .rewrite_object()
471                .set_source_bucket(self.formatted_bucket_path())
472                .set_source_object(&path)
473                .set_source_generation(generation)
474                .set_destination_bucket(self.formatted_bucket_path())
475                .set_destination_name(&path)
476                // Only restore if no live object exists.
477                .set_if_generation_match(0)
478                .set_rewrite_token(rewrite_token)
479                .with_attempt_timeout(self.knobs.operation_attempt_timeout())
480                .send()
481                .await
482            {
483                Ok(response) => response,
484                // A live object appeared concurrently.
485                Err(e) if is_failed_precondition(&e) => return Ok(()),
486                Err(e) => return Err(self.error("RewriteObject", e)),
487            };
488            if response.done {
489                return Ok(());
490            }
491            rewrite_token = response.rewrite_token;
492        }
493    }
494}
495
496#[async_trait]
497impl Blob for GcsBlob {
498    async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
499        self.with_timeout("get", self.get_inner(key)).await
500    }
501
502    async fn list_keys_and_metadata(
503        &self,
504        key_prefix: &str,
505        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
506    ) -> Result<(), ExternalError> {
507        self.with_timeout("list", self.list_keys_and_metadata_inner(key_prefix, f))
508            .await
509    }
510
511    async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
512        self.with_timeout("set", self.set_inner(key, value)).await
513    }
514
515    async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
516        self.with_timeout("delete", self.delete_inner(key)).await
517    }
518
519    async fn restore(&self, key: &str) -> Result<(), ExternalError> {
520        self.with_timeout("restore", self.restore_inner(key)).await
521    }
522}
523
524fn is_not_found(e: &google_cloud_storage::Error) -> bool {
525    e.status().is_some_and(|s| s.code == Code::NotFound) || e.http_status_code() == Some(404)
526}
527
528fn is_range_not_satisfiable(e: &google_cloud_storage::Error) -> bool {
529    e.http_status_code() == Some(416)
530}
531
532fn is_failed_precondition(e: &google_cloud_storage::Error) -> bool {
533    e.status()
534        .is_some_and(|s| s.code == Code::FailedPrecondition)
535        || e.http_status_code() == Some(412)
536}
537
538#[cfg(test)]
539mod tests {
540    use tracing::info;
541
542    use super::*;
543    use crate::location::tests::blob_impl_test;
544
545    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
546    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
547    async fn gcs_blob() -> Result<(), ExternalError> {
548        let config = match GcsBlobConfig::new_for_test().await? {
549            Some(client) => client,
550            None => {
551                info!(
552                    "{} env not set: skipping test that uses external service",
553                    GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
554                );
555                return Ok(());
556            }
557        };
558        blob_impl_test(move |_path| {
559            let config = config.clone();
560            async move { GcsBlob::open(config).await }
561        })
562        .await
563    }
564
565    /// `blob_impl_test` accepts a backend that cannot restore, but `GcsBlob`
566    /// must restore from a versioned bucket, which the test bucket is.
567    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
568    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
569    async fn gcs_blob_restore() -> Result<(), ExternalError> {
570        let Some(config) = GcsBlobConfig::new_for_test().await? else {
571            info!(
572                "{} env not set: skipping test that uses external service",
573                GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
574            );
575            return Ok(());
576        };
577        let blob = GcsBlob::open(config).await?;
578        blob.set("k", Bytes::from("data")).await?;
579        assert_eq!(blob.delete("k").await?, Some("data".len()));
580        assert_eq!(blob.get("k").await?, None);
581        blob.restore("k").await?;
582        assert_eq!(
583            blob.get("k").await?.map(|b| b.into_contiguous()),
584            Some(b"data".to_vec())
585        );
586        blob.delete("k").await?;
587        Ok(())
588    }
589
590    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
591    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
592    async fn gcs_blob_get_chunk_boundaries() -> Result<(), ExternalError> {
593        let Some(config) = GcsBlobConfig::new_for_test().await? else {
594            info!(
595                "{} env not set: skipping test that uses external service",
596                GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
597            );
598            return Ok(());
599        };
600        let blob = GcsBlob::open(config).await?;
601        let chunk = usize::cast_from(GET_CHUNK_SIZE);
602        for len in [0, 1, chunk - 1, chunk, chunk + 1, 3 * chunk - 5] {
603            let value: Vec<u8> = (0..len).map(|i| u8::try_from(i % 251).unwrap()).collect();
604            let key = format!("k{len}");
605            blob.set(&key, Bytes::from(value.clone())).await?;
606            let got = blob.get(&key).await?.map(|b| b.into_contiguous());
607            assert!(got.as_ref() == Some(&value), "mismatch for len {len}");
608            blob.delete(&key).await?;
609        }
610        Ok(())
611    }
612
613    /// Times 128 MiB gets and sets through a single-stream read, [GcsBlob],
614    /// and, when `MZ_PERSIST_BENCH_GCS_HMAC_ID` and
615    /// `MZ_PERSIST_BENCH_GCS_HMAC_SECRET` are set, [S3Blob] against GCS's S3
616    /// API with the HTTP pool. Run in the bucket's region, with
617    /// `--nocapture`, for meaningful numbers.
618    ///
619    /// [S3Blob]: crate::s3::S3Blob
620    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
621    #[ignore]
622    async fn gcs_blob_throughput() -> Result<(), ExternalError> {
623        use std::time::Instant;
624
625        use crate::cfg::BlobKnobs;
626        use crate::s3::{BlobHttpPool, S3Blob, S3BlobConfig};
627
628        const LEN: usize = 128 * 1024 * 1024;
629        const ITERS: usize = 10;
630
631        #[derive(Debug)]
632        struct BenchKnobs;
633        impl BlobKnobs for BenchKnobs {
634            fn operation_timeout(&self) -> Duration {
635                Duration::from_secs(120)
636            }
637            fn operation_attempt_timeout(&self) -> Duration {
638                Duration::from_secs(60)
639            }
640            fn connect_timeout(&self) -> Duration {
641                Duration::from_secs(5)
642            }
643            fn read_timeout(&self) -> Duration {
644                Duration::from_secs(10)
645            }
646            fn is_cc_active(&self) -> bool {
647                false
648            }
649            fn http_pool(&self) -> BlobHttpPool {
650                BlobHttpPool::Gcs
651            }
652        }
653
654        async fn time<F: Future<Output = Result<(), ExternalError>>>(
655            name: &str,
656            mut op: impl FnMut() -> F,
657        ) -> Result<(), ExternalError> {
658            let mut elapsed = Vec::new();
659            for _ in 0..ITERS {
660                let start = Instant::now();
661                op().await?;
662                elapsed.push(start.elapsed());
663            }
664            elapsed.sort();
665            let median = elapsed[ITERS / 2];
666            let mib_s = 128.0 / median.as_secs_f64();
667            println!(
668                "{name:<24} median {median:>10.2?} ({mib_s:>5.0} MiB/s), min {:.2?}, max {:.2?}",
669                elapsed[0],
670                elapsed[ITERS - 1],
671            );
672            Ok(())
673        }
674
675        let config = GcsBlobConfig::new_for_test()
676            .await?
677            .expect("bucket env var must be set");
678        let bucket = config.bucket.clone();
679        let prefix = config.prefix.clone();
680        let gcs = GcsBlob::open(config).await?;
681        let value = Bytes::from(vec![7u8; LEN]);
682
683        time("gcs set", || gcs.set("bench", value.clone())).await?;
684        time("gcs get single stream", || async {
685            let stream = gcs
686                .storage_client
687                .read_object(&gcs.formatted_bucket_path(), gcs.get_path("bench"))
688                .send()
689                .await
690                .map_err(|e| gcs.error("ReadObject", e))?;
691            let (bytes, _) = gcs.read_body(stream, u64::cast_from(LEN)).await?;
692            assert_eq!(bytes.len(), LEN);
693            Ok(())
694        })
695        .await?;
696        time("gcs get", || async {
697            let bytes = gcs.get("bench").await?.expect("bench blob exists");
698            assert_eq!(bytes.len(), LEN);
699            Ok(())
700        })
701        .await?;
702        gcs.delete("bench").await?;
703
704        let (Ok(id), Ok(secret)) = (
705            std::env::var("MZ_PERSIST_BENCH_GCS_HMAC_ID"),
706            std::env::var("MZ_PERSIST_BENCH_GCS_HMAC_SECRET"),
707        ) else {
708            return Ok(());
709        };
710        let config = S3BlobConfig::new(
711            bucket,
712            prefix,
713            None,
714            Some("https://storage.googleapis.com".into()),
715            Some("auto".into()),
716            Some((id, secret)),
717            Box::new(BenchKnobs),
718            S3BlobMetrics::new(&MetricsRegistry::new()),
719        )
720        .await
721        .map_err(|e| anyhow!("{e}"))?;
722        let s3 = S3Blob::open(config).await?;
723        time("s3 set", || s3.set("bench", value.clone())).await?;
724        time("s3 get", || async {
725            let bytes = s3.get("bench").await?.expect("bench blob exists");
726            assert_eq!(bytes.len(), LEN);
727            Ok(())
728        })
729        .await?;
730        s3.delete("bench").await?;
731        Ok(())
732    }
733
734    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
735    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
736    async fn gcs_blob_open_missing_bucket() -> Result<(), ExternalError> {
737        let Some(mut config) = GcsBlobConfig::new_for_test().await? else {
738            info!(
739                "{} env not set: skipping test that uses external service",
740                GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
741            );
742            return Ok(());
743        };
744        config.bucket = format!("mz-missing-{}", Uuid::new_v4());
745        let err = GcsBlob::open(config)
746            .await
747            .expect_err("opening a missing bucket succeeded");
748        assert!(err.to_string().contains("ListObjects"), "{err}");
749        Ok(())
750    }
751}