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;
20use google_cloud_gax::error::rpc::Code;
21use google_cloud_gax::options::RequestOptionsBuilder;
22use google_cloud_gax::paginator::ItemPaginator;
23use google_cloud_storage::client::{Storage, StorageControl};
24use uuid::Uuid;
25
26use mz_ore::bytes::SegmentedBytes;
27use mz_ore::metrics::MetricsRegistry;
28
29use crate::cfg::BlobKnobs;
30use crate::location::{Blob, BlobMetadata, Determinate, ExternalError};
31use crate::metrics::S3BlobMetrics;
32
33#[derive(Clone, Debug)]
35pub struct GcsBlobConfig {
36 metrics: S3BlobMetrics,
37 knobs: Arc<dyn BlobKnobs>,
38 storage_client: Storage,
39 storage_control_client: StorageControl,
40 bucket: String,
41 prefix: String,
42}
43
44impl GcsBlobConfig {
45 const EXTERNAL_TESTS_GCS_BUCKET: &'static str = "MZ_PERSIST_EXTERNAL_STORAGE_TEST_GCS_BUCKET";
46
47 pub async fn new(
52 bucket: String,
53 prefix: String,
54 knobs: Box<dyn BlobKnobs>,
55 metrics: S3BlobMetrics,
56 ) -> Result<Self, ExternalError> {
57 let storage_client = Storage::builder()
58 .build()
59 .await
60 .map_err(|e| anyhow!("GCS Storage builder error: {}", e))?;
61 let storage_control_client = StorageControl::builder()
62 .build()
63 .await
64 .map_err(|e| anyhow!("GCS StorageControl builder error: {}", e))?;
65
66 Ok(GcsBlobConfig {
67 metrics,
68 knobs: Arc::from(knobs),
69 storage_client,
70 storage_control_client,
71 bucket,
72 prefix,
73 })
74 }
75
76 pub async fn new_for_test() -> Result<Option<Self>, ExternalError> {
78 struct TestBlobKnobs;
79 impl Debug for TestBlobKnobs {
80 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
82 }
83 }
84 impl BlobKnobs for TestBlobKnobs {
85 fn operation_timeout(&self) -> Duration {
86 Duration::from_secs(30)
87 }
88
89 fn operation_attempt_timeout(&self) -> Duration {
90 Duration::from_secs(10)
91 }
92
93 fn connect_timeout(&self) -> Duration {
94 Duration::from_secs(5)
95 }
96
97 fn read_timeout(&self) -> Duration {
98 Duration::from_secs(5)
99 }
100
101 fn is_cc_active(&self) -> bool {
102 false
103 }
104 }
105
106 let bucket = match std::env::var(Self::EXTERNAL_TESTS_GCS_BUCKET) {
107 Ok(bucket) => bucket,
108 Err(_) => {
109 if mz_ore::env::is_var_truthy("CI") {
110 panic!("CI is supposed to run this test but something has gone wrong!");
111 }
112 return Ok(None);
113 }
114 };
115 let prefix = Uuid::new_v4().to_string();
116 let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
117 Ok(Some(
118 GcsBlobConfig::new(bucket, prefix, Box::new(TestBlobKnobs), metrics).await?,
119 ))
120 }
121}
122
123#[derive(Debug)]
125pub struct GcsBlob {
126 metrics: S3BlobMetrics,
127 knobs: Arc<dyn BlobKnobs>,
128 storage_client: Storage,
129 storage_control_client: StorageControl,
130 bucket: String,
131 prefix: String,
132}
133
134impl GcsBlob {
135 pub async fn open(config: GcsBlobConfig) -> Result<Self, ExternalError> {
137 let ret = GcsBlob {
138 metrics: config.metrics,
139 knobs: config.knobs,
140 storage_client: config.storage_client,
141 storage_control_client: config.storage_control_client,
142 bucket: config.bucket,
143 prefix: config.prefix,
144 };
145 ret.with_timeout("open", async {
149 ret.metrics.list_objects.inc();
150 ret.storage_control_client
151 .list_objects()
152 .set_parent(ret.formatted_bucket_path())
153 .set_prefix(format!("{}/", ret.prefix))
154 .set_page_size(1)
155 .with_attempt_timeout(ret.knobs.operation_attempt_timeout())
156 .send()
157 .await
158 .map_err(|e| ret.error("ListObjects", e))
159 })
160 .await?;
161 Ok(ret)
162 }
163
164 fn get_path(&self, key: &str) -> String {
165 format!("{}/{}", self.prefix, key)
166 }
167
168 fn formatted_bucket_path(&self) -> String {
171 format!("projects/_/buckets/{}", self.bucket)
172 }
173
174 async fn with_timeout<T>(
182 &self,
183 op: &str,
184 fut: impl Future<Output = Result<T, ExternalError>>,
185 ) -> Result<T, ExternalError> {
186 let timeout = self.knobs.operation_timeout();
187 match tokio::time::timeout(timeout, fut).await {
188 Ok(res) => res,
189 Err(_) => {
190 self.metrics.operation_timeouts.inc();
191 Err(ExternalError::from(anyhow!(
192 "GCS {op} timed out after {timeout:?}"
193 )))
194 }
195 }
196 }
197
198 fn error(&self, op: &str, e: google_cloud_storage::Error) -> ExternalError {
200 let code = if let Some(status) = e.status() {
201 status.code.name().to_string()
202 } else if let Some(code) = e.http_status_code() {
203 format!("HTTP{code}")
204 } else if e.is_timeout() {
205 "Timeout".to_string()
206 } else if e.is_connect() {
207 "Connect".to_string()
208 } else if e.is_authentication() {
209 "Authentication".to_string()
210 } else if e.is_io() || e.is_transport() {
211 "Transport".to_string()
212 } else {
213 "Unknown".to_string()
214 };
215 self.metrics
216 .error_counts
217 .with_label_values(&[op, &code])
218 .inc();
219 ExternalError::from(anyhow!("GCS {op} error: {e}"))
220 }
221
222 async fn get_inner(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
223 let path = self.get_path(key);
224
225 self.metrics.get_part.inc();
226 let mut stream = match self
227 .storage_client
228 .read_object(&self.formatted_bucket_path(), path)
229 .send()
230 .await
231 {
232 Ok(stream) => stream,
233 Err(e) if is_not_found(&e) => return Ok(None),
234 Err(e) => return Err(self.error("ReadObject", e)),
235 };
236 let size = usize::try_from(stream.object().size).context("GCS sent negative size value")?;
237 let mut buf: Vec<u8> = Vec::with_capacity(size);
238 while let Some(data) = stream.next().await {
239 let data = data.map_err(|e| self.error("ReadObject", e))?;
240 buf.extend_from_slice(&data[..])
241 }
242 Ok(Some(SegmentedBytes::from(buf)))
243 }
244
245 async fn list_keys_and_metadata_inner(
246 &self,
247 key_prefix: &str,
248 f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
249 ) -> Result<(), ExternalError> {
250 let blob_key_prefix = self.get_path(key_prefix);
251 let strippable_root_prefix = format!("{}/", self.prefix);
252
253 let mut stream = self
254 .storage_control_client
255 .list_objects()
256 .set_parent(&self.formatted_bucket_path())
257 .set_prefix(blob_key_prefix)
258 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
259 .by_item();
260 self.metrics.list_objects.inc();
261 while let Some(response) = stream.next().await {
262 let obj = response.map_err(|e| self.error("ListObjects", e))?;
263 if let Some(key) = obj.name.strip_prefix(&strippable_root_prefix) {
264 let size_in_bytes =
265 u64::try_from(obj.size).context("GCS sent negative size value")?;
266 f(BlobMetadata { key, size_in_bytes })
267 } else {
268 return Err(ExternalError::from(anyhow!(
269 "found key with invalid prefix: {}",
270 obj.name
271 )));
272 }
273 }
274 Ok(())
275 }
276
277 async fn set_inner(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
278 let path = self.get_path(key);
279 self.metrics.set_single.inc();
280 self.storage_client
281 .write_object(&self.formatted_bucket_path(), path, value)
282 .send_unbuffered()
283 .await
284 .map_err(|e| self.error("WriteObject", e))?;
285 Ok(())
286 }
287
288 async fn delete_inner(&self, key: &str) -> Result<Option<usize>, ExternalError> {
289 let path = self.get_path(key);
290
291 self.metrics.delete_head.inc();
295 let obj = match self
296 .storage_control_client
297 .get_object()
298 .set_bucket(&self.formatted_bucket_path())
299 .set_object(&path)
300 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
301 .send()
302 .await
303 {
304 Ok(obj) => obj,
305 Err(e) if is_not_found(&e) => return Ok(None),
306 Err(e) => return Err(self.error("GetObject", e)),
307 };
308 let size = usize::try_from(obj.size).context("GCS sent negative size value")?;
309 self.metrics.delete_object.inc();
310 match self
311 .storage_control_client
312 .delete_object()
313 .set_bucket(&self.formatted_bucket_path())
314 .set_object(path)
315 .set_if_generation_match(obj.generation)
318 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
319 .send()
320 .await
321 {
322 Ok(()) => Ok(Some(size)),
323 Err(e) if is_not_found(&e) => Ok(None),
325 Err(e) => Err(self.error("DeleteObject", e)),
326 }
327 }
328
329 async fn restore_inner(&self, key: &str) -> Result<(), ExternalError> {
330 let path = self.get_path(key);
331
332 match self
333 .storage_control_client
334 .get_object()
335 .set_bucket(&self.formatted_bucket_path())
336 .set_object(&path)
337 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
338 .send()
339 .await
340 {
341 Ok(_) => return Ok(()),
342 Err(e) if is_not_found(&e) => {}
343 Err(e) => return Err(self.error("GetObject", e)),
344 }
345
346 let mut stream = self
350 .storage_control_client
351 .list_objects()
352 .set_parent(&self.formatted_bucket_path())
353 .set_prefix(&path)
354 .set_versions(true)
355 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
356 .by_item();
357 let mut latest_generation = None;
358 while let Some(obj) = stream.next().await {
359 let obj = obj.map_err(|e| self.error("ListObjects", e))?;
360 if obj.name == path && latest_generation < Some(obj.generation) {
362 latest_generation = Some(obj.generation);
363 }
364 }
365 let Some(generation) = latest_generation else {
366 return Err(Determinate::new(anyhow!(
367 "unable to restore {key} in GCS: no valid version exists"
368 ))
369 .into());
370 };
371
372 let mut rewrite_token = String::new();
375 loop {
376 let response = match self
377 .storage_control_client
378 .rewrite_object()
379 .set_source_bucket(self.formatted_bucket_path())
380 .set_source_object(&path)
381 .set_source_generation(generation)
382 .set_destination_bucket(self.formatted_bucket_path())
383 .set_destination_name(&path)
384 .set_if_generation_match(0)
386 .set_rewrite_token(rewrite_token)
387 .with_attempt_timeout(self.knobs.operation_attempt_timeout())
388 .send()
389 .await
390 {
391 Ok(response) => response,
392 Err(e) if is_failed_precondition(&e) => return Ok(()),
394 Err(e) => return Err(self.error("RewriteObject", e)),
395 };
396 if response.done {
397 return Ok(());
398 }
399 rewrite_token = response.rewrite_token;
400 }
401 }
402}
403
404#[async_trait]
405impl Blob for GcsBlob {
406 async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
407 self.with_timeout("get", self.get_inner(key)).await
408 }
409
410 async fn list_keys_and_metadata(
411 &self,
412 key_prefix: &str,
413 f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
414 ) -> Result<(), ExternalError> {
415 self.with_timeout("list", self.list_keys_and_metadata_inner(key_prefix, f))
416 .await
417 }
418
419 async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
420 self.with_timeout("set", self.set_inner(key, value)).await
421 }
422
423 async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
424 self.with_timeout("delete", self.delete_inner(key)).await
425 }
426
427 async fn restore(&self, key: &str) -> Result<(), ExternalError> {
428 self.with_timeout("restore", self.restore_inner(key)).await
429 }
430}
431
432fn is_not_found(e: &google_cloud_storage::Error) -> bool {
433 e.status().is_some_and(|s| s.code == Code::NotFound) || e.http_status_code() == Some(404)
434}
435
436fn is_failed_precondition(e: &google_cloud_storage::Error) -> bool {
437 e.status()
438 .is_some_and(|s| s.code == Code::FailedPrecondition)
439 || e.http_status_code() == Some(412)
440}
441
442#[cfg(test)]
443mod tests {
444 use tracing::info;
445
446 use super::*;
447 use crate::location::tests::blob_impl_test;
448
449 #[cfg_attr(miri, ignore)] #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
451 async fn gcs_blob() -> Result<(), ExternalError> {
452 let config = match GcsBlobConfig::new_for_test().await? {
453 Some(client) => client,
454 None => {
455 info!(
456 "{} env not set: skipping test that uses external service",
457 GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
458 );
459 return Ok(());
460 }
461 };
462 blob_impl_test(move |_path| {
463 let config = config.clone();
464 async move { GcsBlob::open(config).await }
465 })
466 .await
467 }
468
469 #[cfg_attr(miri, ignore)] #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
473 async fn gcs_blob_restore() -> Result<(), ExternalError> {
474 let Some(config) = GcsBlobConfig::new_for_test().await? else {
475 info!(
476 "{} env not set: skipping test that uses external service",
477 GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
478 );
479 return Ok(());
480 };
481 let blob = GcsBlob::open(config).await?;
482 blob.set("k", Bytes::from("data")).await?;
483 assert_eq!(blob.delete("k").await?, Some("data".len()));
484 assert_eq!(blob.get("k").await?, None);
485 blob.restore("k").await?;
486 assert_eq!(
487 blob.get("k").await?.map(|b| b.into_contiguous()),
488 Some(b"data".to_vec())
489 );
490 blob.delete("k").await?;
491 Ok(())
492 }
493
494 #[cfg_attr(miri, ignore)] #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
496 async fn gcs_blob_open_missing_bucket() -> Result<(), ExternalError> {
497 let Some(mut config) = GcsBlobConfig::new_for_test().await? else {
498 info!(
499 "{} env not set: skipping test that uses external service",
500 GcsBlobConfig::EXTERNAL_TESTS_GCS_BUCKET
501 );
502 return Ok(());
503 };
504 config.bucket = format!("mz-missing-{}", Uuid::new_v4());
505 let err = GcsBlob::open(config)
506 .await
507 .expect_err("opening a missing bucket succeeded");
508 assert!(err.to_string().contains("ListObjects"), "{err}");
509 Ok(())
510 }
511}