1use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::future::Future;
6use std::num::NonZeroU64;
7use std::sync::Arc;
8use std::time::Duration;
9use std::{fmt, io};
10
11use futures_util::{StreamExt, TryStreamExt};
12use gcp_auth::TokenProvider;
13use objectstore_types::headers;
14use objectstore_types::metadata::{ExpirationPolicy, Metadata};
15use objectstore_types::range::{ByteRange, ContentRange};
16use objectstore_types::time::{Rfc3339Timestamp, Timestamp};
17use reqwest::header::HeaderMap;
18use reqwest::{Body, IntoUrl, Method, RequestBuilder, StatusCode, Url, header, multipart};
19use serde::{Deserialize, Serialize};
20
21use crate::backend::common::{
22 self, Backend, DeleteResponse, ExpiryUpdate, GetResponse, MetadataResponse, PutResponse,
23 SetExpiryResponse,
24};
25use crate::backend::extensions::{ReqwestResultExt, ResponseExt, SendTraced};
26use crate::change_stream::{
27 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
28};
29use crate::error::{Error, ErrorKind, Result, ResultExt as _};
30use crate::gcp_auth::PrefetchingTokenProvider;
31use crate::id::ObjectId;
32use crate::resumable::{BackendToken, Session, UploadProgress};
33use crate::stream::ClientStream;
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct GcsConfig {
56 pub endpoint: Option<String>,
69
70 pub bucket: String,
78
79 #[serde(default = "common::default_tcp_user_timeout", with = "humantime_serde")]
93 pub tcp_user_timeout: Duration,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub cogs: Option<CostTrackerStreamConfig>,
107}
108
109const STORED_CONTENT_LENGTH: &str = "x-goog-stored-content-length";
114
115async fn read_stored_content_length(response: reqwest::Response) -> Option<u64> {
123 let header = response
124 .headers()
125 .get(STORED_CONTENT_LENGTH)
126 .and_then(|value| value.to_str().ok())
127 .and_then(|value| value.parse().ok());
128
129 if let Some(size) = header {
130 response.drain_body().await;
131 return Some(size);
132 }
133
134 response.json::<GcsObject>().await.ok()?.size?.parse().ok()
135}
136
137const DEFAULT_ENDPOINT: &str = "https://storage.googleapis.com";
139const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/devstorage.read_write"];
141const REQUEST_RETRY_COUNT: usize = 2;
143
144const BUILTIN_META_PREFIX: &str = "x-sn-";
146const CUSTOM_META_PREFIX: &str = "x-snme-";
148
149#[derive(Debug, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156struct GcsObject {
157 pub content_type: Cow<'static, str>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub content_encoding: Option<String>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub custom_time: Option<Rfc3339Timestamp>,
168
169 pub size: Option<String>,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub time_created: Option<Rfc3339Timestamp>,
178
179 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181 pub metadata: BTreeMap<GcsMetaKey, String>,
182
183 #[serde(skip_serializing)]
185 pub generation: String,
186
187 #[serde(skip_serializing)]
189 pub metageneration: String,
190}
191
192impl GcsObject {
193 fn metadata_size(&self) -> u64 {
197 self.metadata
198 .iter()
199 .filter(|(key, _)| !matches!(key, GcsMetaKey::EmulatorIgnored))
200 .map(|(key, value)| key.to_string().len() as u64 + value.len() as u64)
201 .sum()
202 }
203
204 pub fn is_expired(&self, access_time: Timestamp) -> bool {
206 match self.custom_time {
207 Some(expires_at) => access_time > expires_at.into_inner(),
208 None => false,
209 }
210 }
211
212 pub fn generations(&self) -> GcsGenerations<'_> {
214 (&self.generation, &self.metageneration)
215 }
216
217 pub fn from_metadata(metadata: &Metadata) -> Self {
219 let mut gcs_object = GcsObject {
220 content_type: metadata.content_type.clone(),
221 size: metadata.size.map(|size| size.to_string()),
222 content_encoding: None,
223 custom_time: None,
224 time_created: metadata.time_created.map(Timestamp::as_rfc3339),
225 metadata: BTreeMap::new(),
226 generation: String::new(),
227 metageneration: String::new(),
228 };
229
230 gcs_object.custom_time = metadata.time_expires.map(Timestamp::as_rfc3339);
234
235 if let Some(compression) = metadata.compression {
236 gcs_object.content_encoding = Some(compression.to_string());
237 }
238
239 if metadata.expiration_policy != ExpirationPolicy::default() {
240 gcs_object.metadata.insert(
241 GcsMetaKey::Expiration,
242 metadata.expiration_policy.to_string(),
243 );
244 }
245
246 if let Some(origin) = &metadata.origin {
248 gcs_object.metadata.insert(
249 GcsMetaKey::Origin,
250 headers::encode_header_str(origin).into(),
251 );
252 }
253
254 if let Some(filename) = &metadata.filename {
255 gcs_object.metadata.insert(
256 GcsMetaKey::Filename,
257 headers::encode_header_str(filename).into(),
258 );
259 }
260
261 for (key, value) in &metadata.custom {
262 gcs_object.metadata.insert(
263 GcsMetaKey::Custom(key.clone()),
264 headers::encode_header_str(value).into(),
265 );
266 }
267
268 gcs_object
269 }
270
271 pub fn into_metadata(mut self) -> Result<Metadata> {
273 self.metadata.remove(&GcsMetaKey::EmulatorIgnored);
275
276 let expiration_policy = self
277 .metadata
278 .remove(&GcsMetaKey::Expiration)
279 .map(|s| s.parse())
280 .transpose()
281 .context(ErrorKind::CorruptData, "decoding GCS expiration policy")?
282 .unwrap_or_default();
283
284 let origin = self
285 .metadata
286 .remove(&GcsMetaKey::Origin)
287 .map(|value| decode_gcs_meta_value(&value, "decoding GCS origin metadata"))
288 .transpose()?;
289 let filename = self
290 .metadata
291 .remove(&GcsMetaKey::Filename)
292 .map(|value| decode_gcs_meta_value(&value, "decoding GCS filename metadata"))
293 .transpose()?;
294
295 let content_type = self.content_type;
296 let compression = self
297 .content_encoding
298 .map(|s| s.parse())
299 .transpose()
300 .context(ErrorKind::CorruptData, "decoding GCS compression")?;
301 let size = self
302 .size
303 .map(|size| size.parse())
304 .transpose()
305 .context(ErrorKind::CorruptData, "decoding GCS object size")?;
306 let time_created = self.time_created.map(Rfc3339Timestamp::into_inner);
307
308 let mut custom = BTreeMap::new();
310 for (key, value) in self.metadata {
311 if let GcsMetaKey::Custom(custom_key) = key {
312 custom.insert(
313 custom_key,
314 decode_gcs_meta_value(&value, "decoding GCS custom metadata")?,
315 );
316 } else {
317 return Err(Error::new(
318 ErrorKind::CorruptData,
319 format!("unexpected GCS metadata key: {key}"),
320 ));
321 }
322 }
323
324 Ok(Metadata {
325 content_type,
326 expiration_policy,
327 compression,
328 origin,
329 filename,
330 size,
331 custom,
332 time_created,
333 time_expires: self.custom_time.map(Rfc3339Timestamp::into_inner),
334 })
335 }
336}
337
338type GcsGenerations<'a> = (&'a str, &'a str);
340
341#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
343enum GcsMetaKey {
344 Expiration,
346 Origin,
348 Filename,
350 EmulatorIgnored,
352 Custom(String),
354}
355
356impl std::str::FromStr for GcsMetaKey {
357 type Err = anyhow::Error;
358
359 fn from_str(s: &str) -> Result<Self, Self::Err> {
360 if s.starts_with("x_emulator_") || s.starts_with("x_testbench_") {
361 return Ok(GcsMetaKey::EmulatorIgnored);
362 }
363
364 Ok(match s.strip_prefix(BUILTIN_META_PREFIX) {
365 Some("expiration") => GcsMetaKey::Expiration,
366 Some("origin") => GcsMetaKey::Origin,
367 Some("filename") => GcsMetaKey::Filename,
368 Some(unknown) => anyhow::bail!("unknown builtin metadata key: {unknown}"),
369 None => match s.strip_prefix(CUSTOM_META_PREFIX) {
370 Some(key) => GcsMetaKey::Custom(key.to_string()),
371 None => anyhow::bail!("invalid GCS metadata key format: {s}"),
372 },
373 })
374 }
375}
376
377impl fmt::Display for GcsMetaKey {
378 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379 match self {
380 Self::Expiration => write!(f, "{BUILTIN_META_PREFIX}expiration"),
381 Self::Origin => write!(f, "{BUILTIN_META_PREFIX}origin"),
382 Self::Filename => write!(f, "{BUILTIN_META_PREFIX}filename"),
383 Self::EmulatorIgnored => unreachable!("do not serialize emulator metadata"),
384 Self::Custom(key) => write!(f, "{CUSTOM_META_PREFIX}{key}"),
385 }
386 }
387}
388
389impl<'de> Deserialize<'de> for GcsMetaKey {
390 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391 where
392 D: serde::Deserializer<'de>,
393 {
394 let s = Cow::<'de, str>::deserialize(deserializer)?;
395 s.parse().map_err(serde::de::Error::custom)
396 }
397}
398
399impl Serialize for GcsMetaKey {
400 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
401 where
402 S: serde::Serializer,
403 {
404 serializer.collect_str(self)
405 }
406}
407
408fn decode_gcs_meta_value(value: &str, context: &'static str) -> Result<String> {
410 headers::decode_header_str(value).context(ErrorKind::CorruptData, context)
411}
412
413const CLIENT_CLOSED_REQUEST_STATUS: u16 = 499;
416
417enum GcsUploadProgress {
418 Incomplete(u64),
419 Complete(GcsObject),
420}
421
422impl From<GcsUploadProgress> for UploadProgress {
423 fn from(value: GcsUploadProgress) -> Self {
424 match value {
425 GcsUploadProgress::Incomplete(offset) => UploadProgress::Incomplete { offset },
426 GcsUploadProgress::Complete(_) => UploadProgress::Complete,
427 }
428 }
429}
430
431fn error_is_retryable(error: &Error) -> bool {
433 matches!(
434 error.kind(),
435 ErrorKind::BackendRateLimited | ErrorKind::BackendTimeout | ErrorKind::BackendUnavailable
436 )
437}
438
439pub struct GcsBackend {
441 client: reqwest::Client,
442 endpoint: Url,
443 bucket: String,
444 token_provider: Option<PrefetchingTokenProvider>,
445
446 change_stream: Arc<dyn ChangeStream>,
447}
448
449impl GcsBackend {
450 pub async fn new(config: GcsConfig, streams: &ChangeStreamFactory) -> anyhow::Result<Self> {
452 let GcsConfig {
453 endpoint,
454 bucket,
455 tcp_user_timeout,
456 cogs,
457 } = config;
458 let change_stream = streams.build(cogs.as_ref());
459
460 let token_provider = if endpoint.is_none() {
461 Some(PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?)
462 } else {
463 None
464 };
465
466 let endpoint_str = endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT);
467
468 Ok(Self {
469 client: common::reqwest_client(tcp_user_timeout),
470 endpoint: endpoint_str
471 .parse()
472 .map_err(|e| anyhow::Error::new(e).context("invalid GCS endpoint URL"))?,
473 bucket,
474 token_provider,
475 change_stream,
476 })
477 }
478
479 fn object_url(&self, id: &ObjectId) -> Result<Url> {
481 let mut url = self.endpoint.clone();
482
483 let path = id.as_storage_path().to_string();
484 url.path_segments_mut()
485 .map_err(|()| {
486 Error::new(
487 ErrorKind::Internal,
488 format!("building GCS object URL from {}", self.endpoint),
489 )
490 })?
491 .extend(&["storage", "v1", "b", &self.bucket, "o", &path]);
492
493 Ok(url)
494 }
495
496 fn upload_url(&self, id: &ObjectId, upload_type: &str) -> Result<Url> {
498 let mut url = self.endpoint.clone();
499
500 url.path_segments_mut()
501 .map_err(|()| {
502 Error::new(
503 ErrorKind::Internal,
504 format!("building GCS object URL from {}", self.endpoint),
505 )
506 })?
507 .extend(&["upload", "storage", "v1", "b", &self.bucket, "o"]);
508
509 url.query_pairs_mut()
510 .append_pair("uploadType", upload_type)
511 .append_pair("name", &id.as_storage_path().to_string());
512
513 Ok(url)
514 }
515
516 async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
518 let mut builder = self.client.request(method, url);
519 if let Some(provider) = &self.token_provider {
520 let token = provider.token(TOKEN_SCOPES).await.context(
521 ErrorKind::BackendFailure,
522 "getting GCS authentication token",
523 )?;
524 builder = builder.bearer_auth(token.as_str());
525 }
526 Ok(builder)
527 }
528
529 async fn with_failure_metrics<T>(
531 &self,
532 action: &'static str,
533 future: impl Future<Output = Result<T>> + Send,
534 ) -> Result<T> {
535 future.await.inspect_err(|_| {
536 objectstore_metrics::count!("gcs.failures", action = action);
537 })
538 }
539
540 async fn with_retry<T, F>(&self, action: &'static str, f: impl Fn() -> F) -> Result<T>
542 where
543 F: Future<Output = Result<T>> + Send,
544 {
545 let mut retry_count = 0usize;
546 loop {
547 match f().await {
548 Ok(res) => return Ok(res),
549 Err(ref e) if retry_count < REQUEST_RETRY_COUNT && error_is_retryable(e) => {
550 retry_count += 1;
551 objectstore_metrics::count!("gcs.retries", action = action);
552 objectstore_log::warn!(!!e, retry_count, action, "Retrying request");
553 }
554 Err(e) => {
555 objectstore_metrics::count!("gcs.failures", action = action);
556 return Err(e);
557 }
558 }
559 }
560 }
561
562 #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
564 async fn get_gcs_metadata(
565 &self,
566 object_url: &Url,
567 access_time: Timestamp,
568 ) -> Result<Option<GcsObject>> {
569 let metadata_opt = self
570 .with_retry("get_metadata", || async {
571 let resp = self
572 .request(Method::GET, object_url.clone())
573 .await?
574 .send_traced()
575 .await
576 .reqwest_context("getting GCS object metadata")?;
577
578 if resp.status() == StatusCode::NOT_FOUND {
579 resp.drain_body().await;
580 return Ok(None);
581 }
582
583 let metadata: GcsObject = resp
584 .check_error("getting GCS object metadata")
585 .await?
586 .json()
587 .await
588 .reqwest_context("getting GCS object metadata")?;
589
590 Ok(Some(metadata))
591 })
592 .await?;
593
594 let Some(gcs_metadata) = metadata_opt else {
595 objectstore_log::debug!("Object not found");
596 return Ok(None);
597 };
598
599 if gcs_metadata.is_expired(access_time) {
601 objectstore_log::debug!("Object found but past expiry");
602 return Ok(None);
603 }
604
605 Ok(Some(gcs_metadata))
606 }
607
608 #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
614 async fn update_custom_time(
615 &self,
616 object_url: Url,
617 custom_time: Timestamp,
618 generations: GcsGenerations<'_>,
619 metadata: Option<BTreeMap<GcsMetaKey, String>>,
620 ) -> Result<SetExpiryResponse> {
621 #[derive(Debug, Serialize)]
622 #[serde(rename_all = "camelCase")]
623 struct CustomTimeRequest {
624 custom_time: Rfc3339Timestamp,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 metadata: Option<BTreeMap<GcsMetaKey, String>>,
627 }
628
629 let mut object_url = object_url;
630 object_url
631 .query_pairs_mut()
632 .append_pair("ifGenerationMatch", generations.0)
633 .append_pair("ifMetagenerationMatch", generations.1);
634
635 let request = CustomTimeRequest {
636 custom_time: custom_time.as_rfc3339(),
637 metadata,
638 };
639
640 self.with_retry("update_custom_time", || async {
641 let response = self
642 .request(Method::PATCH, object_url.clone())
643 .await?
644 .json(&request)
645 .send_traced()
646 .await
647 .reqwest_context("updating GCS custom time")?;
648
649 let outcome = match response.status() {
650 StatusCode::NOT_FOUND => Some(SetExpiryResponse::NotFound),
651 StatusCode::PRECONDITION_FAILED => Some(SetExpiryResponse::Rejected),
652 _ => None,
653 };
654 if let Some(outcome) = outcome {
655 response.drain_body().await;
656 return Ok(outcome);
657 }
658
659 response
660 .check_error("updating GCS custom time")
661 .await?
662 .drain_body()
663 .await;
664
665 Ok(SetExpiryResponse::Satisfied(custom_time))
666 })
667 .await
668 }
669
670 async fn report_object_write(
672 &self,
673 id: &ObjectId,
674 stored_size: Option<u64>,
675 metadata_size: u64,
676 expires_at: Option<Timestamp>,
677 ) {
678 match stored_size {
679 Some(stored_size) => {
680 self.change_stream
681 .commit_write(id, stored_size + metadata_size, expires_at)
682 .await
683 }
684 None => {
685 objectstore_metrics::count!("change_stream.unreported", reason = "no_stored_size")
686 }
687 }
688 }
689}
690
691impl fmt::Debug for GcsBackend {
692 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693 f.debug_struct("GcsJsonApi")
694 .field("endpoint", &self.endpoint)
695 .field("bucket", &self.bucket)
696 .finish_non_exhaustive()
697 }
698}
699
700fn range_header_to_offset(value: &str, upload_length: NonZeroU64) -> Result<u64> {
702 let end = value.strip_prefix("bytes=0-").ok_or_else(|| {
703 Error::new(
704 ErrorKind::Internal,
705 "malformed GCS Range header for resumable progress",
706 )
707 })?;
708 let end = end.parse::<u64>().context(
709 ErrorKind::Internal,
710 "invalid GCS Range header for resumable progress",
711 )?;
712
713 let offset = end
714 .checked_add(1)
715 .ok_or_else(|| Error::new(ErrorKind::Internal, "GCS offset overflows u64"))?;
716 if offset > upload_length.get() {
717 return Err(Error::new(
718 ErrorKind::Internal,
719 "GCS offset exceeds upload length",
720 ));
721 }
722 Ok(offset)
723}
724
725async fn range_response_to_upload_progress(
730 upload_length: NonZeroU64,
731 response: reqwest::Response,
732) -> Result<GcsUploadProgress> {
733 let status = response.status();
734
735 match status {
736 StatusCode::NOT_FOUND => {
737 response.drain_body().await;
738 return Err(ErrorKind::UnknownUploadSession.into());
739 }
740 status if status == StatusCode::GONE || status.as_u16() == CLIENT_CLOSED_REQUEST_STATUS => {
741 response.drain_body().await;
742 return Err(ErrorKind::UploadSessionGone.into());
743 }
744 _ => {}
745 }
746
747 let response = response
748 .check_error("processing a GCS resumable upload response")
749 .await?;
750
751 match status {
752 StatusCode::OK | StatusCode::CREATED => {
753 let body = response
754 .bytes()
755 .await
756 .reqwest_context("reading a completed GCS resumable upload response")?;
757 let object = serde_json::from_slice::<GcsObject>(&body).context(
758 ErrorKind::CorruptData,
759 "parsing a completed GCS resumable upload response",
760 )?;
761 Ok(GcsUploadProgress::Complete(object))
762 }
763 StatusCode::PERMANENT_REDIRECT => {
765 let offset = match response.headers().get(header::RANGE) {
766 Some(range) => {
767 let range = range.to_str().map_err(|_| {
768 Error::new(
769 ErrorKind::BackendFailure,
770 "invalid GCS resumable upload Range header",
771 )
772 })?;
773 range_header_to_offset(range, upload_length)?
774 }
775 None => 0,
777 };
778 response.drain_body().await;
779 Ok(GcsUploadProgress::Incomplete(offset))
780 }
781 _ => {
782 response.drain_body().await;
783 Err(Error::new(
784 ErrorKind::BackendFailure,
785 format!("unexpected GCS resumable upload status {status}"),
786 ))
787 }
788 }
789}
790
791#[async_trait::async_trait]
792impl Backend for GcsBackend {
793 fn name(&self) -> &'static str {
794 "gcs"
795 }
796
797 fn upload_granularity(&self) -> u64 {
798 256 * 1024
799 }
800
801 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
802 async fn put_object(
803 &self,
804 id: &ObjectId,
805 metadata: &Metadata,
806 stream: ClientStream,
807 _access_time: Timestamp,
808 ) -> Result<PutResponse> {
809 objectstore_log::debug!("Writing to GCS backend");
810 self.change_stream
811 .begin_write(id, metadata.time_expires)
812 .await?;
813 let gcs_metadata = GcsObject::from_metadata(metadata);
814
815 let metadata_json = serde_json::to_string(&gcs_metadata)
818 .context(ErrorKind::Internal, "encoding GCS upload metadata")?;
819
820 let content_type = metadata
821 .content_type
822 .parse()
823 .context(ErrorKind::InvalidMetadata, "encoding GCS content type")?;
824
825 let multipart = multipart::Form::new()
826 .part(
827 "metadata",
828 multipart::Part::text(metadata_json)
829 .mime_str("application/json")
830 .expect("application/json is a valid mime type"),
831 )
832 .part(
833 "media",
834 multipart::Part::stream(Body::wrap_stream(stream.boxed()))
835 .headers(HeaderMap::from_iter([(header::CONTENT_TYPE, content_type)])),
838 );
839
840 let content_type = format!("multipart/related; boundary={}", multipart.boundary());
844
845 let url = self.upload_url(id, "multipart")?;
846 let response = self
847 .with_failure_metrics("put_object", async {
848 self.request(Method::POST, url)
849 .await?
850 .multipart(multipart)
851 .header(header::CONTENT_TYPE, content_type)
852 .send_traced()
853 .await
854 .check_error("uploading a GCS object")
855 .await
856 })
857 .await?;
858
859 let stored_size = read_stored_content_length(response).await;
860 self.report_object_write(
861 id,
862 stored_size,
863 gcs_metadata.metadata_size(),
864 metadata.time_expires,
865 )
866 .await;
867
868 Ok(())
869 }
870
871 #[tracing::instrument(level = "debug", skip(self))]
872 async fn get_object(
873 &self,
874 id: &ObjectId,
875 access_time: Timestamp,
876 range: Option<ByteRange>,
877 ) -> Result<GetResponse> {
878 objectstore_log::debug!("Reading from GCS backend");
879 let object_url = self.object_url(id)?;
880 let mut generation_retry_count = 0usize;
881
882 loop {
883 let Some(gcs_metadata) = self.get_gcs_metadata(&object_url, access_time).await? else {
884 return Ok(None);
885 };
886
887 let mut download_url = object_url.clone();
888 download_url
889 .query_pairs_mut()
890 .append_pair("alt", "media")
891 .append_pair("ifGenerationMatch", &gcs_metadata.generation);
892
893 let payload_response = self
894 .with_retry("get_payload", || async {
895 let mut req = self
901 .request(Method::GET, download_url.clone())
902 .await?
903 .header(header::ACCEPT_ENCODING, "gzip");
904 if let Some(r) = range {
905 req = req.header(header::RANGE, r.to_header_value());
906 }
907
908 let resp = req
909 .send_traced()
910 .await
911 .reqwest_context("getting a GCS object payload")?;
912
913 if resp.status() == StatusCode::PRECONDITION_FAILED {
914 resp.drain_body().await;
915 return Ok(None);
916 }
917
918 if resp.status() == StatusCode::RANGE_NOT_SATISFIABLE {
919 let raw = resp
920 .headers()
921 .get(header::CONTENT_RANGE)
922 .and_then(|v| v.to_str().ok());
923 let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
924 let err = match total {
925 Some(total) => ErrorKind::RangeNotSatisfiable { total }.into(),
926 None => Error::new(
927 ErrorKind::BackendFailure,
928 "invalid GCS 416 Content-Range",
929 ),
930 };
931 resp.drain_body().await;
932 return Err(err);
933 }
934
935 resp.check_error("getting a GCS object payload")
936 .await
937 .map(Some)
938 })
939 .await?;
940
941 let Some(payload_response) = payload_response else {
942 if generation_retry_count >= REQUEST_RETRY_COUNT {
943 objectstore_metrics::count!("gcs.failures", action = "get_object");
944 return Err(Error::new(
945 ErrorKind::BackendFailure,
946 "GCS object kept changing while being read",
947 ));
948 }
949
950 generation_retry_count += 1;
951 objectstore_metrics::count!("gcs.retries", action = "get_object");
952 continue;
953 };
954
955 let content_range = if payload_response.status() == StatusCode::PARTIAL_CONTENT {
956 Some(
957 payload_response
958 .headers()
959 .get(header::CONTENT_RANGE)
960 .and_then(|v| v.to_str().ok())
961 .and_then(|s| s.parse::<ContentRange>().ok())
962 .ok_or_else(|| {
963 Error::new(ErrorKind::BackendFailure, "missing GCS 206 Content-Range")
964 })?,
965 )
966 } else {
967 None
968 };
969
970 let stream = payload_response
971 .bytes_stream()
972 .map_err(io::Error::other)
973 .boxed();
974
975 let metadata = gcs_metadata.into_metadata()?;
976 return Ok(Some((metadata, content_range, stream)));
977 }
978 }
979
980 #[tracing::instrument(level = "debug", skip(self))]
981 async fn get_metadata(
982 &self,
983 id: &ObjectId,
984 access_time: Timestamp,
985 ) -> Result<MetadataResponse> {
986 objectstore_log::debug!("Reading metadata from GCS backend");
987 let object_url = self.object_url(id)?;
988 match self.get_gcs_metadata(&object_url, access_time).await? {
989 Some(gcs_metadata) => Ok(Some(gcs_metadata.into_metadata()?)),
990 None => Ok(None),
991 }
992 }
993
994 #[tracing::instrument(level = "debug", skip(self))]
995 async fn set_expiry(
996 &self,
997 id: &ObjectId,
998 target: ExpiryUpdate,
999 access_time: Timestamp,
1000 ) -> Result<SetExpiryResponse> {
1001 let object_url = self.object_url(id)?;
1002 let Some(object) = self.get_gcs_metadata(&object_url, access_time).await? else {
1003 return Ok(SetExpiryResponse::NotFound);
1004 };
1005 let Some(current_expiry) = object.custom_time else {
1006 return Ok(SetExpiryResponse::Rejected);
1007 };
1008 let time_created = object.time_created.map(Rfc3339Timestamp::into_inner);
1009 let Some(expire_at) = target.resolve(time_created, access_time)? else {
1010 return Ok(SetExpiryResponse::Rejected);
1011 };
1012 if current_expiry.into_inner() >= expire_at {
1013 return Ok(SetExpiryResponse::Satisfied(expire_at)); }
1015
1016 let policy = object
1017 .metadata
1018 .get(&GcsMetaKey::Expiration)
1019 .map(|value| value.parse())
1020 .transpose()
1021 .context(ErrorKind::CorruptData, "decoding GCS expiration policy")?
1022 .unwrap_or_default();
1023 let updated_policy = common::extended_expiration_policy(
1024 policy,
1025 time_created,
1026 current_expiry.into_inner(),
1027 expire_at,
1028 )?;
1029 let metadata = if updated_policy != policy {
1030 Some(BTreeMap::from([(
1031 GcsMetaKey::Expiration,
1032 updated_policy.to_string(),
1033 )]))
1034 } else {
1035 None
1036 };
1037
1038 self.change_stream.begin_update(id, Some(expire_at)).await?;
1039 let outcome = self
1040 .update_custom_time(object_url, expire_at, object.generations(), metadata)
1041 .await?;
1042 if matches!(outcome, SetExpiryResponse::Satisfied(_)) {
1043 self.change_stream.commit_update(id, Some(expire_at)).await;
1044 }
1045
1046 Ok(outcome)
1047 }
1048
1049 #[tracing::instrument(level = "debug", skip(self))]
1050 async fn delete_object(
1051 &self,
1052 id: &ObjectId,
1053 _access_time: Timestamp,
1054 ) -> Result<DeleteResponse> {
1055 objectstore_log::debug!("Deleting from GCS backend");
1056 let object_url = self.object_url(id)?;
1057
1058 let deleted = self
1059 .with_retry("delete", || async {
1060 let resp = self
1061 .request(Method::DELETE, object_url.clone())
1062 .await?
1063 .send_traced()
1064 .await
1065 .reqwest_context("deleting a GCS object")?;
1066
1067 if resp.status() == StatusCode::NOT_FOUND {
1069 resp.drain_body().await;
1070 return Ok(false);
1071 }
1072
1073 resp.check_error("deleting a GCS object")
1074 .await?
1075 .drain_body()
1076 .await;
1077
1078 Ok(true)
1079 })
1080 .await?;
1081
1082 if deleted {
1083 self.change_stream.commit_delete(id).await;
1084 }
1085
1086 Ok(())
1087 }
1088
1089 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
1090 async fn create_upload_session(
1091 &self,
1092 id: &ObjectId,
1093 metadata: &Metadata,
1094 upload_length: NonZeroU64,
1095 ) -> Result<Option<BackendToken>> {
1096 objectstore_log::debug!("Creating resumable upload session on GCS backend");
1097 self.change_stream
1098 .begin_write(id, metadata.time_expires)
1099 .await?;
1100 let url = self.upload_url(id, "resumable")?;
1101 let metadata_json = serde_json::to_vec(&GcsObject::from_metadata(metadata)).context(
1102 ErrorKind::Internal,
1103 "serializing GCS resumable upload metadata",
1104 )?;
1105 let content_type = metadata.content_type.clone();
1106
1107 let location = self
1108 .with_retry("create_resumable_upload", || {
1109 let url = url.clone();
1110 let metadata_json = metadata_json.clone();
1111 let content_type = content_type.clone();
1112 async move {
1113 let response = self
1114 .request(Method::POST, url)
1115 .await?
1116 .header(header::CONTENT_TYPE, "application/json")
1117 .header("x-upload-content-type", content_type.as_ref())
1118 .header("x-upload-content-length", upload_length.get())
1119 .body(metadata_json)
1120 .send_traced()
1121 .await
1122 .check_error("creating a GCS resumable upload")
1123 .await?;
1124
1125 if response.status() != StatusCode::OK {
1126 let status = response.status();
1127 response.drain_body().await;
1128 return Err(Error::new(
1129 ErrorKind::BackendFailure,
1130 format!("unexpected GCS resumable upload creation status {status}"),
1131 ));
1132 }
1133
1134 let location = response
1135 .headers()
1136 .get(header::LOCATION)
1137 .and_then(|value| value.to_str().ok())
1138 .map(str::to_owned)
1139 .ok_or_else(|| {
1140 Error::new(
1141 ErrorKind::BackendFailure,
1142 "missing valid Location header in GCS resumable upload creation response",
1143 )
1144 })?;
1145 response.drain_body().await;
1146 Ok(location)
1147 }
1148 })
1149 .await?;
1150
1151 let session_uri = Url::parse(&location).map_err(|_| {
1152 Error::new(
1153 ErrorKind::BackendFailure,
1154 "invalid Location URL in GCS resumable upload creation response",
1155 )
1156 })?;
1157 Ok(Some(session_uri.into()))
1158 }
1159
1160 #[tracing::instrument(level = "debug", fields(?session, offset, content_length), skip_all)]
1161 async fn put_chunk(
1162 &self,
1163 session: &Session,
1164 offset: u64,
1165 content_length: u64,
1166 stream: ClientStream,
1167 ) -> Result<UploadProgress> {
1168 objectstore_log::debug!("Uploading resumable chunk to GCS backend");
1169 let session_uri =
1170 Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1171
1172 let end = offset
1173 .checked_add(content_length)
1174 .filter(|end| *end <= session.upload_length.get())
1175 .ok_or(ErrorKind::ChunkExceedsUploadLength {
1176 offset,
1177 content_length,
1178 upload_length: session.upload_length.get(),
1179 })?;
1180 let granularity = self.upload_granularity();
1181 if content_length > 0 && content_length < granularity && end != session.upload_length.get()
1182 {
1183 return Err(ErrorKind::ChunkTooSmall {
1184 chunk_length: content_length,
1185 upload_granularity: granularity,
1186 }
1187 .into());
1188 }
1189
1190 let content_range = match content_length {
1191 0 => format!("bytes */{}", session.upload_length),
1193 _ => format!("bytes {offset}-{}/{}", end - 1, session.upload_length),
1194 };
1195
1196 let progress = self
1197 .with_failure_metrics("put_chunk", async {
1198 let response = self
1199 .request(Method::PUT, session_uri.as_str())
1200 .await?
1201 .header(header::CONTENT_LENGTH, content_length)
1202 .header(header::CONTENT_RANGE, content_range)
1203 .body(Body::wrap_stream(stream))
1204 .send_traced()
1205 .await
1206 .reqwest_context("uploading a GCS resumable chunk")?;
1207
1208 range_response_to_upload_progress(session.upload_length, response).await
1209 })
1210 .await?;
1211 if let GcsUploadProgress::Complete(ref object) = progress {
1212 let stored_size = object.size.as_deref().and_then(|size| size.parse().ok());
1213 let expires_at = object.custom_time.map(Rfc3339Timestamp::into_inner);
1214 self.report_object_write(
1215 &session.object_id,
1216 stored_size,
1217 object.metadata_size(),
1218 expires_at,
1219 )
1220 .await;
1221 }
1222 Ok(progress.into())
1223 }
1224
1225 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
1226 async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
1227 objectstore_log::debug!("Querying resumable upload offset on GCS backend");
1228 let session_uri =
1229 Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1230
1231 self.with_retry("query_resumable_upload", || async {
1232 let response = self
1233 .request(Method::PUT, session_uri.as_str())
1234 .await?
1235 .header(header::CONTENT_LENGTH, 0)
1236 .header(
1237 header::CONTENT_RANGE,
1238 format!("bytes */{}", session.upload_length),
1239 )
1240 .send_traced()
1241 .await
1242 .reqwest_context("querying a GCS resumable upload")?;
1243
1244 let progress =
1245 range_response_to_upload_progress(session.upload_length, response).await?;
1246 if let GcsUploadProgress::Complete(ref object) = progress {
1249 let stored_size = object.size.as_deref().and_then(|size| size.parse().ok());
1250 let expires_at = object.custom_time.map(Rfc3339Timestamp::into_inner);
1251 self.report_object_write(
1252 &session.object_id,
1253 stored_size,
1254 object.metadata_size(),
1255 expires_at,
1256 )
1257 .await;
1258 }
1259 Ok(progress.into())
1260 })
1261 .await
1262 }
1263
1264 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
1265 async fn cancel_upload(&self, session: &Session) -> Result<()> {
1266 objectstore_log::debug!("Cancelling resumable upload on GCS backend");
1267 let session_uri =
1268 Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1269 self.with_retry("cancel_resumable_upload", || {
1270 let session_uri = session_uri.clone();
1271 async move {
1272 let response = self
1273 .request(Method::DELETE, session_uri)
1274 .await?
1275 .header(header::CONTENT_LENGTH, 0)
1276 .send_traced()
1277 .await
1278 .reqwest_context("canceling a GCS resumable upload")?;
1279 match response.status() {
1280 status if status.as_u16() == CLIENT_CLOSED_REQUEST_STATUS => {
1282 response.drain_body().await;
1283 Ok(())
1284 }
1285 StatusCode::GONE | StatusCode::NOT_FOUND => {
1287 response.drain_body().await;
1288 Ok(())
1289 }
1290 _ => {
1291 response
1292 .check_error("canceling a GCS resumable upload")
1293 .await?
1294 .drain_body()
1295 .await;
1296 Err(Error::new(
1297 ErrorKind::BackendFailure,
1298 "unexpected GCS resumable upload cancellation status",
1299 ))
1300 }
1301 }
1302 }
1303 })
1304 .await
1305 }
1306
1307 async fn join(&self) {
1308 flush_change_stream(&self.change_stream).await;
1309 }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use std::collections::BTreeMap;
1315 use std::io::{Read, Write};
1316 use std::net::{TcpListener, TcpStream};
1317 use std::num::NonZeroU64;
1318 use std::sync::mpsc;
1319 use std::thread;
1320 use std::time::Duration;
1321
1322 use anyhow::Result;
1323 #[cfg(feature = "storage-cogs")]
1324 use objectstore_inventory_tracker::{OpType, test_utils::DummyProducer};
1325 use objectstore_types::scope::{Scope, Scopes};
1326 use reqwest::header::{HeaderMap, HeaderValue};
1327
1328 use super::*;
1329 use crate::backend::common::ExpiryTarget;
1330 use crate::id::ObjectContext;
1331 use crate::stream;
1332 #[cfg(feature = "storage-cogs")]
1333 use crate::stream::ClientError;
1334
1335 impl GcsBackend {
1336 async fn create_upload_session(
1337 &self,
1338 id: &ObjectId,
1339 metadata: &Metadata,
1340 upload_length: NonZeroU64,
1341 ) -> Result<Session> {
1342 let backend_token =
1343 <Self as Backend>::create_upload_session(self, id, metadata, upload_length)
1344 .await?
1345 .ok_or_else(|| Error::from(ErrorKind::Unsupported))?;
1346 Ok(Session {
1347 object_id: id.clone(),
1348 upload_length,
1349 backend_token,
1350 })
1351 }
1352 }
1353
1354 fn read_http_request(connection: &mut TcpStream) -> String {
1355 let mut bytes = Vec::new();
1356 let mut byte = [0];
1357 while !bytes.ends_with(b"\r\n\r\n") {
1358 connection.read_exact(&mut byte).unwrap();
1359 bytes.push(byte[0]);
1360 }
1361 String::from_utf8(bytes).unwrap()
1362 }
1363
1364 fn write_http_response(
1365 connection: &mut TcpStream,
1366 status: &str,
1367 content_type: &str,
1368 body: &str,
1369 ) {
1370 write!(
1371 connection,
1372 "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1373 body.len()
1374 )
1375 .unwrap();
1376 }
1377
1378 fn start_generation_race_server()
1379 -> (String, mpsc::Receiver<Vec<String>>, thread::JoinHandle<()>) {
1380 let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1381 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1382 let (request_tx, request_rx) = mpsc::channel();
1383 let server = thread::spawn(move || {
1384 let responses = [
1385 (
1386 "200 OK",
1387 "application/json",
1388 r#"{"contentType":"text/old","generation":"1","metageneration":"1"}"#,
1389 ),
1390 ("412 Precondition Failed", "text/plain", "stale generation"),
1391 (
1392 "200 OK",
1393 "application/json",
1394 r#"{"contentType":"text/new","generation":"2","metageneration":"1"}"#,
1395 ),
1396 ("200 OK", "text/plain", "new"),
1397 ];
1398 let mut requests = Vec::new();
1399
1400 for (status, content_type, body) in responses {
1401 let (mut connection, _) = listener.accept().unwrap();
1402 requests.push(read_http_request(&mut connection));
1403 write_http_response(&mut connection, status, content_type, body);
1404 }
1405
1406 request_tx.send(requests).unwrap();
1407 });
1408 (endpoint, request_rx, server)
1409 }
1410
1411 const RESUMABLE_CHUNK_SIZE: usize = 256 * 1024;
1412
1413 fn test_config() -> GcsConfig {
1419 GcsConfig {
1420 endpoint: Some("http://localhost:8087".into()),
1421 bucket: "test-bucket".into(),
1422 tcp_user_timeout: Duration::from_secs(30),
1423 cogs: None,
1424 }
1425 }
1426
1427 async fn create_test_backend() -> Result<GcsBackend> {
1428 GcsBackend::new(test_config(), &ChangeStreamFactory::default()).await
1429 }
1430
1431 #[cfg(feature = "storage-cogs")]
1432 async fn create_test_backend_with_change_stream() -> Result<(GcsBackend, DummyProducer)> {
1433 let (streams, producer) = crate::change_stream::dummy_factory();
1434 let config = GcsConfig {
1435 cogs: Some(CostTrackerStreamConfig {
1436 shared_resource_id: "gcs_objectstore".into(),
1437 sample_rate: 1.0,
1438 }),
1439 ..test_config()
1440 };
1441
1442 Ok((GcsBackend::new(config, &streams).await?, producer))
1443 }
1444
1445 #[derive(Deserialize)]
1446 struct RetryTestResource {
1447 id: String,
1448 }
1449
1450 async fn inject_retry_test(
1452 backend: &mut GcsBackend,
1453 method: &str,
1454 instruction: &str,
1455 ) -> Result<()> {
1456 let retry_test: RetryTestResource = reqwest::Client::new()
1457 .post(backend.endpoint.join("retry_test")?)
1458 .json(&serde_json::json!({
1459 "instructions": { method: [instruction] },
1460 "transport": "HTTP",
1461 }))
1462 .send()
1463 .await?
1464 .error_for_status()?
1465 .json()
1466 .await?;
1467
1468 let mut headers = HeaderMap::new();
1469 headers.insert("x-retry-test-id", HeaderValue::from_str(&retry_test.id)?);
1470 backend.client = reqwest::Client::builder()
1471 .default_headers(headers)
1472 .build()?;
1473 Ok(())
1474 }
1475
1476 fn make_id() -> ObjectId {
1477 ObjectId::random(ObjectContext {
1478 usecase: "testing".parse().unwrap(),
1479 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1480 })
1481 }
1482
1483 fn make_id_with_key(key: &str) -> ObjectId {
1484 ObjectId::new(
1485 ObjectContext {
1486 usecase: "testing".parse().unwrap(),
1487 scopes: Scopes::from_iter([
1488 Scope::create("organization", "42").unwrap(),
1489 Scope::create("project", "7").unwrap(),
1490 ]),
1491 },
1492 key.into(),
1493 )
1494 }
1495
1496 fn nonzero(value: u64) -> NonZeroU64 {
1497 NonZeroU64::new(value).unwrap()
1498 }
1499
1500 #[tokio::test]
1501 async fn get_object_retries_from_metadata_after_generation_conflict() -> Result<()> {
1502 let (endpoint, request_rx, server) = start_generation_race_server();
1503 let backend = GcsBackend::new(
1504 GcsConfig {
1505 endpoint: Some(endpoint),
1506 bucket: "bucket".into(),
1507 tcp_user_timeout: Duration::from_secs(30),
1508 cogs: None,
1509 },
1510 &ChangeStreamFactory::default(),
1511 )
1512 .await?;
1513
1514 let (metadata, _, stream) = backend
1515 .get_object(&make_id(), Timestamp::now(), None)
1516 .await?
1517 .unwrap();
1518 assert_eq!(metadata.content_type, "text/new");
1519 assert_eq!(stream::read_to_vec(stream).await?, b"new");
1520
1521 let requests = request_rx.recv().unwrap();
1522 assert_eq!(requests.len(), 4);
1523 assert!(!requests[0].contains("alt=media"));
1524 assert!(requests[1].contains("alt=media&ifGenerationMatch=1"));
1525 assert!(!requests[2].contains("alt=media"));
1526 assert!(requests[3].contains("alt=media&ifGenerationMatch=2"));
1527 for request in [&requests[1], &requests[3]] {
1529 assert!(
1530 request
1531 .to_ascii_lowercase()
1532 .contains("\r\naccept-encoding: gzip\r\n"),
1533 "missing accept-encoding: {request}"
1534 );
1535 }
1536 server.join().unwrap();
1537
1538 Ok(())
1539 }
1540
1541 #[test]
1542 fn resumable_range_reports_next_offset_and_rejects_malformed_values() -> Result<()> {
1543 assert_eq!(range_header_to_offset("bytes=0-0", nonzero(10))?, 1);
1544 assert_eq!(
1545 range_header_to_offset("bytes=0-262143", nonzero(300_000))?,
1546 262_144
1547 );
1548
1549 for malformed in [
1550 "",
1551 "bytes=1-2",
1552 "bytes=0-",
1553 "bytes=0-*",
1554 "bytes=0-9 ",
1555 "bytes=0-9,bytes=20-30",
1556 ] {
1557 assert!(
1558 range_header_to_offset(malformed, nonzero(100)).is_err(),
1559 "accepted {malformed:?}"
1560 );
1561 }
1562 assert_eq!(range_header_to_offset("bytes=0-9", nonzero(10))?, 10);
1563 assert!(range_header_to_offset("bytes=0-18446744073709551615", nonzero(u64::MAX)).is_err());
1564 Ok(())
1565 }
1566
1567 #[tokio::test]
1568 async fn test_resumable_empty_chunk_reports_offset_without_writing() -> Result<()> {
1569 let backend = create_test_backend().await?;
1570 let id = make_id_with_key("resumable-empty-chunk");
1571 let upload_length = 2 * (RESUMABLE_CHUNK_SIZE + 2);
1572 let token = backend
1573 .create_upload_session(&id, &Metadata::default(), nonzero(upload_length as u64))
1574 .await?;
1575
1576 assert_eq!(
1579 backend
1580 .put_chunk(&token, 0, 0, stream::single(Vec::new()))
1581 .await?,
1582 UploadProgress::Incomplete { offset: 0 }
1583 );
1584 let chunk_length = RESUMABLE_CHUNK_SIZE + 2;
1587 let after_write = backend
1588 .put_chunk(
1589 &token,
1590 0,
1591 chunk_length as u64,
1592 stream::single(vec![b'a'; chunk_length]),
1593 )
1594 .await?;
1595 assert!(matches!(after_write, UploadProgress::Incomplete { .. }));
1596
1597 assert_eq!(
1603 backend
1604 .put_chunk(&token, chunk_length as u64, 0, stream::single(Vec::new()))
1605 .await?,
1606 after_write
1607 );
1608 assert_eq!(backend.upload_offset(&token).await?, after_write);
1609 Ok(())
1610 }
1611
1612 #[tokio::test]
1613 async fn test_resumable_single_and_multi_chunk_uploads() -> Result<()> {
1614 let backend = create_test_backend().await?;
1615
1616 let single_id = make_id_with_key("resumable-single");
1617 let single = b"single chunk".to_vec();
1618 let token = backend
1619 .create_upload_session(
1620 &single_id,
1621 &Metadata::default(),
1622 nonzero(single.len() as u64),
1623 )
1624 .await?;
1625 assert_eq!(
1626 backend
1627 .put_chunk(
1628 &token,
1629 0,
1630 single.len() as u64,
1631 stream::single(single.clone()),
1632 )
1633 .await?,
1634 UploadProgress::Complete
1635 );
1636 let (_, _, payload) = backend
1637 .get_object(&single_id, Timestamp::now(), None)
1638 .await?
1639 .unwrap();
1640 assert_eq!(stream::read_to_vec(payload).await?, single);
1641
1642 let multi_id = make_id_with_key("resumable-multi");
1643 let mut expected = vec![b'a'; RESUMABLE_CHUNK_SIZE];
1644 expected.extend_from_slice(b"final");
1645 let token = backend
1646 .create_upload_session(
1647 &multi_id,
1648 &Metadata::default(),
1649 nonzero(expected.len() as u64),
1650 )
1651 .await?;
1652 assert_eq!(
1653 backend.upload_offset(&token).await?,
1654 UploadProgress::Incomplete { offset: 0 }
1655 );
1656 let error = backend
1657 .put_chunk(&token, 0, 1, stream::single(b"a".to_vec()))
1658 .await
1659 .unwrap_err();
1660 assert_eq!(
1661 error.kind(),
1662 ErrorKind::ChunkTooSmall {
1663 chunk_length: 1,
1664 upload_granularity: RESUMABLE_CHUNK_SIZE as u64,
1665 }
1666 );
1667 assert_eq!(
1668 backend
1669 .put_chunk(
1670 &token,
1671 0,
1672 RESUMABLE_CHUNK_SIZE as u64,
1673 stream::single(expected[..RESUMABLE_CHUNK_SIZE].to_vec()),
1674 )
1675 .await?,
1676 UploadProgress::Incomplete {
1677 offset: RESUMABLE_CHUNK_SIZE as u64
1678 }
1679 );
1680 assert_eq!(
1681 backend.upload_offset(&token).await?,
1682 UploadProgress::Incomplete {
1683 offset: RESUMABLE_CHUNK_SIZE as u64
1684 }
1685 );
1686 assert_eq!(
1687 backend
1688 .put_chunk(
1689 &token,
1690 RESUMABLE_CHUNK_SIZE as u64,
1691 5,
1692 stream::single(b"final".to_vec()),
1693 )
1694 .await?,
1695 UploadProgress::Complete
1696 );
1697 let (_, _, payload) = backend
1698 .get_object(&multi_id, Timestamp::now(), None)
1699 .await?
1700 .unwrap();
1701 assert_eq!(stream::read_to_vec(payload).await?, expected);
1702 Ok(())
1703 }
1704
1705 #[tokio::test]
1706 async fn test_resumable_unaligned_chunk_and_rewind_preserve_persisted_bytes() -> Result<()> {
1707 let backend = create_test_backend().await?;
1708 let id = make_id_with_key("resumable-rewind");
1709 let upload_length = RESUMABLE_CHUNK_SIZE + 3;
1710 let token = backend
1711 .create_upload_session(&id, &Metadata::default(), nonzero(upload_length as u64))
1712 .await?;
1713
1714 let prefix = vec![b'a'; RESUMABLE_CHUNK_SIZE];
1715 assert_eq!(
1716 backend
1717 .put_chunk(&token, 0, prefix.len() as u64, stream::single(prefix))
1718 .await?,
1719 UploadProgress::Incomplete {
1720 offset: RESUMABLE_CHUNK_SIZE as u64
1721 }
1722 );
1723
1724 assert_eq!(
1727 backend
1728 .put_chunk(
1729 &token,
1730 (RESUMABLE_CHUNK_SIZE - 3) as u64,
1731 6,
1732 stream::single(b"BADxyz".to_vec()),
1733 )
1734 .await?,
1735 UploadProgress::Complete
1736 );
1737
1738 let (_, _, payload) = backend
1739 .get_object(&id, Timestamp::now(), None)
1740 .await?
1741 .unwrap();
1742 let payload = stream::read_to_vec(payload).await?;
1743 assert_eq!(&payload[RESUMABLE_CHUNK_SIZE - 3..], b"aaaxyz");
1744 Ok(())
1745 }
1746
1747 #[tokio::test]
1748 async fn test_resumable_rejects_oversized_chunks() -> Result<()> {
1749 let backend = create_test_backend().await?;
1750 let id = make_id_with_key("resumable-validation");
1751 let token = backend
1752 .create_upload_session(&id, &Metadata::default(), nonzero(10))
1753 .await?;
1754
1755 let error = backend
1756 .put_chunk(&token, 8, 3, stream::single(b"abc".to_vec()))
1757 .await
1758 .unwrap_err();
1759 assert_eq!(
1760 error.kind(),
1761 ErrorKind::ChunkExceedsUploadLength {
1762 offset: 8,
1763 content_length: 3,
1764 upload_length: 10
1765 }
1766 );
1767
1768 let error = backend
1769 .put_chunk(&token, u64::MAX, 2, stream::single(b"ab".to_vec()))
1770 .await
1771 .unwrap_err();
1772 assert!(matches!(
1773 error.kind(),
1774 ErrorKind::ChunkExceedsUploadLength { .. }
1775 ));
1776 Ok(())
1777 }
1778
1779 #[tokio::test]
1780 async fn test_resumable_cancel_is_idempotent_and_session_is_not_found() -> Result<()> {
1781 let backend = create_test_backend().await?;
1782 let id = make_id_with_key("resumable-cancel");
1783 let token = backend
1784 .create_upload_session(&id, &Metadata::default(), nonzero(10))
1785 .await?;
1786
1787 backend.cancel_upload(&token).await?;
1788 backend.cancel_upload(&token).await?;
1789 assert!(matches!(
1790 backend.upload_offset(&token).await,
1791 Err(error) if error.kind() == ErrorKind::UnknownUploadSession
1792 ));
1793 Ok(())
1794 }
1795
1796 #[tokio::test]
1797 async fn test_resumable_retries_only_replayable_operations() -> Result<()> {
1798 let mut backend = create_test_backend().await?;
1799 inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1800 let id = make_id_with_key("resumable-retries");
1801
1802 let token = backend
1804 .create_upload_session(&id, &Metadata::default(), nonzero(10))
1805 .await?;
1806
1807 inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1808 assert_eq!(
1809 backend.upload_offset(&token).await?,
1810 UploadProgress::Incomplete { offset: 0 }
1811 );
1812
1813 inject_retry_test(&mut backend, "storage.objects.delete", "return-503").await?;
1814 backend.cancel_upload(&token).await?;
1815 Ok(())
1816 }
1817
1818 #[tokio::test]
1819 async fn test_resumable_stream_failures_require_explicit_offset_recovery() -> Result<()> {
1820 let mut backend = create_test_backend().await?;
1822 let id = make_id_with_key("resumable-failure-before");
1823 let token = backend
1824 .create_upload_session(&id, &Metadata::default(), nonzero(4))
1825 .await?;
1826 inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1827 let metrics = objectstore_metrics::with_capturing_test_client_async(async {
1828 assert!(matches!(
1829 backend
1830 .put_chunk(&token, 0, 4, stream::single(b"data".to_vec()))
1831 .await,
1832 Err(error) if error.kind() == ErrorKind::BackendUnavailable
1833 ));
1834 })
1835 .await;
1836 assert_eq!(
1837 metrics
1838 .iter()
1839 .filter(|metric| metric.starts_with("gcs.failures:"))
1840 .collect::<Vec<_>>(),
1841 ["gcs.failures:+1|c|#action:put_chunk"]
1842 );
1843 assert!(
1844 !metrics
1845 .iter()
1846 .any(|metric| metric.starts_with("gcs.retries:"))
1847 );
1848 assert_eq!(
1849 backend.upload_offset(&token).await?,
1850 UploadProgress::Incomplete { offset: 0 }
1851 );
1852
1853 let mut backend = create_test_backend().await?;
1856 let id = make_id_with_key("resumable-failure-partial");
1857 let data = vec![b'p'; 2048];
1858 let token = backend
1859 .create_upload_session(&id, &Metadata::default(), nonzero(data.len() as u64))
1860 .await?;
1861 inject_retry_test(
1862 &mut backend,
1863 "storage.objects.insert",
1864 "return-503-after-1K",
1865 )
1866 .await?;
1867 assert!(matches!(
1868 backend
1869 .put_chunk(
1870 &token,
1871 0,
1872 data.len() as u64,
1873 stream::single(data.clone()),
1874 )
1875 .await,
1876 Err(error) if error.kind() == ErrorKind::BackendUnavailable
1877 ));
1878 assert_eq!(
1879 backend.upload_offset(&token).await?,
1880 UploadProgress::Incomplete { offset: 1024 }
1881 );
1882 assert_eq!(
1883 backend
1884 .put_chunk(&token, 1024, 1024, stream::single(data[1024..].to_vec()))
1885 .await?,
1886 UploadProgress::Complete
1887 );
1888
1889 let mut backend = create_test_backend().await?;
1892 let id = make_id_with_key("resumable-failure-final");
1893 let token = backend
1894 .create_upload_session(&id, &Metadata::default(), nonzero(5))
1895 .await?;
1896 inject_retry_test(
1897 &mut backend,
1898 "storage.objects.insert",
1899 "return-broken-stream-final-chunk-after-0B",
1900 )
1901 .await?;
1902 assert!(matches!(
1903 backend
1904 .put_chunk(&token, 0, 5, stream::single(b"final".to_vec()))
1905 .await,
1906 Err(error) if error.kind() == ErrorKind::CorruptData
1907 ));
1908 assert_eq!(
1909 backend.upload_offset(&token).await?,
1910 UploadProgress::Complete
1911 );
1912 Ok(())
1913 }
1914
1915 async fn get_gcs_generations(
1916 backend: &GcsBackend,
1917 object_url: Url,
1918 ) -> Result<(String, String)> {
1919 Ok(backend
1920 .request(Method::GET, object_url)
1921 .await?
1922 .send_traced()
1923 .await
1924 .check_error("getting GCS object metadata")
1925 .await?
1926 .json::<GcsObject>()
1927 .await
1928 .context(
1929 ErrorKind::BackendFailure,
1930 "decoding GCS object metadata response",
1931 )
1932 .map(|object| (object.generation, object.metageneration))?)
1933 }
1934
1935 #[tokio::test]
1936 async fn test_roundtrip() -> Result<()> {
1937 let backend = create_test_backend().await?;
1938
1939 let id = make_id();
1940 let metadata = Metadata {
1941 content_type: "text/plain".into(),
1942 expiration_policy: ExpirationPolicy::Manual,
1943 compression: None,
1944 origin: Some("203.0.113.42".into()),
1945 filename: Some("hello.txt".into()),
1946 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1947 time_created: Some(Timestamp::now()),
1948 time_expires: None,
1949 size: None,
1950 };
1951
1952 backend
1953 .put_object(
1954 &id,
1955 &metadata,
1956 stream::single("hello, world"),
1957 Timestamp::now(),
1958 )
1959 .await?;
1960
1961 let (meta, _, stream) = backend
1962 .get_object(&id, Timestamp::now(), None)
1963 .await?
1964 .unwrap();
1965
1966 let payload = stream::read_to_vec(stream).await?;
1967 let str_payload = str::from_utf8(&payload).unwrap();
1968 assert_eq!(str_payload, "hello, world");
1969 assert_eq!(meta.content_type, metadata.content_type);
1970 assert_eq!(meta.origin, metadata.origin);
1971 assert_eq!(meta.filename, metadata.filename);
1972 assert_eq!(meta.custom, metadata.custom);
1973 assert!(metadata.time_created.is_some());
1974
1975 Ok(())
1976 }
1977
1978 fn unicode_metadata() -> Metadata {
1980 Metadata {
1981 filename: Some("réport-📄.pdf".into()),
1982 custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
1983 ..Default::default()
1984 }
1985 }
1986
1987 #[tokio::test]
1988 async fn test_unicode_metadata_roundtrip_json_upload() -> Result<()> {
1989 let backend = create_test_backend().await?;
1990 let id = make_id();
1991 let metadata = unicode_metadata();
1992
1993 backend
1994 .put_object(
1995 &id,
1996 &metadata,
1997 stream::single("hello, world"),
1998 Timestamp::now(),
1999 )
2000 .await?;
2001
2002 let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2003 assert_eq!(meta.filename, metadata.filename);
2004 assert_eq!(meta.custom, metadata.custom);
2005
2006 Ok(())
2007 }
2008
2009 #[test]
2010 fn from_metadata_uses_provided_time_expires() {
2011 let created = Timestamp::now();
2012 let expires = created + Duration::from_hours(1);
2013 let metadata = Metadata {
2014 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
2015 time_created: Some(created),
2016 time_expires: Some(expires),
2017 ..Default::default()
2018 };
2019
2020 let gcs_object = GcsObject::from_metadata(&metadata);
2021 let custom_time = gcs_object.custom_time.map(Rfc3339Timestamp::into_inner);
2022 assert_eq!(custom_time, Some(expires));
2023
2024 let roundtripped = gcs_object.into_metadata().unwrap();
2025 assert_eq!(roundtripped.time_created, Some(created));
2026 assert_eq!(roundtripped.time_expires, Some(expires));
2027 }
2028
2029 #[tokio::test]
2030 async fn test_get_nonexistent() -> Result<()> {
2031 let backend = create_test_backend().await?;
2032
2033 let id = make_id();
2034 let result = backend.get_object(&id, Timestamp::now(), None).await?;
2035 assert!(result.is_none());
2036
2037 Ok(())
2038 }
2039
2040 #[tokio::test]
2041 async fn test_delete_nonexistent() -> Result<()> {
2042 let backend = create_test_backend().await?;
2043
2044 let id = make_id();
2045 backend.delete_object(&id, Timestamp::now()).await?;
2046
2047 Ok(())
2048 }
2049
2050 #[tokio::test]
2051 async fn test_overwrite() -> Result<()> {
2052 let backend = create_test_backend().await?;
2053
2054 let id = make_id();
2055 let metadata = Metadata {
2056 custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
2057 ..Default::default()
2058 };
2059
2060 backend
2061 .put_object(&id, &metadata, stream::single("hello"), Timestamp::now())
2062 .await?;
2063
2064 let metadata = Metadata {
2065 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
2066 ..Default::default()
2067 };
2068
2069 backend
2070 .put_object(&id, &metadata, stream::single("world"), Timestamp::now())
2071 .await?;
2072
2073 let (meta, _, stream) = backend
2074 .get_object(&id, Timestamp::now(), None)
2075 .await?
2076 .unwrap();
2077
2078 let payload = stream::read_to_vec(stream).await?;
2079 let str_payload = str::from_utf8(&payload).unwrap();
2080 assert_eq!(str_payload, "world");
2081 assert_eq!(meta.custom, metadata.custom);
2082
2083 Ok(())
2084 }
2085
2086 #[tokio::test]
2087 async fn test_read_after_delete() -> Result<()> {
2088 let backend = create_test_backend().await?;
2089
2090 let id = make_id();
2091 let metadata = Metadata::default();
2092
2093 backend
2094 .put_object(
2095 &id,
2096 &metadata,
2097 stream::single("hello, world"),
2098 Timestamp::now(),
2099 )
2100 .await?;
2101
2102 backend.delete_object(&id, Timestamp::now()).await?;
2103
2104 let result = backend.get_object(&id, Timestamp::now(), None).await?;
2105 assert!(result.is_none());
2106
2107 Ok(())
2108 }
2109
2110 #[tokio::test]
2111 async fn test_ttl_immediate() -> Result<()> {
2112 let backend = create_test_backend().await?;
2116
2117 let id = make_id();
2118 let metadata = Metadata {
2119 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2120 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2121 ..Default::default()
2122 };
2123
2124 backend
2125 .put_object(
2126 &id,
2127 &metadata,
2128 stream::single("hello, world"),
2129 Timestamp::now(),
2130 )
2131 .await?;
2132
2133 let result = backend.get_object(&id, Timestamp::now(), None).await?;
2134 assert!(result.is_none());
2135
2136 Ok(())
2137 }
2138
2139 #[tokio::test]
2140 async fn test_tti_immediate() -> Result<()> {
2141 let backend = create_test_backend().await?;
2145
2146 let id = make_id();
2147 let metadata = Metadata {
2148 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2149 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2150 ..Default::default()
2151 };
2152
2153 backend
2154 .put_object(
2155 &id,
2156 &metadata,
2157 stream::single("hello, world"),
2158 Timestamp::now(),
2159 )
2160 .await?;
2161
2162 let result = backend.get_object(&id, Timestamp::now(), None).await?;
2163 assert!(result.is_none());
2164
2165 Ok(())
2166 }
2167
2168 #[tokio::test]
2169 async fn test_get_metadata_returns_metadata() -> Result<()> {
2170 let backend = create_test_backend().await?;
2171
2172 let id = make_id();
2173 let metadata = Metadata {
2174 content_type: "text/plain".into(),
2175 origin: Some("203.0.113.42".into()),
2176 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
2177 ..Default::default()
2178 };
2179
2180 backend
2181 .put_object(
2182 &id,
2183 &metadata,
2184 stream::single("hello, world"),
2185 Timestamp::now(),
2186 )
2187 .await?;
2188
2189 let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2190 assert_eq!(meta.content_type, metadata.content_type);
2191 assert_eq!(meta.origin, metadata.origin);
2192 assert_eq!(meta.custom, metadata.custom);
2193
2194 Ok(())
2195 }
2196
2197 #[tokio::test]
2198 async fn test_get_metadata_nonexistent() -> Result<()> {
2199 let backend = create_test_backend().await?;
2200
2201 let id = make_id();
2202 let result = backend.get_metadata(&id, Timestamp::now()).await?;
2203 assert!(result.is_none());
2204
2205 Ok(())
2206 }
2207
2208 #[tokio::test]
2209 async fn test_set_expiry() -> Result<()> {
2210 for is_ttl in [false, true] {
2211 let backend = create_test_backend().await?;
2212
2213 let id = make_id();
2214 let tti = Duration::from_hours(2 * 24);
2215 let metadata = Metadata {
2216 content_type: "text/plain".into(),
2217 custom: [("preserved".into(), "yes".into())].into(),
2218 expiration_policy: if is_ttl {
2219 ExpirationPolicy::TimeToLive(tti)
2220 } else {
2221 ExpirationPolicy::TimeToIdle(tti)
2222 },
2223 time_expires: Some(Timestamp::now() + tti),
2224 ..Default::default()
2225 };
2226
2227 backend
2228 .put_object(
2229 &id,
2230 &metadata,
2231 stream::single("hello, world"),
2232 Timestamp::now(),
2233 )
2234 .await?;
2235
2236 let object_url = backend.object_url(&id)?;
2238 let old_deadline = Timestamp::now() + Duration::from_mins(1);
2239 let generations = get_gcs_generations(&backend, object_url.clone()).await?;
2240 backend
2241 .update_custom_time(
2242 object_url,
2243 old_deadline,
2244 (&generations.0, &generations.1),
2245 None,
2246 )
2247 .await?;
2248
2249 let pre_meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2251 let pre_expiry = pre_meta.time_expires.unwrap();
2252 let created = pre_meta.time_created.unwrap();
2253 assert_eq!(
2254 backend
2255 .get_metadata(&id, Timestamp::now())
2256 .await?
2257 .unwrap()
2258 .time_expires,
2259 Some(pre_expiry)
2260 );
2261
2262 let requested = created + tti + tti;
2263 for target in [
2264 ExpiryTarget::At(requested),
2265 ExpiryTarget::FromCreation(tti + tti),
2266 ] {
2267 assert_eq!(
2268 backend
2269 .set_expiry(&id, target.into(), Timestamp::now())
2270 .await?,
2271 SetExpiryResponse::Satisfied(requested)
2272 );
2273 }
2274 assert_eq!(
2275 backend
2276 .get_metadata(&id, Timestamp::now())
2277 .await?
2278 .unwrap()
2279 .time_expires,
2280 Some(requested)
2281 );
2282
2283 let (updated, _, stream) = backend
2284 .get_object(&id, Timestamp::now(), None)
2285 .await?
2286 .unwrap();
2287 assert_eq!(
2288 updated.expiration_policy,
2289 if is_ttl {
2290 ExpirationPolicy::TimeToLive(tti + tti)
2291 } else {
2292 ExpirationPolicy::TimeToIdle(tti)
2293 }
2294 );
2295 assert_eq!(updated.custom, metadata.custom);
2296 let payload = stream::read_to_vec(stream).await?;
2297 assert_eq!(&payload, b"hello, world");
2298 }
2299 Ok(())
2300 }
2301
2302 #[tokio::test]
2303 async fn test_expiry_conflict() -> Result<()> {
2304 let backend = create_test_backend().await?;
2305 let id = make_id();
2306 let metadata = Metadata {
2307 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
2308 time_expires: Some(Timestamp::now() + Duration::from_mins(10)),
2309 ..Default::default()
2310 };
2311 backend
2312 .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
2313 .await?;
2314 let object_url = backend.object_url(&id)?;
2315 let generations = get_gcs_generations(&backend, object_url.clone()).await?;
2316
2317 let deadline = Timestamp::now() + Duration::from_hours(1);
2318 assert_eq!(
2319 backend
2320 .update_custom_time(
2321 object_url.clone(),
2322 deadline,
2323 (&generations.0, &generations.1),
2324 None,
2325 )
2326 .await?,
2327 SetExpiryResponse::Satisfied(deadline)
2328 );
2329 assert_eq!(
2330 backend
2331 .update_custom_time(
2332 object_url.clone(),
2333 Timestamp::now() + Duration::from_hours(2),
2334 (&generations.0, &generations.1),
2335 None,
2336 )
2337 .await?,
2338 SetExpiryResponse::Rejected
2339 );
2340
2341 backend.delete_object(&id, Timestamp::now()).await?;
2342 assert_eq!(
2343 backend
2344 .update_custom_time(
2345 object_url,
2346 Timestamp::now() + Duration::from_hours(2),
2347 (&generations.0, &generations.1),
2348 None,
2349 )
2350 .await?,
2351 SetExpiryResponse::NotFound
2352 );
2353 Ok(())
2354 }
2355
2356 #[tokio::test]
2357 async fn test_compressed_payload_roundtrip() -> Result<()> {
2358 use objectstore_types::metadata::Compression;
2359
2360 let backend = create_test_backend().await?;
2361
2362 let plaintext = b"hello, world (but compressed with zstd)";
2363 let compressed = zstd::encode_all(&plaintext[..], 3)?;
2364
2365 let id = make_id();
2366 let metadata = Metadata {
2367 content_type: "text/plain".into(),
2368 compression: Some(Compression::Zstd),
2369 ..Default::default()
2370 };
2371
2372 backend
2373 .put_object(
2374 &id,
2375 &metadata,
2376 stream::single(compressed.clone()),
2377 Timestamp::now(),
2378 )
2379 .await?;
2380
2381 let (meta, _, stream) = backend
2382 .get_object(&id, Timestamp::now(), None)
2383 .await?
2384 .unwrap();
2385 let payload = stream::read_to_vec(stream).await?;
2386
2387 assert_eq!(meta.compression, Some(Compression::Zstd));
2388 assert_eq!(
2389 payload, compressed,
2390 "Payload should be returned still compressed, not auto-decompressed"
2391 );
2392
2393 Ok(())
2394 }
2395
2396 fn gzip_encode(data: &[u8]) -> Vec<u8> {
2397 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2398 encoder.write_all(data).unwrap();
2399 encoder.finish().unwrap()
2400 }
2401
2402 #[tokio::test]
2405 async fn test_gzip_payload_full_and_range_reads() -> Result<()> {
2406 use objectstore_types::metadata::Compression;
2407
2408 let backend = create_test_backend().await?;
2409
2410 let plaintext = b"hello, world (but compressed with gzip)";
2411 let compressed = gzip_encode(plaintext);
2412
2413 let id = make_id();
2414 let metadata = Metadata {
2415 content_type: "text/plain".into(),
2416 compression: Some(Compression::Gzip),
2417 ..Default::default()
2418 };
2419
2420 backend
2421 .put_object(
2422 &id,
2423 &metadata,
2424 stream::single(compressed.clone()),
2425 Timestamp::now(),
2426 )
2427 .await?;
2428
2429 let (meta, _, stream) = backend
2430 .get_object(&id, Timestamp::now(), None)
2431 .await?
2432 .unwrap();
2433 let payload = stream::read_to_vec(stream).await?;
2434
2435 assert_eq!(meta.compression, Some(Compression::Gzip));
2436 assert_eq!(
2437 payload, compressed,
2438 "Payload should be returned still compressed, not transcoded"
2439 );
2440
2441 let (meta, content_range, stream) = backend
2442 .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(2, 9)))
2443 .await?
2444 .unwrap();
2445 let payload = stream::read_to_vec(stream).await?;
2446
2447 assert_eq!(meta.compression, Some(Compression::Gzip));
2448 assert_eq!(
2449 content_range,
2450 Some(ContentRange {
2451 start: 2,
2452 end: 9,
2453 total: compressed.len() as u64,
2454 })
2455 );
2456 assert_eq!(payload, &compressed[2..=9]);
2457
2458 Ok(())
2459 }
2460 #[cfg(feature = "storage-cogs")]
2461 #[tokio::test]
2462 async fn change_stream_reports_the_size_gcs_stored() -> Result<()> {
2463 let (backend, producer) = create_test_backend_with_change_stream().await?;
2464 let id = make_id();
2465 let payload = vec![b'x'; 4096];
2466 let metadata = Metadata {
2467 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)),
2468 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2469 ..Default::default()
2470 };
2471
2472 backend
2473 .put_object(
2474 &id,
2475 &metadata,
2476 stream::single::<ClientError>(payload.clone()),
2477 Timestamp::now(),
2478 )
2479 .await?;
2480
2481 let records = producer.records();
2482 assert_eq!(records.len(), 1);
2483 assert_eq!(records[0].op_type, OpType::Write);
2484 assert_eq!(records[0].shared_resource_id, "gcs_objectstore");
2485 assert_eq!(records[0].app_feature, "testing");
2486 assert_eq!(
2487 records[0].size,
2488 Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2489 );
2490 assert!(records[0].expiration_time.is_some());
2491
2492 Ok(())
2493 }
2494
2495 #[cfg(feature = "storage-cogs")]
2496 #[tokio::test]
2497 async fn resumable_completion_reports_to_change_stream() -> Result<()> {
2498 let (backend, producer) = create_test_backend_with_change_stream().await?;
2499 let id = make_id();
2500 let payload = b"resumable payload".to_vec();
2501 let metadata = Metadata {
2502 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2503 ..Default::default()
2504 };
2505 let token = backend
2506 .create_upload_session(&id, &metadata, nonzero(payload.len() as u64))
2507 .await?;
2508
2509 assert_eq!(
2510 backend
2511 .put_chunk(
2512 &token,
2513 0,
2514 payload.len() as u64,
2515 stream::single::<ClientError>(payload.clone()),
2516 )
2517 .await?,
2518 UploadProgress::Complete
2519 );
2520
2521 let records = producer.records();
2522 assert_eq!(records.len(), 1);
2523 assert_eq!(records[0].op_type, OpType::Write);
2524 assert_eq!(
2525 records[0].size,
2526 Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2527 );
2528 assert!(records[0].expiration_time.is_some());
2529 Ok(())
2530 }
2531
2532 #[cfg(feature = "storage-cogs")]
2533 #[tokio::test]
2534 async fn resumable_completion_after_corrupt_response_reports_to_change_stream() -> Result<()> {
2535 let (mut backend, producer) = create_test_backend_with_change_stream().await?;
2536 let id = make_id_with_key("resumable-change-stream-after-corrupt-response");
2537 let payload = b"final".to_vec();
2538 let metadata = Metadata::default();
2539 let token = backend
2540 .create_upload_session(&id, &metadata, nonzero(payload.len() as u64))
2541 .await?;
2542 inject_retry_test(
2543 &mut backend,
2544 "storage.objects.insert",
2545 "return-broken-stream-final-chunk-after-0B",
2546 )
2547 .await?;
2548
2549 assert!(matches!(
2550 backend
2551 .put_chunk(
2552 &token,
2553 0,
2554 payload.len() as u64,
2555 stream::single::<ClientError>(payload.clone()),
2556 )
2557 .await,
2558 Err(error) if error.kind() == ErrorKind::CorruptData
2559 ));
2560 assert_eq!(
2561 backend.upload_offset(&token).await?,
2562 UploadProgress::Complete
2563 );
2564
2565 let records = producer.records();
2566 assert_eq!(records.len(), 1);
2567 assert_eq!(records[0].op_type, OpType::Write);
2568 assert_eq!(
2569 records[0].size,
2570 Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2571 );
2572 Ok(())
2573 }
2574
2575 #[cfg(feature = "storage-cogs")]
2576 #[tokio::test]
2577 async fn change_stream_size_includes_metadata_keys_and_values() -> Result<()> {
2578 let (backend, producer) = create_test_backend_with_change_stream().await?;
2579 let payload = b"tiny".to_vec();
2580
2581 let bare = Metadata::default();
2582 backend
2583 .put_object(
2584 &make_id(),
2585 &bare,
2586 stream::single::<ClientError>(payload.clone()),
2587 Timestamp::now(),
2588 )
2589 .await?;
2590
2591 let annotated = Metadata {
2592 custom: BTreeMap::from_iter([("a-fairly-long-metadata-key".into(), "value".into())]),
2593 ..Default::default()
2594 };
2595 backend
2596 .put_object(
2597 &make_id(),
2598 &annotated,
2599 stream::single::<ClientError>(payload.clone()),
2600 Timestamp::now(),
2601 )
2602 .await?;
2603
2604 let records = producer.records();
2605 assert_eq!(records.len(), 2);
2606
2607 let bare_size = records[0].size.unwrap();
2608 let annotated_size = records[1].size.unwrap();
2609 assert_eq!(
2610 bare_size,
2611 payload.len() as u64,
2612 "default metadata contributes no custom keys"
2613 );
2614 assert!(
2615 annotated_size > bare_size,
2616 "same payload, more metadata: {annotated_size} should exceed {bare_size}"
2617 );
2618
2619 Ok(())
2620 }
2621
2622 #[cfg(feature = "storage-cogs")]
2623 #[tokio::test]
2624 async fn change_stream_reports_nothing_when_the_object_was_already_gone() -> Result<()> {
2625 let (backend, producer) = create_test_backend_with_change_stream().await?;
2626
2627 backend.delete_object(&make_id(), Timestamp::now()).await?;
2628
2629 assert!(producer.records().is_empty());
2630
2631 Ok(())
2632 }
2633
2634 #[cfg(feature = "storage-cogs")]
2635 #[tokio::test]
2636 async fn change_stream_reports_deletes() -> Result<()> {
2637 let (backend, producer) = create_test_backend_with_change_stream().await?;
2638 let id = make_id();
2639
2640 backend
2641 .put_object(
2642 &id,
2643 &Metadata::default(),
2644 stream::single::<ClientError>(b"hi".to_vec()),
2645 Timestamp::now(),
2646 )
2647 .await?;
2648 producer.clear();
2649
2650 backend.delete_object(&id, Timestamp::now()).await?;
2651
2652 let records = producer.records();
2653 assert_eq!(records.len(), 1, "a retried delete must report only once");
2654 assert_eq!(records[0].op_type, OpType::Delete);
2655 assert_eq!(records[0].size, None);
2656
2657 Ok(())
2658 }
2659
2660 #[cfg(feature = "storage-cogs")]
2661 #[tokio::test]
2662 async fn change_stream_reports_expiry_extension_as_an_update() -> Result<()> {
2663 let (backend, producer) = create_test_backend_with_change_stream().await?;
2664 let id = make_id();
2665 let metadata = Metadata {
2666 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
2667 time_expires: Some(Timestamp::now() + Duration::from_secs(1)),
2668 ..Default::default()
2669 };
2670
2671 backend
2672 .put_object(
2673 &id,
2674 &metadata,
2675 stream::single::<ClientError>(b"hi".to_vec()),
2676 Timestamp::now(),
2677 )
2678 .await?;
2679 producer.clear();
2680
2681 backend.get_metadata(&id, Timestamp::now()).await?;
2682 assert!(producer.records().is_empty());
2683
2684 backend
2685 .set_expiry(
2686 &id,
2687 ExpiryTarget::At(Timestamp::now() + Duration::from_secs(3600)).into(),
2688 Timestamp::now(),
2689 )
2690 .await?;
2691
2692 let records = producer.records();
2693 assert_eq!(records.len(), 1);
2694 assert_eq!(records[0].op_type, OpType::Update);
2695 assert_eq!(records[0].size, None);
2696 assert!(records[0].expiration_time.is_some());
2697
2698 Ok(())
2699 }
2700}