Skip to main content

mz_persist/
s3.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! An S3 implementation of [Blob] storage.
11
12use 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/// Configuration for opening an [S3Blob].
49///
50/// NOTE: cloning shares the underlying `S3Client` and therefore its HTTP
51/// connection pool. Connection-pool isolation (as hedged gets require, see
52/// [crate::hedge]) needs a fresh [S3BlobConfig::new].
53#[derive(Clone, Debug)]
54pub struct S3BlobConfig {
55    metrics: S3BlobMetrics,
56    client: S3Client,
57    bucket: String,
58    prefix: String,
59}
60
61/// Which S3 endpoints get [`mz_aws_util::pooled_http_client`], one of the
62/// [`BlobHttpPool`] values.
63///
64/// Read on every request, so a change takes effect without reopening the blob.
65pub 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
73/// The `max_in_flight` that [`BLOB_HTTP_POOL`]'s pool passes to
74/// [`mz_aws_util::pooled_http_client`].
75///
76/// The default matches the `MAX_CONCURRENT_STREAMS: 100` that GCS advertised in its HTTP/2
77/// settings, as observed in October 2026 with
78/// `curl -v --trace-config http/2 --http2 https://storage.googleapis.com/`.
79pub 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/// The values of [`BLOB_HTTP_POOL`].
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum BlobHttpPool {
90    /// No endpoint uses the pool.
91    Off,
92    /// Only GCS endpoints use the pool.
93    Gcs,
94    /// Every endpoint uses the pool.
95    On,
96}
97
98impl BlobHttpPool {
99    const ALL: [Self; 3] = [Self::Off, Self::Gcs, Self::On];
100
101    /// The value [`BLOB_HTTP_POOL`] defaults to.
102    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    /// Falls back to the default if `s` is unrecognized.
115    ///
116    /// Silent: this runs per request, so a typo would log on every request.
117    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    /// Reads and parses [`BLOB_HTTP_POOL`].
125    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
138// There is no simple way to hook into the S3 client to capture when its various timeouts
139// are hit. Instead, we pass along marker values that inform our [MetricsSleep] impl which
140// type of timeout was requested so it can substitute in a dynamic value set by config
141// from the caller.
142const 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        // the sleep future we return here will only be polled to
176        // completion if its corresponding http request to S3 times
177        // out, meaning we can chain incrementing the appropriate
178        // timeout counter to when it finishes
179        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    /// Returns a new [S3BlobConfig] for use in production.
192    ///
193    /// Stores objects in the given bucket prepended with the (possibly empty)
194    /// prefix. S3 credentials and region must be available in the process or
195    /// environment.
196    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        // NB: we must always use the custom sleep impl if we use the timeout marker values
251        loader = loader.sleep_impl(MetricsSleep {
252            knobs,
253            metrics: metrics.clone(),
254        });
255        loader = loader.timeout_config(
256            TimeoutConfig::builder()
257                // maximum time allowed for a top-level S3 API call (including internal retries)
258                .operation_timeout(OPERATION_TIMEOUT_MARKER)
259                // maximum time allowed for a single network call
260                .operation_attempt_timeout(OPERATION_ATTEMPT_TIMEOUT_MARKER)
261                // maximum time until a connection succeeds
262                .connect_timeout(CONNECT_TIMEOUT_MARKER)
263                // maximum time to read the first byte of a response
264                .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    /// Returns a new [S3BlobConfig] for use in unit tests.
278    ///
279    /// By default, persist tests that use external storage (like s3) are
280    /// no-ops, so that `cargo test` does the right thing without any
281    /// configuration. To activate the tests, set the
282    /// `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET` environment variable and
283    /// ensure you have valid AWS credentials available in a location where the
284    /// AWS Rust SDK can discovery them.
285    ///
286    /// This intentionally uses the `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET`
287    /// env as the switch for test no-op-ness instead of the presence of a valid
288    /// AWS authentication configuration envs because a developers might have
289    /// valid credentials present and this isn't an explicit enough signal from
290    /// a developer running `cargo test` that it's okay to use these
291    /// credentials. It also intentionally does not use the local drop-in s3
292    /// replacement to keep persist unit tests light.
293    ///
294    /// On CI, these tests are enabled by adding the scratch-aws-access plugin
295    /// to the `cargo-test` step in `ci/test/pipeline.template.yml` and setting
296    /// `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET` in
297    /// `ci/test/cargo-test/mzcompose.py`.
298    ///
299    /// For a Materialize developer, to opt in to these tests locally for
300    /// development, follow the AWS access guide:
301    ///
302    /// ```text
303    /// https://github.com/MaterializeInc/i2/blob/main/doc/aws-access.md
304    /// ```
305    ///
306    /// then running `source src/persist/s3_test_env_mz.sh`. You will also have
307    /// to run `aws sso login` if you haven't recently.
308    ///
309    /// Non-Materialize developers will have to set up their own auto-deleting
310    /// bucket and export the same env vars that s3_test_env_mz.sh does.
311    ///
312    /// Only public for use in src/benches.
313    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        // Give each test a unique prefix so they don't conflict. We don't have
353        // to worry about deleting any data that we create because the bucket is
354        // set to auto-delete after 1 day.
355        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    /// Replaces the HTTP client that [S3Blob] sends requests through, keeping
373    /// every other setting. For benchmarking alternative HTTP clients.
374    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    /// Returns a clone of Self with a new v4 uuid prefix.
389    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/// Implementation of [Blob] backed by S3.
397#[derive(Debug)]
398pub struct S3Blob {
399    metrics: S3BlobMetrics,
400    client: S3Client,
401    bucket: String,
402    prefix: String,
403    // Maximum number of keys we get information about per list-objects request.
404    //
405    // Defaults to 1000 which is the current AWS max.
406    max_keys: i32,
407    multipart_config: MultipartConfig,
408}
409
410impl S3Blob {
411    /// Opens the given location for non-exclusive read-write access.
412    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        // Connect before returning success. We don't particularly care about
422        // what's stored in this blob (nothing writes to it, so presumably it's
423        // empty) just that we were able and allowed to fetch it.
424        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        // S3 advises that it's fastest to download large objects along the part
440        // boundaries they were originally uploaded with [1].
441        //
442        // [1]: https://docs.aws.amazon.com/whitepapers/latest/s3-optimizing-performance-best-practices/use-byte-range-fetches.html
443        //
444        // One option is to run the same logic as multipart does and do the
445        // requests using the resulting byte ranges, but if we ever changed the
446        // multipart chunking logic, they wouldn't line up for old blobs written
447        // by a previous version.
448        //
449        // Another option is to store the part boundaries in the metadata we
450        // keep about the batch, but this would be large and wasteful.
451        //
452        // Luckily, s3 exposes a part_number param on GetObject requests that we
453        // can use. If an object was created with multipart, it allows
454        // requesting each part as they were originally uploaded by the part
455        // number index. With this, we can simply send off requests for part
456        // number 1..=num_parts and reassemble the results.
457        //
458        // We could roundtrip the number of parts through persist batch
459        // metadata, but with some cleverness, we can avoid even this. Turns
460        // out, if multipart upload wasn't used (it was just a normal PutObject
461        // request), s3 will still happily return it for a request specifying a
462        // part_number of 1. This lets us fire off a first request, which
463        // contains the metadata we need to determine how many additional parts
464        // we need, if any.
465        //
466        // So, the following call sends this first request. The SDK even returns
467        // the headers before the full data body has completed. This gives us
468        // the number of parts. We can then proceed to fetch the body of the
469        // first request concurrently with the rest of the parts of the object.
470        //
471        // Not every S3-compatible store reports the part count. Its response
472        // to the first request is then indistinguishable from a single-part
473        // object's, except that `Content-Range` still carries the object's
474        // total size. The remainder is fetched by byte range in that case, and
475        // the reassembled length is checked against the total either way, so a
476        // store that misbehaves fails the get instead of handing the decoder a
477        // truncated blob.
478
479        // For each header and body that we fetch, we track the fastest, and
480        // any large deviations from it.
481        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        // Fetch our first header, this tells us how many more are left.
486        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        // The object's total size comes from `Content-Range`, which s3 returns
508        // for any request that names a part. Its denominator is the length of
509        // the whole object, not of the part that was asked for, which is what
510        // makes it usable as the completeness check below. A store that put
511        // the part's length there instead would fail every multipart get.
512        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            // For a non-multipart upload, parts_count will be None, and the
521            // first request already returned the whole object. A store that
522            // does not report the count looks the same, except that the first
523            // part falls short of the total. Its remainder is fetched by byte
524            // range in chunks of the first part's size, which is the size the
525            // upload used for every part but the last.
526            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            // A non-positive value is invalid.
537            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            // Clone a handle to our MinElapsed trackers so we can give one to
555            // each download task.
556            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                    // Fetched above, together with the headers that shaped
563                    // the remaining requests.
564                    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                // Request the body.
585                let body_start = Instant::now();
586
587                // Coalesce all hyper chunks for this part into a single contiguous
588                // allocation. Pushing each SDK `Bytes` chunk separately into
589                // `SegmentedBytes` yields hundreds of segments per blob, which makes
590                // every parquet `ChunkReader::get_bytes` call O(N) and dominates CPU
591                // in `SegmentedBytes::advance`/`get_bytes` during decode. Copying
592                // also releases the hyper pool buffer so it doesn't stay pinned for
593                // the lifetime of the blob.
594                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        // Await on all of our parts requests.
626        let mut segments = vec![];
627        while let Some(result) = body_futures.next().await {
628            // Download failure, we failed to fetch the body from S3.
629            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            // Collect all of our segments.
639            segments.append(&mut part_body);
640        }
641
642        // A store that reports neither the part count nor a range it honors
643        // hands back less than the object. Fail the get here rather than let
644        // the decoder find out. This runs for every store, not just the ones
645        // needing the byte-range fallback, so any short read becomes a
646        // retryable error instead of a corrupt blob.
647        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        // we only want to return keys that match the specified blob key prefix
673        let blob_key_prefix = self.get_path(key_prefix);
674        // but we want to exclude the shared root prefix from our returned keys,
675        // so only the blob key itself is passed in to `f`
676        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        // There is a race condition here where, if two delete calls for the
743        // same key occur simultaneously, both might think they did the actual
744        // deletion. This return value is only used for metrics, so it's
745        // unfortunate, but fine.
746        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        // Fetch the latest version of the object. If it's a normal version, return true;
790        // if it's a delete marker, delete it and loop; if there is no such version,
791        // return false.
792        // TODO: limit the number of delete markers we'll peel back?
793        loop {
794            // S3 only lets us fetch the versions of an object with a list requests.
795            // Seems a bit wasteful to just fetch one at a time, but otherwise we can only
796            // guess the order of versions via the timestamp, and that feels brittle.
797            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                    // We need to check that any versions we're looking at have the right key,
813                    // not just a key with our key as a prefix.
814                    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    // TODO(benesch): remove this once this function no longer makes use of
880    // potentially dangerous `as` conversions.
881    #[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        // Start the multi part request and get an upload id.
887        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                // By default the Rust AWS SDK does not set the Content-Length
897                // header on POST calls with empty bodies. This is fine for S3,
898                // but when running against GCS's S3 interop mode these calls
899                // will be rejected unless we set this header manually.
900                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        // Fire off all the individual parts.
918        //
919        // TODO: The aws cli throttles how many of these are outstanding at any
920        // given point. We'll likely want to do the same at some point.
921        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            // NB: Without this spawn, these will execute serially. This is rust
925            // async 101 stuff, but there isn't much async in the persist
926            // codebase (yet?) so I thought it worth calling out.
927            let part_span = debug_span!("s3set_multi_part", payload_len = part_range.len());
928            let part_fut = async_runtime.spawn_named(
929                // TODO: Add the key and part number once this can be annotated
930                // with metadata.
931                || "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        // Wait on all the parts to finish. This is done in part order, no need
951        // for joining them in the order they finish.
952        //
953        // TODO: Consider using something like futures::future::join_all() for
954        // this. That would cancel outstanding requests for us if any of them
955        // fails. However, it might not play well with using retries for tail
956        // latencies. Investigate.
957        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        // Complete the upload.
986        //
987        // Currently, we early return if any of the individual parts fail. This
988        // permanently orphans any parts that succeeded. One fix is to call
989        // abort_multipart_upload, which deletes them. However, there's also an
990        // option for an s3 bucket to auto-delete parts that haven't been
991        // completed or aborted after a given amount of time. This latter is
992        // simpler and also resilient to ill-timed mz restarts, so we use it for
993        // now. We could likely add the accounting necessary to make
994        // abort_multipart_upload work, but it would be complex and affect perf.
995        // Let's see how far we can get without it.
996        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            // There is some overlap with MetricsSleep. MetricsSleep is more granular
1058            // but does not contain the operation.
1059            SdkError::TimeoutError(_) => "TimeoutError",
1060            // an error was added at some point in the future
1061            _ => "UnknownSdkError",
1062        };
1063        self.metrics
1064            .error_counts
1065            .with_label_values(&[op, code])
1066            .inc();
1067    }
1068}
1069
1070/// One request of a multi-request `get`.
1071enum PartRequest {
1072    /// The first part, already fetched to learn the object's shape.
1073    First(GetObjectOutput),
1074    /// A piece of the object still to fetch.
1075    Fetch(PartSelector),
1076}
1077
1078/// How a `GetObject` request names the piece of the object it wants.
1079enum PartSelector {
1080    /// A part by the number it was uploaded with.
1081    Number(i32),
1082    /// A byte range, for stores that do not report the part count.
1083    Range(Range<u64>),
1084}
1085
1086/// Whether `endpoint`'s host is `googleapis.com` or one of its subdomains, which
1087/// covers GCS's global, regional (`storage.<region>.rep.googleapis.com`) and
1088/// Private Service Connect (`*.p.googleapis.com`) endpoints.
1089fn 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
1100/// The total size in a `Content-Range` header (`bytes 0-8388607/9711660`), if
1101/// the header states one.
1102fn parse_content_range_total(content_range: &str) -> Option<u64> {
1103    content_range.rsplit_once('/')?.1.trim().parse().ok()
1104}
1105
1106/// Byte ranges covering `[first_len, total_len)` in chunks of `first_len`.
1107///
1108/// A store reporting a zero-length first part leaves no chunk size to reuse,
1109/// so the whole object is fetched in a single range instead.
1110fn 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    /// The minimum object size for which we start using multipart upload.
1142    ///
1143    /// From the official `aws cli` tool implementation:
1144    ///
1145    /// <https://github.com/aws/aws-cli/blob/2.4.14/awscli/customizations/s3/transferconfig.py#L18-L29>
1146    const DEFAULT_MULTIPART_THRESHOLD: usize = 8 * MB;
1147    /// The size of each part (except the last) in a multipart upload.
1148    ///
1149    /// From the official `aws cli` tool implementation:
1150    ///
1151    /// <https://github.com/aws/aws-cli/blob/2.4.14/awscli/customizations/s3/transferconfig.py#L18-L29>
1152    const DEFAULT_MULTIPART_CHUNK_SIZE: usize = 8 * MB;
1153
1154    /// The largest size object creatable in S3.
1155    ///
1156    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1157    const MAX_SINGLE_UPLOAD_SIZE: usize = 5 * TB;
1158    /// The minimum size of a part in a multipart upload.
1159    ///
1160    /// This minimum doesn't apply to the last chunk, which can be any size.
1161    ///
1162    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1163    const MIN_UPLOAD_CHUNK_SIZE: usize = 5 * MB;
1164    /// The smallest allowable part number (inclusive).
1165    ///
1166    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1167    const MIN_PART_NUM: u32 = 1;
1168    /// The largest allowable part number (inclusive).
1169    ///
1170    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1171    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        // Compute the minimum part size we can use without going over the max
1204        // number of parts that S3 allows: `ceil(blob_len / max_parts)`.This
1205        // will end up getting thrown away by the `cmp::max` for anything
1206        // smaller than `max_parts * default_part_size = 80GiB`.
1207        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        // Part nums are 1-indexed in S3. Convert back to 0-indexed to make the
1211        // range math easier to follow.
1212        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/// A helper for tracking the minimum of a set of Durations.
1239#[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        // Possibly set a new minimum.
1260        let prev_min = self.min.fetch_min(nanos, atomic::Ordering::SeqCst);
1261
1262        // Trace if our provided duration was much larger than our minimum.
1263        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// Make sure the "vendored" feature of the openssl_sys crate makes it into the
1275// transitive dep graph of persist, so that we don't attempt to link against the
1276// system OpenSSL library. Fake a usage of the crate here so that a good
1277// samaritan doesn't remove our unused dep.
1278#[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)] // https://github.com/MaterializeInc/database-issues/issues/5586
1350    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1351    #[ignore] // TODO: Reenable against minio so it can run locally
1352    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        // Also specifically test multipart. S3 requires all parts but the last
1383        // to be at least 5MB, which we don't want to do from a test, so this
1384        // uses the multipart code path but only writes a single part.
1385        {
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    /// Runs the conformance suite through [crate::hedge::HedgedBlob] with two
1398    /// genuinely independent S3 clients (separate connection pools) pointed
1399    /// at the same bucket/prefix, a hedge racing on every get. Ignored by
1400    /// default like `s3_blob` above. When run against the external test
1401    /// bucket, it is the one exercise of the real pool-isolation path.
1402    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1403    #[cfg_attr(coverage, ignore)] // https://github.com/MaterializeInc/database-issues/issues/5586
1404    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1405    #[ignore] // TODO: Reenable against minio so it can run locally
1406    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        // A second client with its own connection pool. Its generated prefix
1419        // is discarded below: both sides must point at the same store.
1420        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    /// None of the SDK timeouts covers a response body, so a body that stops
1525    /// arriving is bounded only by the SDK's stalled-stream protection (on by
1526    /// default for downloads). The hedged-gets design relies on it, so these
1527    /// tests pin it against a fake S3 endpoint.
1528    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        /// The production defaults of the `persist_blob_*_timeout` configs.
1545        #[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        /// A fake S3 endpoint on 127.0.0.1. A GET of the key `target` gets a
1567        /// response head promising `BODY_LEN` bytes, then the body, or, if
1568        /// `stall` is set, no body byte at all while the connection stays
1569        /// open. Every other key gets a 404 NoSuchKey, which is what the health
1570        /// check in `S3Blob::open` expects.
1571        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                    // Dropping this task aborts every connection handler.
1582                    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                    // Hold the connection open until the client closes it.
1659                    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)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1672        async fn stalled_body_fails_the_get() {
1673            let server = FakeS3::start(true).await;
1674            let blob = server.open().await;
1675            // The protection fires after about 6 s without a byte. The
1676            // generous timeout only catches a get that hangs.
1677            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)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1685        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}