1use 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
37const GET_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
40
41#[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 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 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#[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 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 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 fn formatted_bucket_path(&self) -> String {
179 format!("projects/_/buckets/{}", self.bucket)
180 }
181
182 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 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 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 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 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 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 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 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 .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 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 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 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 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 .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 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)] #[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 #[cfg_attr(miri, ignore)] #[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)] #[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 #[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)] #[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}