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::primitives::ByteStream;
28use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
29use aws_types::region::Region;
30use bytes::{Bytes, BytesMut};
31use futures_util::stream::FuturesOrdered;
32use futures_util::{FutureExt, StreamExt};
33use mz_ore::bytes::SegmentedBytes;
34use mz_ore::cast::CastFrom;
35use mz_ore::metrics::MetricsRegistry;
36use mz_ore::task::RuntimeExt;
37use tokio::runtime::Handle as AsyncHandle;
38use tracing::{Instrument, debug, debug_span, trace, trace_span};
39use uuid::Uuid;
40
41use crate::cfg::BlobKnobs;
42use crate::error::Error;
43use crate::location::{Blob, BlobMetadata, Determinate, ExternalError};
44use crate::metrics::S3BlobMetrics;
45
46/// Configuration for opening an [S3Blob].
47///
48/// NOTE: cloning shares the underlying `S3Client` and therefore its HTTP
49/// connection pool. Connection-pool isolation (as hedged gets require, see
50/// [crate::hedge]) needs a fresh [S3BlobConfig::new].
51#[derive(Clone, Debug)]
52pub struct S3BlobConfig {
53    metrics: S3BlobMetrics,
54    client: S3Client,
55    bucket: String,
56    prefix: String,
57}
58
59// There is no simple way to hook into the S3 client to capture when its various timeouts
60// are hit. Instead, we pass along marker values that inform our [MetricsSleep] impl which
61// type of timeout was requested so it can substitute in a dynamic value set by config
62// from the caller.
63const OPERATION_TIMEOUT_MARKER: Duration = Duration::new(111, 1111);
64const OPERATION_ATTEMPT_TIMEOUT_MARKER: Duration = Duration::new(222, 2222);
65const CONNECT_TIMEOUT_MARKER: Duration = Duration::new(333, 3333);
66const READ_TIMEOUT_MARKER: Duration = Duration::new(444, 4444);
67
68#[derive(Debug)]
69struct MetricsSleep {
70    knobs: Box<dyn BlobKnobs>,
71    metrics: S3BlobMetrics,
72}
73
74impl AsyncSleep for MetricsSleep {
75    fn sleep(&self, duration: Duration) -> Sleep {
76        let (duration, metric) = match duration {
77            OPERATION_TIMEOUT_MARKER => (
78                self.knobs.operation_timeout(),
79                Some(self.metrics.operation_timeouts.clone()),
80            ),
81            OPERATION_ATTEMPT_TIMEOUT_MARKER => (
82                self.knobs.operation_attempt_timeout(),
83                Some(self.metrics.operation_attempt_timeouts.clone()),
84            ),
85            CONNECT_TIMEOUT_MARKER => (
86                self.knobs.connect_timeout(),
87                Some(self.metrics.connect_timeouts.clone()),
88            ),
89            READ_TIMEOUT_MARKER => (
90                self.knobs.read_timeout(),
91                Some(self.metrics.read_timeouts.clone()),
92            ),
93            duration => (duration, None),
94        };
95
96        // the sleep future we return here will only be polled to
97        // completion if its corresponding http request to S3 times
98        // out, meaning we can chain incrementing the appropriate
99        // timeout counter to when it finishes
100        Sleep::new(tokio::time::sleep(duration).map(|x| {
101            if let Some(counter) = metric {
102                counter.inc();
103            }
104            x
105        }))
106    }
107}
108
109impl S3BlobConfig {
110    const EXTERNAL_TESTS_S3_BUCKET: &'static str = "MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET";
111
112    /// Returns a new [S3BlobConfig] for use in production.
113    ///
114    /// Stores objects in the given bucket prepended with the (possibly empty)
115    /// prefix. S3 credentials and region must be available in the process or
116    /// environment.
117    pub async fn new(
118        bucket: String,
119        prefix: String,
120        role_arn: Option<String>,
121        endpoint: Option<String>,
122        region: Option<String>,
123        credentials: Option<(String, String)>,
124        knobs: Box<dyn BlobKnobs>,
125        metrics: S3BlobMetrics,
126    ) -> Result<Self, Error> {
127        let mut loader = mz_aws_util::defaults();
128
129        if let Some(region) = region {
130            loader = loader.region(Region::new(region));
131        };
132
133        if let Some(role_arn) = role_arn {
134            let assume_role_sdk_config = mz_aws_util::defaults().load().await;
135            let role_provider = AssumeRoleProvider::builder(role_arn)
136                .configure(&assume_role_sdk_config)
137                .session_name("persist")
138                .build()
139                .await;
140            loader = loader.credentials_provider(role_provider);
141        }
142
143        if let Some((access_key_id, secret_access_key)) = credentials {
144            loader = loader.credentials_provider(Credentials::from_keys(
145                access_key_id,
146                secret_access_key,
147                None,
148            ));
149        }
150
151        if let Some(endpoint) = endpoint {
152            loader = loader.endpoint_url(endpoint)
153        }
154
155        // NB: we must always use the custom sleep impl if we use the timeout marker values
156        loader = loader.sleep_impl(MetricsSleep {
157            knobs,
158            metrics: metrics.clone(),
159        });
160        loader = loader.timeout_config(
161            TimeoutConfig::builder()
162                // maximum time allowed for a top-level S3 API call (including internal retries)
163                .operation_timeout(OPERATION_TIMEOUT_MARKER)
164                // maximum time allowed for a single network call
165                .operation_attempt_timeout(OPERATION_ATTEMPT_TIMEOUT_MARKER)
166                // maximum time until a connection succeeds
167                .connect_timeout(CONNECT_TIMEOUT_MARKER)
168                // maximum time to read the first byte of a response
169                .read_timeout(READ_TIMEOUT_MARKER)
170                .build(),
171        );
172
173        let client = mz_aws_util::s3::new_client(&loader.load().await);
174        Ok(S3BlobConfig {
175            metrics,
176            client,
177            bucket,
178            prefix,
179        })
180    }
181
182    /// Returns a new [S3BlobConfig] for use in unit tests.
183    ///
184    /// By default, persist tests that use external storage (like s3) are
185    /// no-ops, so that `cargo test` does the right thing without any
186    /// configuration. To activate the tests, set the
187    /// `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET` environment variable and
188    /// ensure you have valid AWS credentials available in a location where the
189    /// AWS Rust SDK can discovery them.
190    ///
191    /// This intentionally uses the `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET`
192    /// env as the switch for test no-op-ness instead of the presence of a valid
193    /// AWS authentication configuration envs because a developers might have
194    /// valid credentials present and this isn't an explicit enough signal from
195    /// a developer running `cargo test` that it's okay to use these
196    /// credentials. It also intentionally does not use the local drop-in s3
197    /// replacement to keep persist unit tests light.
198    ///
199    /// On CI, these tests are enabled by adding the scratch-aws-access plugin
200    /// to the `cargo-test` step in `ci/test/pipeline.template.yml` and setting
201    /// `MZ_PERSIST_EXTERNAL_STORAGE_TEST_S3_BUCKET` in
202    /// `ci/test/cargo-test/mzcompose.py`.
203    ///
204    /// For a Materialize developer, to opt in to these tests locally for
205    /// development, follow the AWS access guide:
206    ///
207    /// ```text
208    /// https://github.com/MaterializeInc/i2/blob/main/doc/aws-access.md
209    /// ```
210    ///
211    /// then running `source src/persist/s3_test_env_mz.sh`. You will also have
212    /// to run `aws sso login` if you haven't recently.
213    ///
214    /// Non-Materialize developers will have to set up their own auto-deleting
215    /// bucket and export the same env vars that s3_test_env_mz.sh does.
216    ///
217    /// Only public for use in src/benches.
218    pub async fn new_for_test() -> Result<Option<Self>, Error> {
219        let bucket = match std::env::var(Self::EXTERNAL_TESTS_S3_BUCKET) {
220            Ok(bucket) => bucket,
221            Err(_) => {
222                if mz_ore::env::is_var_truthy("CI") {
223                    panic!("CI is supposed to run this test but something has gone wrong!");
224                }
225                return Ok(None);
226            }
227        };
228
229        struct TestBlobKnobs;
230        impl std::fmt::Debug for TestBlobKnobs {
231            fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
232                f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
233            }
234        }
235        impl BlobKnobs for TestBlobKnobs {
236            fn operation_timeout(&self) -> Duration {
237                OPERATION_TIMEOUT_MARKER
238            }
239
240            fn operation_attempt_timeout(&self) -> Duration {
241                OPERATION_ATTEMPT_TIMEOUT_MARKER
242            }
243
244            fn connect_timeout(&self) -> Duration {
245                CONNECT_TIMEOUT_MARKER
246            }
247
248            fn read_timeout(&self) -> Duration {
249                READ_TIMEOUT_MARKER
250            }
251
252            fn is_cc_active(&self) -> bool {
253                false
254            }
255        }
256
257        // Give each test a unique prefix so they don't conflict. We don't have
258        // to worry about deleting any data that we create because the bucket is
259        // set to auto-delete after 1 day.
260        let prefix = Uuid::new_v4().to_string();
261        let role_arn = None;
262        let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
263        let config = S3BlobConfig::new(
264            bucket,
265            prefix,
266            role_arn,
267            None,
268            None,
269            None,
270            Box::new(TestBlobKnobs),
271            metrics,
272        )
273        .await?;
274        Ok(Some(config))
275    }
276
277    /// Returns a clone of Self with a new v4 uuid prefix.
278    pub fn clone_with_new_uuid_prefix(&self) -> Self {
279        let mut ret = self.clone();
280        ret.prefix = Uuid::new_v4().to_string();
281        ret
282    }
283}
284
285/// Implementation of [Blob] backed by S3.
286#[derive(Debug)]
287pub struct S3Blob {
288    metrics: S3BlobMetrics,
289    client: S3Client,
290    bucket: String,
291    prefix: String,
292    // Maximum number of keys we get information about per list-objects request.
293    //
294    // Defaults to 1000 which is the current AWS max.
295    max_keys: i32,
296    multipart_config: MultipartConfig,
297}
298
299impl S3Blob {
300    /// Opens the given location for non-exclusive read-write access.
301    pub async fn open(config: S3BlobConfig) -> Result<Self, ExternalError> {
302        let ret = S3Blob {
303            metrics: config.metrics,
304            client: config.client,
305            bucket: config.bucket,
306            prefix: config.prefix,
307            max_keys: 1_000,
308            multipart_config: MultipartConfig::default(),
309        };
310        // Connect before returning success. We don't particularly care about
311        // what's stored in this blob (nothing writes to it, so presumably it's
312        // empty) just that we were able and allowed to fetch it.
313        let _ = ret.get("HEALTH_CHECK").await?;
314        Ok(ret)
315    }
316
317    fn get_path(&self, key: &str) -> String {
318        format!("{}/{}", self.prefix, key)
319    }
320}
321
322#[async_trait]
323impl Blob for S3Blob {
324    async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
325        let start_overall = Instant::now();
326        let path = self.get_path(key);
327
328        // S3 advises that it's fastest to download large objects along the part
329        // boundaries they were originally uploaded with [1].
330        //
331        // [1]: https://docs.aws.amazon.com/whitepapers/latest/s3-optimizing-performance-best-practices/use-byte-range-fetches.html
332        //
333        // One option is to run the same logic as multipart does and do the
334        // requests using the resulting byte ranges, but if we ever changed the
335        // multipart chunking logic, they wouldn't line up for old blobs written
336        // by a previous version.
337        //
338        // Another option is to store the part boundaries in the metadata we
339        // keep about the batch, but this would be large and wasteful.
340        //
341        // Luckily, s3 exposes a part_number param on GetObject requests that we
342        // can use. If an object was created with multipart, it allows
343        // requesting each part as they were originally uploaded by the part
344        // number index. With this, we can simply send off requests for part
345        // number 1..=num_parts and reassemble the results.
346        //
347        // We could roundtrip the number of parts through persist batch
348        // metadata, but with some cleverness, we can avoid even this. Turns
349        // out, if multipart upload wasn't used (it was just a normal PutObject
350        // request), s3 will still happily return it for a request specifying a
351        // part_number of 1. This lets us fire off a first request, which
352        // contains the metadata we need to determine how many additional parts
353        // we need, if any.
354        //
355        // So, the following call sends this first request. The SDK even returns
356        // the headers before the full data body has completed. This gives us
357        // the number of parts. We can then proceed to fetch the body of the
358        // first request concurrently with the rest of the parts of the object.
359
360        // For each header and body that we fetch, we track the fastest, and
361        // any large deviations from it.
362        let min_body_elapsed = Arc::new(MinElapsed::default());
363        let min_header_elapsed = Arc::new(MinElapsed::default());
364        self.metrics.get_part.inc();
365
366        // Fetch our first header, this tells us how many more are left.
367        let header_start = Instant::now();
368        let object = self
369            .client
370            .get_object()
371            .bucket(&self.bucket)
372            .key(&path)
373            .part_number(1)
374            .send()
375            .await;
376        let elapsed = header_start.elapsed();
377        min_header_elapsed.observe(elapsed, "s3 download first part header");
378
379        let first_part = match object {
380            Ok(object) => object,
381            Err(SdkError::ServiceError(err)) if err.err().is_no_such_key() => return Ok(None),
382            Err(err) => {
383                self.update_error_metrics("GetObject", &err);
384                Err(anyhow!(err).context("s3 get meta err"))?
385            }
386        };
387
388        // Get the remaining number of parts
389        let num_parts = match first_part.parts_count() {
390            // For a non-multipart upload, parts_count will be None. The rest of  the code works
391            // perfectly well if we just pretend this was a multipart upload of 1 part.
392            None => 1,
393            // For any positive value greater than 0, just return it.
394            Some(parts @ 1..) => parts,
395            // A non-positive value is invalid.
396            Some(bad) => {
397                assert!(bad <= 0);
398                return Err(anyhow!("unexpected number of s3 object parts: {}", bad).into());
399            }
400        };
401
402        trace!(
403            "s3 download first header took {:?} ({num_parts} parts)",
404            start_overall.elapsed(),
405        );
406
407        let mut body_futures = FuturesOrdered::new();
408        let mut first_part = Some(first_part);
409
410        // Fetch the headers of the rest of the parts. (Starting at part 2 because we already
411        // did part 1.)
412        for part_num in 1..=num_parts {
413            // Clone a handle to our MinElapsed trackers so we can give one to
414            // each download task.
415            let min_header_elapsed = Arc::clone(&min_header_elapsed);
416            let min_body_elapsed = Arc::clone(&min_body_elapsed);
417            let get_invalid_resp = self.metrics.get_invalid_resp.clone();
418            let first_part = first_part.take();
419            let path = &path;
420            let request_future = async move {
421                // Fetch the headers of the rest of the parts. (Using the existing headers
422                // for part 1.
423                let mut object = match first_part {
424                    Some(first_part) => {
425                        assert_eq!(part_num, 1, "only the first part should be prefetched");
426                        first_part
427                    }
428                    None => {
429                        assert_ne!(part_num, 1, "first part should be prefetched");
430                        // Request our headers.
431                        let header_start = Instant::now();
432                        let object = self
433                            .client
434                            .get_object()
435                            .bucket(&self.bucket)
436                            .key(path)
437                            .part_number(part_num)
438                            .send()
439                            .await
440                            .inspect_err(|err| self.update_error_metrics("GetObject", err))
441                            .context("s3 get meta err")?;
442                        min_header_elapsed
443                            .observe(header_start.elapsed(), "s3 download part header");
444                        object
445                    }
446                };
447
448                // Request the body.
449                let body_start = Instant::now();
450
451                // Coalesce all hyper chunks for this part into a single contiguous
452                // allocation. Pushing each SDK `Bytes` chunk separately into
453                // `SegmentedBytes` yields hundreds of segments per blob, which makes
454                // every parquet `ChunkReader::get_bytes` call O(N) and dominates CPU
455                // in `SegmentedBytes::advance`/`get_bytes` during decode. Copying
456                // also releases the hyper pool buffer so it doesn't stay pinned for
457                // the lifetime of the blob.
458                let mut buf = match object.content_length() {
459                    Some(len @ 1..) => BytesMut::with_capacity(usize::cast_from(
460                        u64::try_from(len).expect("positive integer"),
461                    )),
462                    Some(len @ ..=-1) => {
463                        tracing::trace!(?len, "found invalid content-length");
464                        get_invalid_resp.inc();
465                        BytesMut::new()
466                    }
467                    Some(0) | None => BytesMut::new(),
468                };
469
470                while let Some(data) = object.body.next().await {
471                    let data = data.context("s3 get body err")?;
472                    buf.extend_from_slice(&data);
473                }
474
475                let body_elapsed = body_start.elapsed();
476                min_body_elapsed.observe(body_elapsed, "s3 download part body");
477
478                let body_parts = if buf.is_empty() {
479                    Vec::new()
480                } else {
481                    vec![buf.freeze()]
482                };
483                Ok::<_, anyhow::Error>(body_parts)
484            };
485
486            body_futures.push_back(request_future);
487        }
488
489        // Await on all of our parts requests.
490        let mut segments = vec![];
491        while let Some(result) = body_futures.next().await {
492            // Download failure, we failed to fetch the body from S3.
493            let mut part_body = result
494                .inspect_err(|e| {
495                    self.metrics
496                        .error_counts
497                        .with_label_values(&["GetObjectStream", e.to_string().as_str()])
498                        .inc()
499                })
500                .context("s3 get body err")?;
501
502            // Collect all of our segments.
503            segments.append(&mut part_body);
504        }
505
506        debug!(
507            "s3 GetObject took {:?} ({} parts)",
508            start_overall.elapsed(),
509            num_parts
510        );
511        Ok(Some(SegmentedBytes::from(segments)))
512    }
513
514    async fn list_keys_and_metadata(
515        &self,
516        key_prefix: &str,
517        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
518    ) -> Result<(), ExternalError> {
519        let mut continuation_token = None;
520        // we only want to return keys that match the specified blob key prefix
521        let blob_key_prefix = self.get_path(key_prefix);
522        // but we want to exclude the shared root prefix from our returned keys,
523        // so only the blob key itself is passed in to `f`
524        let strippable_root_prefix = format!("{}/", self.prefix);
525
526        loop {
527            self.metrics.list_objects.inc();
528            let resp = self
529                .client
530                .list_objects_v2()
531                .bucket(&self.bucket)
532                .prefix(&blob_key_prefix)
533                .max_keys(self.max_keys)
534                .set_continuation_token(continuation_token)
535                .send()
536                .await
537                .inspect_err(|err| self.update_error_metrics("ListObjectsV2", err))
538                .context("list bucket error")?;
539            if let Some(contents) = resp.contents {
540                for object in contents.iter() {
541                    if let Some(key) = object.key.as_ref() {
542                        if let Some(key) = key.strip_prefix(&strippable_root_prefix) {
543                            let size_in_bytes = match object.size {
544                                None => {
545                                    return Err(ExternalError::from(anyhow!(
546                                        "object missing size: {key}"
547                                    )));
548                                }
549                                Some(size) => size
550                                    .try_into()
551                                    .expect("file in S3 cannot have negative size"),
552                            };
553                            f(BlobMetadata { key, size_in_bytes });
554                        } else {
555                            return Err(ExternalError::from(anyhow!(
556                                "found key with invalid prefix: {}",
557                                key
558                            )));
559                        }
560                    }
561                }
562            }
563
564            if resp.next_continuation_token.is_some() {
565                continuation_token = resp.next_continuation_token;
566            } else {
567                break;
568            }
569        }
570
571        Ok(())
572    }
573
574    async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
575        let value_len = value.len();
576        if self
577            .multipart_config
578            .should_multipart(value_len)
579            .map_err(anyhow::Error::msg)?
580        {
581            self.set_multi_part(key, value)
582                .instrument(debug_span!("s3set_multi", payload_len = value_len))
583                .await
584        } else {
585            self.set_single_part(key, value).await
586        }
587    }
588
589    async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
590        // There is a race condition here where, if two delete calls for the
591        // same key occur simultaneously, both might think they did the actual
592        // deletion. This return value is only used for metrics, so it's
593        // unfortunate, but fine.
594        let path = self.get_path(key);
595        self.metrics.delete_head.inc();
596        let head_res = self
597            .client
598            .head_object()
599            .bucket(&self.bucket)
600            .key(&path)
601            .send()
602            .await;
603        let size_bytes = match head_res {
604            Ok(x) => match x.content_length {
605                None => {
606                    return Err(ExternalError::from(anyhow!(
607                        "s3 delete content length was none"
608                    )));
609                }
610                Some(content_length) => {
611                    u64::try_from(content_length).expect("file in S3 cannot have negative size")
612                }
613            },
614            Err(SdkError::ServiceError(err)) if err.err().is_not_found() => return Ok(None),
615            Err(err) => {
616                self.update_error_metrics("HeadObject", &err);
617                return Err(ExternalError::from(
618                    anyhow!(err).context("s3 delete head err"),
619                ));
620            }
621        };
622        self.metrics.delete_object.inc();
623        let _ = self
624            .client
625            .delete_object()
626            .bucket(&self.bucket)
627            .key(&path)
628            .send()
629            .await
630            .inspect_err(|err| self.update_error_metrics("DeleteObject", err))
631            .context("s3 delete object err")?;
632        Ok(Some(usize::cast_from(size_bytes)))
633    }
634
635    async fn restore(&self, key: &str) -> Result<(), ExternalError> {
636        let path = self.get_path(key);
637        // Fetch the latest version of the object. If it's a normal version, return true;
638        // if it's a delete marker, delete it and loop; if there is no such version,
639        // return false.
640        // TODO: limit the number of delete markers we'll peel back?
641        loop {
642            // S3 only lets us fetch the versions of an object with a list requests.
643            // Seems a bit wasteful to just fetch one at a time, but otherwise we can only
644            // guess the order of versions via the timestamp, and that feels brittle.
645            let list_res = self
646                .client
647                .list_object_versions()
648                .bucket(&self.bucket)
649                .prefix(&path)
650                .max_keys(1)
651                .send()
652                .await
653                .inspect_err(|err| self.update_error_metrics("ListObjectVersions", err))
654                .context("listing object versions during restore")?;
655
656            let current_delete = list_res
657                .delete_markers()
658                .into_iter()
659                .filter(|d| {
660                    // We need to check that any versions we're looking at have the right key,
661                    // not just a key with our key as a prefix.
662                    d.key() == Some(path.as_str())
663                })
664                .find(|d| d.is_latest().unwrap_or(false))
665                .and_then(|d| d.version_id());
666
667            if let Some(version) = current_delete {
668                let deleted = self
669                    .client
670                    .delete_object()
671                    .bucket(&self.bucket)
672                    .key(&path)
673                    .version_id(version)
674                    .send()
675                    .await
676                    .inspect_err(|err| self.update_error_metrics("DeleteObject", err))
677                    .context("deleting a delete marker")?;
678                assert!(
679                    deleted.delete_marker().unwrap_or(false),
680                    "deleting a delete marker"
681                );
682            } else {
683                let has_current_version = list_res
684                    .versions()
685                    .into_iter()
686                    .filter(|d| d.key() == Some(path.as_str()))
687                    .any(|v| v.is_latest().unwrap_or(false));
688
689                if !has_current_version {
690                    return Err(Determinate::new(anyhow!(
691                        "unable to restore {key} in s3: no valid version exists"
692                    ))
693                    .into());
694                }
695                return Ok(());
696            }
697        }
698    }
699}
700
701impl S3Blob {
702    async fn set_single_part(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
703        let start_overall = Instant::now();
704        let path = self.get_path(key);
705
706        let value_len = value.len();
707        let part_span = trace_span!("s3set_single", payload_len = value_len);
708        self.metrics.set_single.inc();
709        self.client
710            .put_object()
711            .bucket(&self.bucket)
712            .key(path)
713            .body(ByteStream::from(value))
714            .send()
715            .instrument(part_span)
716            .await
717            .inspect_err(|err| self.update_error_metrics("PutObject", err))
718            .context("set single part")?;
719        debug!(
720            "s3 PutObject single done {}b / {:?}",
721            value_len,
722            start_overall.elapsed()
723        );
724        Ok(())
725    }
726
727    // TODO(benesch): remove this once this function no longer makes use of
728    // potentially dangerous `as` conversions.
729    #[allow(clippy::as_conversions)]
730    async fn set_multi_part(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
731        let start_overall = Instant::now();
732        let path = self.get_path(key);
733
734        // Start the multi part request and get an upload id.
735        trace!("s3 PutObject multi start {}b", value.len());
736        self.metrics.set_multi_create.inc();
737        let upload_res = self
738            .client
739            .create_multipart_upload()
740            .bucket(&self.bucket)
741            .key(&path)
742            .customize()
743            .mutate_request(|req| {
744                // By default the Rust AWS SDK does not set the Content-Length
745                // header on POST calls with empty bodies. This is fine for S3,
746                // but when running against GCS's S3 interop mode these calls
747                // will be rejected unless we set this header manually.
748                req.headers_mut().insert("Content-Length", "0");
749            })
750            .send()
751            .instrument(debug_span!("s3set_multi_start"))
752            .await
753            .inspect_err(|err| self.update_error_metrics("CreateMultipartUpload", err))
754            .context("create_multipart_upload err")?;
755        let upload_id = upload_res
756            .upload_id()
757            .ok_or_else(|| anyhow!("create_multipart_upload response missing upload_id"))?;
758        trace!(
759            "s3 create_multipart_upload took {:?}",
760            start_overall.elapsed()
761        );
762
763        let async_runtime = AsyncHandle::try_current().map_err(anyhow::Error::new)?;
764
765        // Fire off all the individual parts.
766        //
767        // TODO: The aws cli throttles how many of these are outstanding at any
768        // given point. We'll likely want to do the same at some point.
769        let start_parts = Instant::now();
770        let mut part_futs = Vec::new();
771        for (part_num, part_range) in self.multipart_config.part_iter(value.len()) {
772            // NB: Without this spawn, these will execute serially. This is rust
773            // async 101 stuff, but there isn't much async in the persist
774            // codebase (yet?) so I thought it worth calling out.
775            let part_span = debug_span!("s3set_multi_part", payload_len = part_range.len());
776            let part_fut = async_runtime.spawn_named(
777                // TODO: Add the key and part number once this can be annotated
778                // with metadata.
779                || "persist_s3blob_put_part",
780                {
781                    self.metrics.set_multi_part.inc();
782                    self.client
783                        .upload_part()
784                        .bucket(&self.bucket)
785                        .key(&path)
786                        .upload_id(upload_id)
787                        .part_number(part_num as i32)
788                        .body(ByteStream::from(value.slice(part_range)))
789                        .send()
790                        .instrument(part_span)
791                        .map(move |res| (start_parts.elapsed(), res))
792                },
793            );
794            part_futs.push((part_num, part_fut));
795        }
796        let parts_len = part_futs.len();
797
798        // Wait on all the parts to finish. This is done in part order, no need
799        // for joining them in the order they finish.
800        //
801        // TODO: Consider using something like futures::future::join_all() for
802        // this. That would cancel outstanding requests for us if any of them
803        // fails. However, it might not play well with using retries for tail
804        // latencies. Investigate.
805        let min_part_elapsed = MinElapsed::default();
806        let mut parts = Vec::with_capacity(parts_len);
807        for (part_num, part_fut) in part_futs.into_iter() {
808            let (this_part_elapsed, part_res) = part_fut.await;
809            let part_res = part_res
810                .inspect_err(|err| self.update_error_metrics("UploadPart", err))
811                .context("s3 upload_part err")?;
812            let part_e_tag = part_res.e_tag().ok_or_else(|| {
813                self.metrics
814                    .error_counts
815                    .with_label_values(&["UploadPart", "MissingEtag"])
816                    .inc();
817                anyhow!("s3 upload part missing e_tag")
818            })?;
819            parts.push(
820                CompletedPart::builder()
821                    .e_tag(part_e_tag)
822                    .part_number(part_num as i32)
823                    .build(),
824            );
825            min_part_elapsed.observe(this_part_elapsed, "s3 upload_part took");
826        }
827        trace!(
828            "s3 upload_parts overall took {:?} ({} parts)",
829            start_parts.elapsed(),
830            parts_len
831        );
832
833        // Complete the upload.
834        //
835        // Currently, we early return if any of the individual parts fail. This
836        // permanently orphans any parts that succeeded. One fix is to call
837        // abort_multipart_upload, which deletes them. However, there's also an
838        // option for an s3 bucket to auto-delete parts that haven't been
839        // completed or aborted after a given amount of time. This latter is
840        // simpler and also resilient to ill-timed mz restarts, so we use it for
841        // now. We could likely add the accounting necessary to make
842        // abort_multipart_upload work, but it would be complex and affect perf.
843        // Let's see how far we can get without it.
844        let start_complete = Instant::now();
845        self.metrics.set_multi_complete.inc();
846        self.client
847            .complete_multipart_upload()
848            .bucket(&self.bucket)
849            .key(&path)
850            .upload_id(upload_id)
851            .multipart_upload(
852                CompletedMultipartUpload::builder()
853                    .set_parts(Some(parts))
854                    .build(),
855            )
856            .send()
857            .instrument(debug_span!("s3set_multi_complete", num_parts = parts_len))
858            .await
859            .inspect_err(|err| self.update_error_metrics("CompleteMultipartUpload", err))
860            .context("complete_multipart_upload err")?;
861        trace!(
862            "s3 complete_multipart_upload took {:?}",
863            start_complete.elapsed()
864        );
865
866        debug!(
867            "s3 PutObject multi done {}b / {:?} ({} parts)",
868            value.len(),
869            start_overall.elapsed(),
870            parts_len
871        );
872        Ok(())
873    }
874
875    fn update_error_metrics<E, R>(&self, op: &str, err: &SdkError<E, R>)
876    where
877        E: ProvideErrorMetadata,
878    {
879        let code = match err {
880            SdkError::ServiceError(e) => match e.err().code() {
881                Some(code) => code,
882                None => "UnknownServiceError",
883            },
884            SdkError::DispatchFailure(e) => {
885                if let Some(other_error) = e.as_other() {
886                    match other_error {
887                        aws_config::retry::ErrorKind::TransientError => "TransientError",
888                        aws_config::retry::ErrorKind::ThrottlingError => "ThrottlingError",
889                        aws_config::retry::ErrorKind::ServerError => "ServerError",
890                        aws_config::retry::ErrorKind::ClientError => "ClientError",
891                        _ => "UnknownDispatchFailure",
892                    }
893                } else if e.is_timeout() {
894                    "TimeoutError"
895                } else if e.is_io() {
896                    "IOError"
897                } else if e.is_user() {
898                    "UserError"
899                } else {
900                    "UnknownDispathFailure"
901                }
902            }
903            SdkError::ResponseError(_) => "ResponseError",
904            SdkError::ConstructionFailure(_) => "ConstructionFailure",
905            // There is some overlap with MetricsSleep. MetricsSleep is more granular
906            // but does not contain the operation.
907            SdkError::TimeoutError(_) => "TimeoutError",
908            // an error was added at some point in the future
909            _ => "UnknownSdkError",
910        };
911        self.metrics
912            .error_counts
913            .with_label_values(&[op, code])
914            .inc();
915    }
916}
917
918#[derive(Clone, Debug)]
919struct MultipartConfig {
920    multipart_threshold: usize,
921    multipart_chunk_size: usize,
922}
923
924impl Default for MultipartConfig {
925    fn default() -> Self {
926        Self {
927            multipart_threshold: Self::DEFAULT_MULTIPART_THRESHOLD,
928            multipart_chunk_size: Self::DEFAULT_MULTIPART_CHUNK_SIZE,
929        }
930    }
931}
932
933const MB: usize = 1024 * 1024;
934const TB: usize = 1024 * 1024 * MB;
935
936impl MultipartConfig {
937    /// The minimum object size for which we start using multipart upload.
938    ///
939    /// From the official `aws cli` tool implementation:
940    ///
941    /// <https://github.com/aws/aws-cli/blob/2.4.14/awscli/customizations/s3/transferconfig.py#L18-L29>
942    const DEFAULT_MULTIPART_THRESHOLD: usize = 8 * MB;
943    /// The size of each part (except the last) in a multipart upload.
944    ///
945    /// From the official `aws cli` tool implementation:
946    ///
947    /// <https://github.com/aws/aws-cli/blob/2.4.14/awscli/customizations/s3/transferconfig.py#L18-L29>
948    const DEFAULT_MULTIPART_CHUNK_SIZE: usize = 8 * MB;
949
950    /// The largest size object creatable in S3.
951    ///
952    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
953    const MAX_SINGLE_UPLOAD_SIZE: usize = 5 * TB;
954    /// The minimum size of a part in a multipart upload.
955    ///
956    /// This minimum doesn't apply to the last chunk, which can be any size.
957    ///
958    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
959    const MIN_UPLOAD_CHUNK_SIZE: usize = 5 * MB;
960    /// The smallest allowable part number (inclusive).
961    ///
962    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
963    const MIN_PART_NUM: u32 = 1;
964    /// The largest allowable part number (inclusive).
965    ///
966    /// From <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
967    const MAX_PART_NUM: u32 = 10_000;
968
969    fn should_multipart(&self, blob_len: usize) -> Result<bool, String> {
970        if blob_len > Self::MAX_SINGLE_UPLOAD_SIZE {
971            return Err(format!(
972                "S3 does not support blobs larger than {} bytes got: {}",
973                Self::MAX_SINGLE_UPLOAD_SIZE,
974                blob_len
975            ));
976        }
977        Ok(blob_len > self.multipart_threshold)
978    }
979
980    fn part_iter(&self, blob_len: usize) -> MultipartChunkIter {
981        mz_ore::soft_assert_no_log!(
982            self.multipart_chunk_size >= MultipartConfig::MIN_UPLOAD_CHUNK_SIZE
983        );
984        MultipartChunkIter::new(self.multipart_chunk_size, blob_len)
985    }
986}
987
988#[derive(Clone, Debug)]
989struct MultipartChunkIter {
990    total_len: usize,
991    part_size: usize,
992    part_idx: u32,
993}
994
995impl MultipartChunkIter {
996    fn new(default_part_size: usize, blob_len: usize) -> Self {
997        let max_parts: usize = usize::cast_from(MultipartConfig::MAX_PART_NUM);
998
999        // Compute the minimum part size we can use without going over the max
1000        // number of parts that S3 allows: `ceil(blob_len / max_parts)`.This
1001        // will end up getting thrown away by the `cmp::max` for anything
1002        // smaller than `max_parts * default_part_size = 80GiB`.
1003        let min_part_size = (blob_len + max_parts - 1) / max_parts;
1004        let part_size = cmp::max(min_part_size, default_part_size);
1005
1006        // Part nums are 1-indexed in S3. Convert back to 0-indexed to make the
1007        // range math easier to follow.
1008        let part_idx = MultipartConfig::MIN_PART_NUM - 1;
1009        MultipartChunkIter {
1010            total_len: blob_len,
1011            part_size,
1012            part_idx,
1013        }
1014    }
1015}
1016
1017impl Iterator for MultipartChunkIter {
1018    type Item = (u32, Range<usize>);
1019
1020    fn next(&mut self) -> Option<Self::Item> {
1021        let part_idx = self.part_idx;
1022        self.part_idx += 1;
1023
1024        let start = usize::cast_from(part_idx) * self.part_size;
1025        if start >= self.total_len {
1026            return None;
1027        }
1028        let end = cmp::min(start + self.part_size, self.total_len);
1029        let part_num = part_idx + 1;
1030        Some((part_num, start..end))
1031    }
1032}
1033
1034/// A helper for tracking the minimum of a set of Durations.
1035#[derive(Debug)]
1036struct MinElapsed {
1037    min: AtomicU64,
1038    alert_factor: u64,
1039}
1040
1041impl Default for MinElapsed {
1042    fn default() -> Self {
1043        MinElapsed {
1044            min: AtomicU64::new(u64::MAX),
1045            alert_factor: 8,
1046        }
1047    }
1048}
1049
1050impl MinElapsed {
1051    fn observe(&self, x: Duration, msg: &'static str) {
1052        let nanos = x.as_nanos();
1053        let nanos = u64::try_from(nanos).unwrap_or(u64::MAX);
1054
1055        // Possibly set a new minimum.
1056        let prev_min = self.min.fetch_min(nanos, atomic::Ordering::SeqCst);
1057
1058        // Trace if our provided duration was much larger than our minimum.
1059        let new_min = std::cmp::min(prev_min, nanos);
1060        if nanos > new_min.saturating_mul(self.alert_factor) {
1061            let min_duration = Duration::from_nanos(new_min);
1062            let factor = self.alert_factor;
1063            debug!("{msg} took {x:?} more than {factor}x the min {min_duration:?}");
1064        } else {
1065            trace!("{msg} took {x:?}");
1066        }
1067    }
1068}
1069
1070// Make sure the "vendored" feature of the openssl_sys crate makes it into the
1071// transitive dep graph of persist, so that we don't attempt to link against the
1072// system OpenSSL library. Fake a usage of the crate here so that a good
1073// samaritan doesn't remove our unused dep.
1074#[allow(dead_code)]
1075fn openssl_sys_hack() {
1076    openssl_sys::init();
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use tracing::info;
1082
1083    use crate::location::tests::blob_impl_test;
1084
1085    use super::*;
1086
1087    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1088    #[cfg_attr(coverage, ignore)] // https://github.com/MaterializeInc/database-issues/issues/5586
1089    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1090    #[ignore] // TODO: Reenable against minio so it can run locally
1091    async fn s3_blob() -> Result<(), ExternalError> {
1092        let config = match S3BlobConfig::new_for_test().await? {
1093            Some(client) => client,
1094            None => {
1095                info!(
1096                    "{} env not set: skipping test that uses external service",
1097                    S3BlobConfig::EXTERNAL_TESTS_S3_BUCKET
1098                );
1099                return Ok(());
1100            }
1101        };
1102        let config_multipart = config.clone_with_new_uuid_prefix();
1103
1104        blob_impl_test(move |path| {
1105            let path = path.to_owned();
1106            let config = config.clone();
1107            async move {
1108                let config = S3BlobConfig {
1109                    metrics: config.metrics.clone(),
1110                    client: config.client.clone(),
1111                    bucket: config.bucket.clone(),
1112                    prefix: format!("{}/s3_blob_impl_test/{}", config.prefix, path),
1113                };
1114                let mut blob = S3Blob::open(config).await?;
1115                blob.max_keys = 2;
1116                Ok(blob)
1117            }
1118        })
1119        .await?;
1120
1121        // Also specifically test multipart. S3 requires all parts but the last
1122        // to be at least 5MB, which we don't want to do from a test, so this
1123        // uses the multipart code path but only writes a single part.
1124        {
1125            let blob = S3Blob::open(config_multipart).await?;
1126            blob.set_multi_part("multipart", "foobar".into()).await?;
1127            assert_eq!(
1128                blob.get("multipart").await?,
1129                Some(b"foobar".to_vec().into())
1130            );
1131        }
1132
1133        Ok(())
1134    }
1135
1136    /// Runs the conformance suite through [crate::hedge::HedgedBlob] with two
1137    /// genuinely independent S3 clients (separate connection pools) pointed
1138    /// at the same bucket/prefix, a hedge racing on every get. Ignored by
1139    /// default like `s3_blob` above. When run against the external test
1140    /// bucket, it is the one exercise of the real pool-isolation path.
1141    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1142    #[cfg_attr(coverage, ignore)] // https://github.com/MaterializeInc/database-issues/issues/5586
1143    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
1144    #[ignore] // TODO: Reenable against minio so it can run locally
1145    async fn s3_blob_hedged() -> Result<(), ExternalError> {
1146        use crate::hedge::{
1147            BLOB_HEDGED_GET_BUDGET_RATIO, BLOB_HEDGED_GET_DELAY, BLOB_HEDGED_GET_ENABLED,
1148            HedgeSibling, HedgedBlob,
1149        };
1150        use crate::metrics::BlobHedgeMetrics;
1151        use mz_dyncfg::{ConfigSet, ConfigUpdates};
1152
1153        let config = match S3BlobConfig::new_for_test().await? {
1154            Some(client) => client,
1155            None => return Ok(()),
1156        };
1157        // A second client with its own connection pool. Its generated prefix
1158        // is discarded below: both sides must point at the same store.
1159        let sibling = match S3BlobConfig::new_for_test().await? {
1160            Some(client) => client,
1161            None => return Ok(()),
1162        };
1163
1164        let cfg = crate::cfg::all_dyn_configs(ConfigSet::default());
1165        let mut updates = ConfigUpdates::default();
1166        updates.add(&BLOB_HEDGED_GET_ENABLED, true);
1167        updates.add(&BLOB_HEDGED_GET_DELAY, Duration::ZERO);
1168        updates.add(&BLOB_HEDGED_GET_BUDGET_RATIO, 1.0);
1169        updates.apply(&cfg);
1170        let cfg = Arc::new(cfg);
1171
1172        blob_impl_test(move |path| {
1173            let path = path.to_owned();
1174            let config = config.clone();
1175            let sibling = sibling.clone();
1176            let cfg = Arc::clone(&cfg);
1177            async move {
1178                let prefix = format!("{}/s3_blob_hedged_test/{}", config.prefix, path);
1179                let primary_config = S3BlobConfig {
1180                    metrics: config.metrics.clone(),
1181                    client: config.client.clone(),
1182                    bucket: config.bucket.clone(),
1183                    prefix: prefix.clone(),
1184                };
1185                let hedge_config = S3BlobConfig {
1186                    metrics: sibling.metrics.clone(),
1187                    client: sibling.client.clone(),
1188                    bucket: config.bucket.clone(),
1189                    prefix,
1190                };
1191                let primary: Arc<dyn Blob> = Arc::new(S3Blob::open(primary_config).await?);
1192                let hedge: Arc<dyn Blob> = Arc::new(S3Blob::open(hedge_config).await?);
1193                Ok(HedgedBlob::new(
1194                    primary,
1195                    HedgeSibling::Isolated(hedge),
1196                    cfg,
1197                    BlobHedgeMetrics::new(&MetricsRegistry::new()),
1198                ))
1199            }
1200        })
1201        .await?;
1202
1203        Ok(())
1204    }
1205
1206    #[mz_ore::test]
1207    fn should_multipart() {
1208        let config = MultipartConfig::default();
1209        assert_eq!(config.should_multipart(0), Ok(false));
1210        assert_eq!(config.should_multipart(1), Ok(false));
1211        assert_eq!(
1212            config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD),
1213            Ok(false)
1214        );
1215        assert_eq!(
1216            config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD + 1),
1217            Ok(true)
1218        );
1219        assert_eq!(
1220            config.should_multipart(MultipartConfig::DEFAULT_MULTIPART_THRESHOLD * 2),
1221            Ok(true)
1222        );
1223        assert_eq!(
1224            config.should_multipart(MultipartConfig::MAX_SINGLE_UPLOAD_SIZE),
1225            Ok(true)
1226        );
1227        assert_eq!(
1228            config.should_multipart(MultipartConfig::MAX_SINGLE_UPLOAD_SIZE + 1),
1229            Err(
1230                "S3 does not support blobs larger than 5497558138880 bytes got: 5497558138881"
1231                    .into()
1232            )
1233        );
1234    }
1235
1236    #[mz_ore::test]
1237    fn multipart_iter() {
1238        let iter = MultipartChunkIter::new(10, 0);
1239        assert_eq!(iter.collect::<Vec<_>>(), vec![]);
1240
1241        let iter = MultipartChunkIter::new(10, 9);
1242        assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..9)]);
1243
1244        let iter = MultipartChunkIter::new(10, 10);
1245        assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10)]);
1246
1247        let iter = MultipartChunkIter::new(10, 11);
1248        assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..11)]);
1249
1250        let iter = MultipartChunkIter::new(10, 19);
1251        assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..19)]);
1252
1253        let iter = MultipartChunkIter::new(10, 20);
1254        assert_eq!(iter.collect::<Vec<_>>(), vec![(1, 0..10), (2, 10..20)]);
1255
1256        let iter = MultipartChunkIter::new(10, 21);
1257        assert_eq!(
1258            iter.collect::<Vec<_>>(),
1259            vec![(1, 0..10), (2, 10..20), (3, 20..21)]
1260        );
1261    }
1262}