1use std::cmp;
13use std::fmt::{Debug, Formatter};
14use std::ops::Range;
15use std::sync::Arc;
16use std::sync::atomic::{self, AtomicU64};
17use std::time::{Duration, Instant};
18
19use anyhow::{Context, anyhow};
20use async_trait::async_trait;
21use aws_config::sts::AssumeRoleProvider;
22use aws_config::timeout::TimeoutConfig;
23use aws_credential_types::Credentials;
24use aws_sdk_s3::Client as S3Client;
25use aws_sdk_s3::config::{AsyncSleep, Sleep};
26use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
27use aws_sdk_s3::operation::get_object::GetObjectOutput;
28use aws_sdk_s3::primitives::ByteStream;
29use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
30use aws_types::region::Region;
31use bytes::{Bytes, BytesMut};
32use futures_util::stream::FuturesOrdered;
33use futures_util::{FutureExt, StreamExt};
34use mz_dyncfg::{Config, ConfigSet, ParameterScope};
35use mz_ore::bytes::SegmentedBytes;
36use mz_ore::cast::CastFrom;
37use mz_ore::metrics::MetricsRegistry;
38use mz_ore::task::RuntimeExt;
39use tokio::runtime::Handle as AsyncHandle;
40use tracing::{Instrument, debug, debug_span, trace, trace_span};
41use uuid::Uuid;
42
43use crate::cfg::BlobKnobs;
44use crate::error::Error;
45use crate::location::{Blob, BlobMetadata, Determinate, ExternalError};
46use crate::metrics::S3BlobMetrics;
47
48#[derive(Clone, Debug)]
54pub struct S3BlobConfig {
55 metrics: S3BlobMetrics,
56 client: S3Client,
57 bucket: String,
58 prefix: String,
59}
60
61pub const BLOB_HTTP_POOL: Config<&'static str> = Config::new(
66 "persist_blob_http_pool",
67 BlobHttpPool::default().as_str(),
68 "Which S3 blob endpoints spread requests over a growing pool of HTTP clients: 'gcs' \
69 (only GCS endpoints), 'on' (every endpoint), or 'off'. Unrecognized values mean 'off'.",
70 ParameterScope::Environment,
71);
72
73pub const BLOB_HTTP_POOL_MAX_IN_FLIGHT_PER_CLIENT: Config<usize> = Config::new(
80 "persist_blob_http_pool_max_in_flight_per_client",
81 100,
82 "When `persist_blob_http_pool` applies, a blob request goes to a new HTTP client once every \
83 client in the pool has this many requests in flight. 0 means 1.",
84 ParameterScope::Environment,
85);
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum BlobHttpPool {
90 Off,
92 Gcs,
94 On,
96}
97
98impl BlobHttpPool {
99 const ALL: [Self; 3] = [Self::Off, Self::Gcs, Self::On];
100
101 pub const fn default() -> Self {
103 Self::Off
104 }
105
106 const fn as_str(self) -> &'static str {
107 match self {
108 Self::Off => "off",
109 Self::Gcs => "gcs",
110 Self::On => "on",
111 }
112 }
113
114 fn from_str_lossy(s: &str) -> Self {
118 Self::ALL
119 .into_iter()
120 .find(|v| v.as_str() == s)
121 .unwrap_or_else(Self::default)
122 }
123
124 pub fn get(configs: &ConfigSet) -> Self {
126 Self::from_str_lossy(&BLOB_HTTP_POOL.get(configs))
127 }
128
129 fn use_pool(self, is_google_endpoint: bool) -> bool {
130 match self {
131 Self::Off => false,
132 Self::Gcs => is_google_endpoint,
133 Self::On => true,
134 }
135 }
136}
137
138const OPERATION_TIMEOUT_MARKER: Duration = Duration::new(111, 1111);
143const OPERATION_ATTEMPT_TIMEOUT_MARKER: Duration = Duration::new(222, 2222);
144const CONNECT_TIMEOUT_MARKER: Duration = Duration::new(333, 3333);
145const READ_TIMEOUT_MARKER: Duration = Duration::new(444, 4444);
146
147#[derive(Debug)]
148struct MetricsSleep {
149 knobs: Arc<dyn BlobKnobs>,
150 metrics: S3BlobMetrics,
151}
152
153impl AsyncSleep for MetricsSleep {
154 fn sleep(&self, duration: Duration) -> Sleep {
155 let (duration, metric) = match duration {
156 OPERATION_TIMEOUT_MARKER => (
157 self.knobs.operation_timeout(),
158 Some(self.metrics.operation_timeouts.clone()),
159 ),
160 OPERATION_ATTEMPT_TIMEOUT_MARKER => (
161 self.knobs.operation_attempt_timeout(),
162 Some(self.metrics.operation_attempt_timeouts.clone()),
163 ),
164 CONNECT_TIMEOUT_MARKER => (
165 self.knobs.connect_timeout(),
166 Some(self.metrics.connect_timeouts.clone()),
167 ),
168 READ_TIMEOUT_MARKER => (
169 self.knobs.read_timeout(),
170 Some(self.metrics.read_timeouts.clone()),
171 ),
172 duration => (duration, None),
173 };
174
175 Sleep::new(tokio::time::sleep(duration).map(|x| {
180 if let Some(counter) = metric {
181 counter.inc();
182 }
183 x
184 }))
185 }
186}
187
188impl S3BlobConfig {
189 const EXTERNAL_TESTS_S3_BUCKET: &'static str = "MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET";
190
191 pub async fn new(
197 bucket: String,
198 prefix: String,
199 role_arn: Option<String>,
200 endpoint: Option<String>,
201 region: Option<String>,
202 credentials: Option<(String, String)>,
203 knobs: Box<dyn BlobKnobs>,
204 metrics: S3BlobMetrics,
205 ) -> Result<Self, Error> {
206 let knobs: Arc<dyn BlobKnobs> = Arc::from(knobs);
207 let mut loader = mz_aws_util::defaults();
208
209 if let Some(region) = region {
210 loader = loader.region(Region::new(region));
211 };
212
213 if let Some(role_arn) = role_arn {
214 let assume_role_sdk_config = mz_aws_util::defaults().load().await;
215 let role_provider = AssumeRoleProvider::builder(role_arn)
216 .configure(&assume_role_sdk_config)
217 .session_name("persist")
218 .build()
219 .await;
220 loader = loader.credentials_provider(role_provider);
221 }
222
223 if let Some((access_key_id, secret_access_key)) = credentials {
224 loader = loader.credentials_provider(Credentials::from_keys(
225 access_key_id,
226 secret_access_key,
227 None,
228 ));
229 }
230
231 let is_google = endpoint.as_deref().is_some_and(is_google_endpoint);
232 let use_pool = {
233 let knobs = Arc::clone(&knobs);
234 move || knobs.http_pool().use_pool(is_google)
235 };
236 let pool_max_in_flight = {
237 let knobs = Arc::clone(&knobs);
238 move || knobs.http_pool_max_in_flight_per_client()
239 };
240 loader = loader.http_client(mz_aws_util::switched_http_client(
241 use_pool,
242 pool_max_in_flight,
243 metrics.http_pool(),
244 ));
245
246 if let Some(endpoint) = endpoint {
247 loader = loader.endpoint_url(endpoint)
248 }
249
250 loader = loader.sleep_impl(MetricsSleep {
252 knobs,
253 metrics: metrics.clone(),
254 });
255 loader = loader.timeout_config(
256 TimeoutConfig::builder()
257 .operation_timeout(OPERATION_TIMEOUT_MARKER)
259 .operation_attempt_timeout(OPERATION_ATTEMPT_TIMEOUT_MARKER)
261 .connect_timeout(CONNECT_TIMEOUT_MARKER)
263 .read_timeout(READ_TIMEOUT_MARKER)
265 .build(),
266 );
267
268 let client = mz_aws_util::s3::new_client(&loader.load().await);
269 Ok(S3BlobConfig {
270 metrics,
271 client,
272 bucket,
273 prefix,
274 })
275 }
276
277 pub async fn new_for_test() -> Result<Option<Self>, Error> {
314 let bucket = match std::env::var(Self::EXTERNAL_TESTS_S3_BUCKET) {
315 Ok(bucket) => bucket,
316 Err(_) => {
317 if mz_ore::env::is_var_truthy("CI") {
318 panic!("CI is supposed to run this test but something has gone wrong!");
319 }
320 return Ok(None);
321 }
322 };
323
324 struct TestBlobKnobs;
325 impl std::fmt::Debug for TestBlobKnobs {
326 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
327 f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
328 }
329 }
330 impl BlobKnobs for TestBlobKnobs {
331 fn operation_timeout(&self) -> Duration {
332 OPERATION_TIMEOUT_MARKER
333 }
334
335 fn operation_attempt_timeout(&self) -> Duration {
336 OPERATION_ATTEMPT_TIMEOUT_MARKER
337 }
338
339 fn connect_timeout(&self) -> Duration {
340 CONNECT_TIMEOUT_MARKER
341 }
342
343 fn read_timeout(&self) -> Duration {
344 READ_TIMEOUT_MARKER
345 }
346
347 fn is_cc_active(&self) -> bool {
348 false
349 }
350 }
351
352 let prefix = Uuid::new_v4().to_string();
356 let role_arn = None;
357 let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
358 let config = S3BlobConfig::new(
359 bucket,
360 prefix,
361 role_arn,
362 None,
363 None,
364 None,
365 Box::new(TestBlobKnobs),
366 metrics,
367 )
368 .await?;
369 Ok(Some(config))
370 }
371
372 pub fn with_http_client(
375 mut self,
376 http_client: impl aws_sdk_s3::config::HttpClient + 'static,
377 ) -> Self {
378 let config = self
379 .client
380 .config()
381 .to_builder()
382 .http_client(http_client)
383 .build();
384 self.client = S3Client::from_conf(config);
385 self
386 }
387
388 pub fn clone_with_new_uuid_prefix(&self) -> Self {
390 let mut ret = self.clone();
391 ret.prefix = Uuid::new_v4().to_string();
392 ret
393 }
394}
395
396#[derive(Debug)]
398pub struct S3Blob {
399 metrics: S3BlobMetrics,
400 client: S3Client,
401 bucket: String,
402 prefix: String,
403 max_keys: i32,
407 multipart_config: MultipartConfig,
408}
409
410impl S3Blob {
411 pub async fn open(config: S3BlobConfig) -> Result<Self, ExternalError> {
413 let ret = S3Blob {
414 metrics: config.metrics,
415 client: config.client,
416 bucket: config.bucket,
417 prefix: config.prefix,
418 max_keys: 1_000,
419 multipart_config: MultipartConfig::default(),
420 };
421 let _ = ret.get("HEALTH_CHECK").await?;
425 Ok(ret)
426 }
427
428 fn get_path(&self, key: &str) -> String {
429 format!("{}/{}", self.prefix, key)
430 }
431}
432
433#[async_trait]
434impl Blob for S3Blob {
435 async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
436 let start_overall = Instant::now();
437 let path = self.get_path(key);
438
439 let min_body_elapsed = Arc::new(MinElapsed::default());
482 let min_header_elapsed = Arc::new(MinElapsed::default());
483 self.metrics.get_part.inc();
484
485 let header_start = Instant::now();
487 let object = self
488 .client
489 .get_object()
490 .bucket(&self.bucket)
491 .key(&path)
492 .part_number(1)
493 .send()
494 .await;
495 let elapsed = header_start.elapsed();
496 min_header_elapsed.observe(elapsed, "s3 download first part header");
497
498 let first_part = match object {
499 Ok(object) => object,
500 Err(SdkError::ServiceError(err)) if err.err().is_no_such_key() => return Ok(None),
501 Err(err) => {
502 self.update_error_metrics("GetObject", &err);
503 Err(anyhow!(err).context("s3 get meta err"))?
504 }
505 };
506
507 let total_len = first_part
513 .content_range()
514 .and_then(parse_content_range_total);
515 let first_len = first_part
516 .content_length()
517 .and_then(|len| u64::try_from(len).ok());
518
519 let remaining: Vec<PartSelector> = match first_part.parts_count() {
520 None => match (first_len, total_len) {
527 (Some(first_len), Some(total_len)) if first_len < total_len => {
528 remaining_ranges(first_len, total_len)
529 .into_iter()
530 .map(PartSelector::Range)
531 .collect()
532 }
533 _ => Vec::new(),
534 },
535 Some(parts @ 1..) => (2..=parts).map(PartSelector::Number).collect(),
536 Some(bad) => {
538 assert!(bad <= 0);
539 return Err(anyhow!("unexpected number of s3 object parts: {}", bad).into());
540 }
541 };
542 let num_requests = remaining.len() + 1;
543
544 trace!(
545 "s3 download first header took {:?} ({num_requests} requests)",
546 start_overall.elapsed(),
547 );
548
549 let mut body_futures = FuturesOrdered::new();
550 let mut requests = vec![PartRequest::First(first_part)];
551 requests.extend(remaining.into_iter().map(PartRequest::Fetch));
552
553 for request in requests {
554 let min_header_elapsed = Arc::clone(&min_header_elapsed);
557 let min_body_elapsed = Arc::clone(&min_body_elapsed);
558 let get_invalid_resp = self.metrics.get_invalid_resp.clone();
559 let path = &path;
560 let request_future = async move {
561 let mut object = match request {
562 PartRequest::First(object) => object,
565 PartRequest::Fetch(selector) => {
566 let header_start = Instant::now();
567 let req = self.client.get_object().bucket(&self.bucket).key(path);
568 let req = match selector {
569 PartSelector::Number(part_num) => req.part_number(part_num),
570 PartSelector::Range(range) => {
571 req.range(format!("bytes={}-{}", range.start, range.end - 1))
572 }
573 };
574 let object = req
575 .send()
576 .await
577 .inspect_err(|err| self.update_error_metrics("GetObject", err))
578 .context("s3 get meta err")?;
579 min_header_elapsed
580 .observe(header_start.elapsed(), "s3 download part header");
581 object
582 }
583 };
584 let body_start = Instant::now();
586
587 let mut buf = match object.content_length() {
595 Some(len @ 1..) => BytesMut::with_capacity(usize::cast_from(
596 u64::try_from(len).expect("positive integer"),
597 )),
598 Some(len @ ..=-1) => {
599 tracing::trace!(?len, "found invalid content-length");
600 get_invalid_resp.inc();
601 BytesMut::new()
602 }
603 Some(0) | None => BytesMut::new(),
604 };
605
606 while let Some(data) = object.body.next().await {
607 let data = data.context("s3 get body err")?;
608 buf.extend_from_slice(&data);
609 }
610
611 let body_elapsed = body_start.elapsed();
612 min_body_elapsed.observe(body_elapsed, "s3 download part body");
613
614 let body_parts = if buf.is_empty() {
615 Vec::new()
616 } else {
617 vec![buf.freeze()]
618 };
619 Ok::<_, anyhow::Error>(body_parts)
620 };
621
622 body_futures.push_back(request_future);
623 }
624
625 let mut segments = vec![];
627 while let Some(result) = body_futures.next().await {
628 let mut part_body = result
630 .inspect_err(|e| {
631 self.metrics
632 .error_counts
633 .with_label_values(&["GetObjectStream", e.to_string().as_str()])
634 .inc()
635 })
636 .context("s3 get body err")?;
637
638 segments.append(&mut part_body);
640 }
641
642 if let Some(total_len) = total_len {
648 let fetched_len: u64 = segments.iter().map(|s| u64::cast_from(s.len())).sum();
649 if fetched_len != total_len {
650 self.metrics.get_invalid_resp.inc();
651 return Err(anyhow!(
652 "s3 GetObject {path} returned {fetched_len} bytes of a {total_len} byte object"
653 )
654 .into());
655 }
656 }
657
658 debug!(
659 "s3 GetObject took {:?} ({} requests)",
660 start_overall.elapsed(),
661 num_requests
662 );
663 Ok(Some(SegmentedBytes::from(segments)))
664 }
665
666 async fn list_keys_and_metadata(
667 &self,
668 key_prefix: &str,
669 f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
670 ) -> Result<(), ExternalError> {
671 let mut continuation_token = None;
672 let blob_key_prefix = self.get_path(key_prefix);
674 let strippable_root_prefix = format!("{}/", self.prefix);
677
678 loop {
679 self.metrics.list_objects.inc();
680 let resp = self
681 .client
682 .list_objects_v2()
683 .bucket(&self.bucket)
684 .prefix(&blob_key_prefix)
685 .max_keys(self.max_keys)
686 .set_continuation_token(continuation_token)
687 .send()
688 .await
689 .inspect_err(|err| self.update_error_metrics("ListObjectsV2", err))
690 .context("list bucket error")?;
691 if let Some(contents) = resp.contents {
692 for object in contents.iter() {
693 if let Some(key) = object.key.as_ref() {
694 if let Some(key) = key.strip_prefix(&strippable_root_prefix) {
695 let size_in_bytes = match object.size {
696 None => {
697 return Err(ExternalError::from(anyhow!(
698 "object missing size: {key}"
699 )));
700 }
701 Some(size) => size
702 .try_into()
703 .expect("file in S3 cannot have negative size"),
704 };
705 f(BlobMetadata { key, size_in_bytes });
706 } else {
707 return Err(ExternalError::from(anyhow!(
708 "found key with invalid prefix: {}",
709 key
710 )));
711 }
712 }
713 }
714 }
715
716 if resp.next_continuation_token.is_some() {
717 continuation_token = resp.next_continuation_token;
718 } else {
719 break;
720 }
721 }
722
723 Ok(())
724 }
725
726 async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
727 let value_len = value.len();
728 if self
729 .multipart_config
730 .should_multipart(value_len)
731 .map_err(anyhow::Error::msg)?
732 {
733 self.set_multi_part(key, value)
734 .instrument(debug_span!("s3set_multi", payload_len = value_len))
735 .await
736 } else {
737 self.set_single_part(key, value).await
738 }
739 }
740
741 async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
742 let path = self.get_path(key);
747 self.metrics.delete_head.inc();
748 let head_res = self
749 .client
750 .head_object()
751 .bucket(&self.bucket)
752 .key(&path)
753 .send()
754 .await;
755 let size_bytes = match head_res {
756 Ok(x) => match x.content_length {
757 None => {
758 return Err(ExternalError::from(anyhow!(
759 "s3 delete content length was none"
760 )));
761 }
762 Some(content_length) => {
763 u64::try_from(content_length).expect("file in S3 cannot have negative size")
764 }
765 },
766 Err(SdkError::ServiceError(err)) if err.err().is_not_found() => return Ok(None),
767 Err(err) => {
768 self.update_error_metrics("HeadObject", &err);
769 return Err(ExternalError::from(
770 anyhow!(err).context("s3 delete head err"),
771 ));
772 }
773 };
774 self.metrics.delete_object.inc();
775 let _ = self
776 .client
777 .delete_object()
778 .bucket(&self.bucket)
779 .key(&path)
780 .send()
781 .await
782 .inspect_err(|err| self.update_error_metrics("DeleteObject", err))
783 .context("s3 delete object err")?;
784 Ok(Some(usize::cast_from(size_bytes)))
785 }
786
787 async fn restore(&self, key: &str) -> Result<(), ExternalError> {
788 let path = self.get_path(key);
789 loop {
794 let list_res = self
798 .client
799 .list_object_versions()
800 .bucket(&self.bucket)
801 .prefix(&path)
802 .max_keys(1)
803 .send()
804 .await
805 .inspect_err(|err| self.update_error_metrics("ListObjectVersions", err))
806 .context("listing object versions during restore")?;
807
808 let current_delete = list_res
809 .delete_markers()
810 .into_iter()
811 .filter(|d| {
812 d.key() == Some(path.as_str())
815 })
816 .find(|d| d.is_latest().unwrap_or(false))
817 .and_then(|d| d.version_id());
818
819 if let Some(version) = current_delete {
820 let deleted = self
821 .client
822 .delete_object()
823 .bucket(&self.bucket)
824 .key(&path)
825 .version_id(version)
826 .send()
827 .await
828 .inspect_err(|err| self.update_error_metrics("DeleteObject", err))
829 .context("deleting a delete marker")?;
830 assert!(
831 deleted.delete_marker().unwrap_or(false),
832 "deleting a delete marker"
833 );
834 } else {
835 let has_current_version = list_res
836 .versions()
837 .into_iter()
838 .filter(|d| d.key() == Some(path.as_str()))
839 .any(|v| v.is_latest().unwrap_or(false));
840
841 if !has_current_version {
842 return Err(Determinate::new(anyhow!(
843 "unable to restore {key} in s3: no valid version exists"
844 ))
845 .into());
846 }
847 return Ok(());
848 }
849 }
850 }
851}
852
853impl S3Blob {
854 async fn set_single_part(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
855 let start_overall = Instant::now();
856 let path = self.get_path(key);
857
858 let value_len = value.len();
859 let part_span = trace_span!("s3set_single", payload_len = value_len);
860 self.metrics.set_single.inc();
861 self.client
862 .put_object()
863 .bucket(&self.bucket)
864 .key(path)
865 .body(ByteStream::from(value))
866 .send()
867 .instrument(part_span)
868 .await
869 .inspect_err(|err| self.update_error_metrics("PutObject", err))
870 .context("set single part")?;
871 debug!(
872 "s3 PutObject single done {}b / {:?}",
873 value_len,
874 start_overall.elapsed()
875 );
876 Ok(())
877 }
878
879 #[allow(clippy::as_conversions)]
882 async fn set_multi_part(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
883 let start_overall = Instant::now();
884 let path = self.get_path(key);
885
886 trace!("s3 PutObject multi start {}b", value.len());
888 self.metrics.set_multi_create.inc();
889 let upload_res = self
890 .client
891 .create_multipart_upload()
892 .bucket(&self.bucket)
893 .key(&path)
894 .customize()
895 .mutate_request(|req| {
896 req.headers_mut().insert("Content-Length", "0");
901 })
902 .send()
903 .instrument(debug_span!("s3set_multi_start"))
904 .await
905 .inspect_err(|err| self.update_error_metrics("CreateMultipartUpload", err))
906 .context("create_multipart_upload err")?;
907 let upload_id = upload_res
908 .upload_id()
909 .ok_or_else(|| anyhow!("create_multipart_upload response missing upload_id"))?;
910 trace!(
911 "s3 create_multipart_upload took {:?}",
912 start_overall.elapsed()
913 );
914
915 let async_runtime = AsyncHandle::try_current().map_err(anyhow::Error::new)?;
916
917 let start_parts = Instant::now();
922 let mut part_futs = Vec::new();
923 for (part_num, part_range) in self.multipart_config.part_iter(value.len()) {
924 let part_span = debug_span!("s3set_multi_part", payload_len = part_range.len());
928 let part_fut = async_runtime.spawn_named(
929 || "persist_s3blob_put_part",
932 {
933 self.metrics.set_multi_part.inc();
934 self.client
935 .upload_part()
936 .bucket(&self.bucket)
937 .key(&path)
938 .upload_id(upload_id)
939 .part_number(part_num as i32)
940 .body(ByteStream::from(value.slice(part_range)))
941 .send()
942 .instrument(part_span)
943 .map(move |res| (start_parts.elapsed(), res))
944 },
945 );
946 part_futs.push((part_num, part_fut));
947 }
948 let parts_len = part_futs.len();
949
950 let min_part_elapsed = MinElapsed::default();
958 let mut parts = Vec::with_capacity(parts_len);
959 for (part_num, part_fut) in part_futs.into_iter() {
960 let (this_part_elapsed, part_res) = part_fut.await;
961 let part_res = part_res
962 .inspect_err(|err| self.update_error_metrics("UploadPart", err))
963 .context("s3 upload_part err")?;
964 let part_e_tag = part_res.e_tag().ok_or_else(|| {
965 self.metrics
966 .error_counts
967 .with_label_values(&["UploadPart", "MissingEtag"])
968 .inc();
969 anyhow!("s3 upload part missing e_tag")
970 })?;
971 parts.push(
972 CompletedPart::builder()
973 .e_tag(part_e_tag)
974 .part_number(part_num as i32)
975 .build(),
976 );
977 min_part_elapsed.observe(this_part_elapsed, "s3 upload_part took");
978 }
979 trace!(
980 "s3 upload_parts overall took {:?} ({} parts)",
981 start_parts.elapsed(),
982 parts_len
983 );
984
985 let start_complete = Instant::now();
997 self.metrics.set_multi_complete.inc();
998 self.client
999 .complete_multipart_upload()
1000 .bucket(&self.bucket)
1001 .key(&path)
1002 .upload_id(upload_id)
1003 .multipart_upload(
1004 CompletedMultipartUpload::builder()
1005 .set_parts(Some(parts))
1006 .build(),
1007 )
1008 .send()
1009 .instrument(debug_span!("s3set_multi_complete", num_parts = parts_len))
1010 .await
1011 .inspect_err(|err| self.update_error_metrics("CompleteMultipartUpload", err))
1012 .context("complete_multipart_upload err")?;
1013 trace!(
1014 "s3 complete_multipart_upload took {:?}",
1015 start_complete.elapsed()
1016 );
1017
1018 debug!(
1019 "s3 PutObject multi done {}b / {:?} ({} parts)",
1020 value.len(),
1021 start_overall.elapsed(),
1022 parts_len
1023 );
1024 Ok(())
1025 }
1026
1027 fn update_error_metrics<E, R>(&self, op: &str, err: &SdkError<E, R>)
1028 where
1029 E: ProvideErrorMetadata,
1030 {
1031 let code = match err {
1032 SdkError::ServiceError(e) => match e.err().code() {
1033 Some(code) => code,
1034 None => "UnknownServiceError",
1035 },
1036 SdkError::DispatchFailure(e) => {
1037 if let Some(other_error) = e.as_other() {
1038 match other_error {
1039 aws_config::retry::ErrorKind::TransientError => "TransientError",
1040 aws_config::retry::ErrorKind::ThrottlingError => "ThrottlingError",
1041 aws_config::retry::ErrorKind::ServerError => "ServerError",
1042 aws_config::retry::ErrorKind::ClientError => "ClientError",
1043 _ => "UnknownDispatchFailure",
1044 }
1045 } else if e.is_timeout() {
1046 "TimeoutError"
1047 } else if e.is_io() {
1048 "IOError"
1049 } else if e.is_user() {
1050 "UserError"
1051 } else {
1052 "UnknownDispathFailure"
1053 }
1054 }
1055 SdkError::ResponseError(_) => "ResponseError",
1056 SdkError::ConstructionFailure(_) => "ConstructionFailure",
1057 SdkError::TimeoutError(_) => "TimeoutError",
1060 _ => "UnknownSdkError",
1062 };
1063 self.metrics
1064 .error_counts
1065 .with_label_values(&[op, code])
1066 .inc();
1067 }
1068}
1069
1070enum PartRequest {
1072 First(GetObjectOutput),
1074 Fetch(PartSelector),
1076}
1077
1078enum PartSelector {
1080 Number(i32),
1082 Range(Range<u64>),
1084}
1085
1086fn is_google_endpoint(endpoint: &str) -> bool {
1090 let Ok(url) = url::Url::parse(endpoint) else {
1091 return false;
1092 };
1093 let Some(host) = url.host_str() else {
1094 return false;
1095 };
1096 let host = host.strip_suffix('.').unwrap_or(host);
1097 host == "googleapis.com" || host.ends_with(".googleapis.com")
1098}
1099
1100fn parse_content_range_total(content_range: &str) -> Option<u64> {
1103 content_range.rsplit_once('/')?.1.trim().parse().ok()
1104}
1105
1106fn remaining_ranges(first_len: u64, total_len: u64) -> Vec<Range<u64>> {
1111 let chunk_len = if first_len == 0 { total_len } else { first_len };
1112 let mut ranges = Vec::new();
1113 let mut start = first_len;
1114 while start < total_len {
1115 let end = cmp::min(start + chunk_len, total_len);
1116 ranges.push(start..end);
1117 start = end;
1118 }
1119 ranges
1120}
1121
1122#[derive(Clone, Debug)]
1123struct MultipartConfig {
1124 multipart_threshold: usize,
1125 multipart_chunk_size: usize,
1126}
1127
1128impl Default for MultipartConfig {
1129 fn default() -> Self {
1130 Self {
1131 multipart_threshold: Self::DEFAULT_MULTIPART_THRESHOLD,
1132 multipart_chunk_size: Self::DEFAULT_MULTIPART_CHUNK_SIZE,
1133 }
1134 }
1135}
1136
1137const MB: usize = 1024 * 1024;
1138const TB: usize = 1024 * 1024 * MB;
1139
1140impl MultipartConfig {
1141 const DEFAULT_MULTIPART_THRESHOLD: usize = 8 * MB;
1147 const DEFAULT_MULTIPART_CHUNK_SIZE: usize = 8 * MB;
1153
1154 const MAX_SINGLE_UPLOAD_SIZE: usize = 5 * TB;
1158 const MIN_UPLOAD_CHUNK_SIZE: usize = 5 * MB;
1164 const MIN_PART_NUM: u32 = 1;
1168 const MAX_PART_NUM: u32 = 10_000;
1172
1173 fn should_multipart(&self, blob_len: usize) -> Result<bool, String> {
1174 if blob_len > Self::MAX_SINGLE_UPLOAD_SIZE {
1175 return Err(format!(
1176 "S3 does not support blobs larger than {} bytes got: {}",
1177 Self::MAX_SINGLE_UPLOAD_SIZE,
1178 blob_len
1179 ));
1180 }
1181 Ok(blob_len > self.multipart_threshold)
1182 }
1183
1184 fn part_iter(&self, blob_len: usize) -> MultipartChunkIter {
1185 mz_ore::soft_assert_no_log!(
1186 self.multipart_chunk_size >= MultipartConfig::MIN_UPLOAD_CHUNK_SIZE
1187 );
1188 MultipartChunkIter::new(self.multipart_chunk_size, blob_len)
1189 }
1190}
1191
1192#[derive(Clone, Debug)]
1193struct MultipartChunkIter {
1194 total_len: usize,
1195 part_size: usize,
1196 part_idx: u32,
1197}
1198
1199impl MultipartChunkIter {
1200 fn new(default_part_size: usize, blob_len: usize) -> Self {
1201 let max_parts: usize = usize::cast_from(MultipartConfig::MAX_PART_NUM);
1202
1203 let min_part_size = (blob_len + max_parts - 1) / max_parts;
1208 let part_size = cmp::max(min_part_size, default_part_size);
1209
1210 let part_idx = MultipartConfig::MIN_PART_NUM - 1;
1213 MultipartChunkIter {
1214 total_len: blob_len,
1215 part_size,
1216 part_idx,
1217 }
1218 }
1219}
1220
1221impl Iterator for MultipartChunkIter {
1222 type Item = (u32, Range<usize>);
1223
1224 fn next(&mut self) -> Option<Self::Item> {
1225 let part_idx = self.part_idx;
1226 self.part_idx += 1;
1227
1228 let start = usize::cast_from(part_idx) * self.part_size;
1229 if start >= self.total_len {
1230 return None;
1231 }
1232 let end = cmp::min(start + self.part_size, self.total_len);
1233 let part_num = part_idx + 1;
1234 Some((part_num, start..end))
1235 }
1236}
1237
1238#[derive(Debug)]
1240struct MinElapsed {
1241 min: AtomicU64,
1242 alert_factor: u64,
1243}
1244
1245impl Default for MinElapsed {
1246 fn default() -> Self {
1247 MinElapsed {
1248 min: AtomicU64::new(u64::MAX),
1249 alert_factor: 8,
1250 }
1251 }
1252}
1253
1254impl MinElapsed {
1255 fn observe(&self, x: Duration, msg: &'static str) {
1256 let nanos = x.as_nanos();
1257 let nanos = u64::try_from(nanos).unwrap_or(u64::MAX);
1258
1259 let prev_min = self.min.fetch_min(nanos, atomic::Ordering::SeqCst);
1261
1262 let new_min = std::cmp::min(prev_min, nanos);
1264 if nanos > new_min.saturating_mul(self.alert_factor) {
1265 let min_duration = Duration::from_nanos(new_min);
1266 let factor = self.alert_factor;
1267 debug!("{msg} took {x:?} more than {factor}x the min {min_duration:?}");
1268 } else {
1269 trace!("{msg} took {x:?}");
1270 }
1271 }
1272}
1273
1274#[allow(dead_code)]
1279fn openssl_sys_hack() {
1280 openssl_sys::init();
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use tracing::info;
1286
1287 use crate::location::tests::blob_impl_test;
1288
1289 use super::*;
1290
1291 #[mz_ore::test]
1292 fn blob_http_pool_values() {
1293 for mode in BlobHttpPool::ALL {
1294 assert_eq!(BlobHttpPool::from_str_lossy(mode.as_str()), mode);
1295 }
1296 assert_eq!(
1297 BlobHttpPool::from_str_lossy(BLOB_HTTP_POOL.default()),
1298 BlobHttpPool::default()
1299 );
1300 assert_eq!(
1301 BlobHttpPool::from_str_lossy("not_a_mode"),
1302 BlobHttpPool::default()
1303 );
1304 }
1305
1306 #[mz_ore::test]
1307 fn google_endpoints() {
1308 assert!(is_google_endpoint("https://storage.googleapis.com"));
1309 assert!(is_google_endpoint("https://STORAGE.googleapis.com/"));
1310 assert!(is_google_endpoint("https://storage.googleapis.com."));
1311 assert!(is_google_endpoint(
1312 "https://storage.us-east1.rep.googleapis.com"
1313 ));
1314 assert!(is_google_endpoint("https://storage-mz.p.googleapis.com"));
1315 assert!(!is_google_endpoint("https://notgoogleapis.com"));
1316 assert!(!is_google_endpoint("https://googleapis.com.example.com"));
1317 assert!(!is_google_endpoint("https://s3.us-east-1.amazonaws.com"));
1318 assert!(!is_google_endpoint("not a url"));
1319 }
1320
1321 #[mz_ore::test]
1322 fn content_range_total() {
1323 assert_eq!(
1324 parse_content_range_total("bytes 0-8388607/9711660"),
1325 Some(9711660)
1326 );
1327 assert_eq!(parse_content_range_total("bytes */42"), Some(42));
1328 assert_eq!(parse_content_range_total("bytes 0-1/*"), None);
1329 assert_eq!(parse_content_range_total("garbage"), None);
1330 }
1331
1332 #[mz_ore::test]
1333 fn ranges_for_unreported_parts() {
1334 let mib = 1024 * 1024;
1335 assert_eq!(remaining_ranges(8 * mib, 9711660), vec![8 * mib..9711660]);
1336 assert_eq!(
1337 remaining_ranges(8 * mib, 3 * 8 * mib + 5),
1338 vec![
1339 8 * mib..16 * mib,
1340 16 * mib..24 * mib,
1341 24 * mib..24 * mib + 5
1342 ]
1343 );
1344 assert_eq!(remaining_ranges(8 * mib, 8 * mib), Vec::<Range<u64>>::new());
1345 assert_eq!(remaining_ranges(0, 10), vec![0..10]);
1346 }
1347
1348 #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1349 #[cfg_attr(coverage, ignore)] #[cfg_attr(miri, ignore)] #[ignore] async fn s3_blob() -> Result<(), ExternalError> {
1353 let config = match S3BlobConfig::new_for_test().await? {
1354 Some(client) => client,
1355 None => {
1356 info!(
1357 "{} env not set: skipping test that uses external service",
1358 S3BlobConfig::EXTERNAL_TESTS_S3_BUCKET
1359 );
1360 return Ok(());
1361 }
1362 };
1363 let config_multipart = config.clone_with_new_uuid_prefix();
1364
1365 blob_impl_test(move |path| {
1366 let path = path.to_owned();
1367 let config = config.clone();
1368 async move {
1369 let config = S3BlobConfig {
1370 metrics: config.metrics.clone(),
1371 client: config.client.clone(),
1372 bucket: config.bucket.clone(),
1373 prefix: format!("{}/s3_blob_impl_test/{}", config.prefix, path),
1374 };
1375 let mut blob = S3Blob::open(config).await?;
1376 blob.max_keys = 2;
1377 Ok(blob)
1378 }
1379 })
1380 .await?;
1381
1382 {
1386 let blob = S3Blob::open(config_multipart).await?;
1387 blob.set_multi_part("multipart", "foobar".into()).await?;
1388 assert_eq!(
1389 blob.get("multipart").await?,
1390 Some(b"foobar".to_vec().into())
1391 );
1392 }
1393
1394 Ok(())
1395 }
1396
1397 #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1403 #[cfg_attr(coverage, ignore)] #[cfg_attr(miri, ignore)] #[ignore] async fn s3_blob_hedged() -> Result<(), ExternalError> {
1407 use crate::hedge::{
1408 BLOB_HEDGED_GET_BUDGET_RATIO, BLOB_HEDGED_GET_DELAY, BLOB_HEDGED_GET_ENABLED,
1409 HedgeSibling, HedgedBlob,
1410 };
1411 use crate::metrics::BlobHedgeMetrics;
1412 use mz_dyncfg::{ConfigSet, ConfigUpdates};
1413
1414 let config = match S3BlobConfig::new_for_test().await? {
1415 Some(client) => client,
1416 None => return Ok(()),
1417 };
1418 let sibling = match S3BlobConfig::new_for_test().await? {
1421 Some(client) => client,
1422 None => return Ok(()),
1423 };
1424
1425 let cfg = crate::cfg::all_dyn_configs(ConfigSet::default());
1426 let mut updates = ConfigUpdates::default();
1427 updates.add(&BLOB_HEDGED_GET_ENABLED, true);
1428 updates.add(&BLOB_HEDGED_GET_DELAY, Duration::ZERO);
1429 updates.add(&BLOB_HEDGED_GET_BUDGET_RATIO, 1.0);
1430 updates.apply(&cfg);
1431 let cfg = Arc::new(cfg);
1432
1433 blob_impl_test(move |path| {
1434 let path = path.to_owned();
1435 let config = config.clone();
1436 let sibling = sibling.clone();
1437 let cfg = Arc::clone(&cfg);
1438 async move {
1439 let prefix = format!("{}/s3_blob_hedged_test/{}", config.prefix, path);
1440 let primary_config = S3BlobConfig {
1441 metrics: config.metrics.clone(),
1442 client: config.client.clone(),
1443 bucket: config.bucket.clone(),
1444 prefix: prefix.clone(),
1445 };
1446 let hedge_config = S3BlobConfig {
1447 metrics: sibling.metrics.clone(),
1448 client: sibling.client.clone(),
1449 bucket: config.bucket.clone(),
1450 prefix,
1451 };
1452 let primary: Arc<dyn Blob> = Arc::new(S3Blob::open(primary_config).await?);
1453 let hedge: Arc<dyn Blob> = Arc::new(S3Blob::open(hedge_config).await?);
1454 Ok(HedgedBlob::new(
1455 primary,
1456 HedgeSibling::Isolated(hedge),
1457 cfg,
1458 BlobHedgeMetrics::new(&MetricsRegistry::new()),
1459 ))
1460 }
1461 })
1462 .await?;
1463
1464 Ok(())
1465 }
1466
1467 #[mz_ore::test]
1468 fn should_multipart() {
1469 let config = MultipartConfig::default();
1470 assert_eq!(config.should_multipart(0), Ok(false));
1471 assert_eq!(config.should_multipart(1), Ok(false));
1472 assert_eq!(
1473 config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD),
1474 Ok(false)
1475 );
1476 assert_eq!(
1477 config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD + 1),
1478 Ok(true)
1479 );
1480 assert_eq!(
1481 config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD * 2),
1482 Ok(true)
1483 );
1484 assert_eq!(
1485 config.should_multipart(MultipartConfig::MAX_SINGLE_UPLOAD_SIZE),
1486 Ok(true)
1487 );
1488 assert_eq!(
1489 config.should_multipart(MultipartConfig::MAX_SINGLE_UPLOAD_SIZE + 1),
1490 Err(
1491 "S3 does not support blobs larger than 5497558138880 bytes got: 5497558138881"
1492 .into()
1493 )
1494 );
1495 }
1496
1497 #[mz_ore::test]
1498 fn multipart_iter() {
1499 let iter = MultipartChunkIter::new(10, 0);
1500 assert_eq!(iter.collect::<Vec<_>>(), vec![]);
1501
1502 let iter = MultipartChunkIter::new(10, 9);
1503 assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..9)]);
1504
1505 let iter = MultipartChunkIter::new(10, 10);
1506 assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10)]);
1507
1508 let iter = MultipartChunkIter::new(10, 11);
1509 assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..11)]);
1510
1511 let iter = MultipartChunkIter::new(10, 19);
1512 assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..19)]);
1513
1514 let iter = MultipartChunkIter::new(10, 20);
1515 assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..20)]);
1516
1517 let iter = MultipartChunkIter::new(10, 21);
1518 assert_eq!(
1519 iter.collect::<Vec<_>>(),
1520 vec![(1, 0..10), (2, 10..20), (3, 20..21)]
1521 );
1522 }
1523
1524 mod stalled_body {
1529 use std::net::SocketAddr;
1530
1531 use mz_dyncfg::{ConfigSet, ConfigUpdates};
1532 use mz_ore::task::{AbortOnDropHandle, JoinSetExt};
1533 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1534 use tokio::net::{TcpListener, TcpStream};
1535 use tokio::task::JoinSet;
1536
1537 use crate::hedge::{BLOB_HEDGED_GET_ENABLED, HedgeSibling, HedgedBlob};
1538 use crate::metrics::BlobHedgeMetrics;
1539
1540 use super::*;
1541
1542 const BODY_LEN: usize = 1024 * 1024;
1543
1544 #[derive(Debug)]
1546 struct ProdKnobs;
1547
1548 impl BlobKnobs for ProdKnobs {
1549 fn operation_timeout(&self) -> Duration {
1550 Duration::from_secs(180)
1551 }
1552 fn operation_attempt_timeout(&self) -> Duration {
1553 Duration::from_secs(90)
1554 }
1555 fn connect_timeout(&self) -> Duration {
1556 Duration::from_secs(7)
1557 }
1558 fn read_timeout(&self) -> Duration {
1559 Duration::from_secs(10)
1560 }
1561 fn is_cc_active(&self) -> bool {
1562 false
1563 }
1564 }
1565
1566 struct FakeS3 {
1572 addr: SocketAddr,
1573 _accept: AbortOnDropHandle<()>,
1574 }
1575
1576 impl FakeS3 {
1577 async fn start(stall: bool) -> FakeS3 {
1578 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1579 let addr = listener.local_addr().expect("local addr");
1580 let accept = mz_ore::task::spawn(|| "fake_s3_accept", async move {
1581 let mut conns = JoinSet::new();
1583 loop {
1584 let (stream, _) = listener.accept().await.expect("accept");
1585 conns.spawn_named(|| "fake_s3_conn", serve(stream, stall));
1586 }
1587 })
1588 .abort_on_drop();
1589 FakeS3 {
1590 addr,
1591 _accept: accept,
1592 }
1593 }
1594
1595 async fn open(&self) -> S3Blob {
1596 let config = S3BlobConfig::new(
1597 "bucket".into(),
1598 "prefix".into(),
1599 None,
1600 Some(format!("http://{}", self.addr)),
1601 Some("us-east-1".into()),
1602 Some(("user".into(), "pass".into())),
1603 Box::new(ProdKnobs),
1604 S3BlobMetrics::new(&MetricsRegistry::new()),
1605 )
1606 .await
1607 .expect("config");
1608 S3Blob::open(config).await.expect("open")
1609 }
1610 }
1611
1612 async fn serve(mut stream: TcpStream, stall: bool) {
1613 let mut buf = Vec::new();
1614 loop {
1615 let head_end = loop {
1616 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1617 break pos;
1618 }
1619 let mut chunk = [0u8; 4096];
1620 match stream.read(&mut chunk).await {
1621 Ok(0) | Err(_) => return,
1622 Ok(n) => buf.extend_from_slice(&chunk[..n]),
1623 }
1624 };
1625 let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
1626 buf.drain(..head_end + 4);
1627 let path = head.split_whitespace().nth(1).unwrap_or_default();
1628 if !path.contains("/target") {
1629 let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1630 <Error><Code>NoSuchKey</Code></Error>";
1631 let resp = format!(
1632 "HTTP/1.1 404 Not Found\r\n\
1633 Content-Type: application/xml\r\n\
1634 Content-Length: {}\r\n\r\n{body}",
1635 body.len()
1636 );
1637 if stream.write_all(resp.as_bytes()).await.is_err() {
1638 return;
1639 }
1640 continue;
1641 }
1642 let mut resp = format!(
1643 "HTTP/1.1 206 Partial Content\r\n\
1644 ETag: \"etag\"\r\n\
1645 Content-Range: bytes 0-{last}/{BODY_LEN}\r\n\
1646 Content-Type: application/octet-stream\r\n\
1647 Content-Length: {BODY_LEN}\r\n\r\n",
1648 last = BODY_LEN - 1,
1649 )
1650 .into_bytes();
1651 if !stall {
1652 resp.resize(resp.len() + BODY_LEN, b'x');
1653 }
1654 if stream.write_all(&resp).await.is_err() {
1655 return;
1656 }
1657 if stall {
1658 let mut chunk = [0u8; 4096];
1660 while let Ok(n) = stream.read(&mut chunk).await {
1661 if n == 0 {
1662 return;
1663 }
1664 }
1665 return;
1666 }
1667 }
1668 }
1669
1670 #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1671 #[cfg_attr(miri, ignore)] async fn stalled_body_fails_the_get() {
1673 let server = FakeS3::start(true).await;
1674 let blob = server.open().await;
1675 let res = tokio::time::timeout(Duration::from_secs(60), blob.get("target"))
1678 .await
1679 .expect("a stalled body must fail the get, not hang it");
1680 assert!(res.is_err(), "unexpected success");
1681 }
1682
1683 #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1684 #[cfg_attr(miri, ignore)] async fn hedge_rescues_stalled_body() {
1686 let primary_server = FakeS3::start(true).await;
1687 let sibling_server = FakeS3::start(false).await;
1688 let primary: Arc<dyn Blob> = Arc::new(primary_server.open().await);
1689 let sibling: Arc<dyn Blob> = Arc::new(sibling_server.open().await);
1690 let cfg = crate::cfg::all_dyn_configs(ConfigSet::default());
1691 let mut updates = ConfigUpdates::default();
1692 updates.add(&BLOB_HEDGED_GET_ENABLED, true);
1693 updates.apply(&cfg);
1694 let metrics = BlobHedgeMetrics::new(&MetricsRegistry::new());
1695 let blob = HedgedBlob::new(
1696 primary,
1697 HedgeSibling::Isolated(sibling),
1698 Arc::new(cfg),
1699 metrics.clone(),
1700 );
1701 let res = tokio::time::timeout(Duration::from_secs(60), blob.get("target"))
1702 .await
1703 .expect("the hedge must rescue the get");
1704 let bytes = res.expect("get").expect("blob exists");
1705 assert_eq!(bytes.len(), BODY_LEN);
1706 assert_eq!(metrics.won.get(), 1);
1707 }
1708 }
1709}