Line data Source code
1 : //! A set of generic storage abstractions for the page server to use when backing up and restoring its state from the external storage.
2 : //! No other modules from this tree are supposed to be used directly by the external code.
3 : //!
4 : //! [`RemoteStorage`] trait a CRUD-like generic abstraction to use for adapting external storages with a few implementations:
5 : //! * [`local_fs`] allows to use local file system as an external storage
6 : //! * [`s3_bucket`] uses AWS S3 bucket as an external storage
7 : //! * [`azure_blob`] allows to use Azure Blob storage as an external storage
8 : //!
9 : #![deny(unsafe_code)]
10 : #![deny(clippy::undocumented_unsafe_blocks)]
11 :
12 : mod azure_blob;
13 : mod error;
14 : mod local_fs;
15 : mod s3_bucket;
16 : mod simulate_failures;
17 : mod support;
18 :
19 : use std::{
20 : collections::HashMap,
21 : fmt::Debug,
22 : num::{NonZeroU32, NonZeroUsize},
23 : pin::Pin,
24 : str::FromStr,
25 : sync::Arc,
26 : time::{Duration, SystemTime},
27 : };
28 :
29 : use anyhow::{bail, Context};
30 : use aws_sdk_s3::types::StorageClass;
31 : use camino::{Utf8Path, Utf8PathBuf};
32 :
33 : use bytes::Bytes;
34 : use futures::stream::Stream;
35 : use serde::{Deserialize, Serialize};
36 : use tokio::sync::Semaphore;
37 : use tokio_util::sync::CancellationToken;
38 : use toml_edit::Item;
39 : use tracing::info;
40 :
41 : pub use self::{
42 : azure_blob::AzureBlobStorage, local_fs::LocalFs, s3_bucket::S3Bucket,
43 : simulate_failures::UnreliableWrapper,
44 : };
45 : use s3_bucket::RequestKind;
46 :
47 : /// Azure SDK's ETag type is a simple String wrapper: we use this internally instead of repeating it here.
48 : pub use azure_core::Etag;
49 :
50 : pub use error::{DownloadError, TimeTravelError, TimeoutOrCancel};
51 :
52 : /// Currently, sync happens with AWS S3, that has two limits on requests per second:
53 : /// ~200 RPS for IAM services
54 : /// <https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAMDBAuth.html>
55 : /// ~3500 PUT/COPY/POST/DELETE or 5500 GET/HEAD S3 requests
56 : /// <https://aws.amazon.com/premiumsupport/knowledge-center/s3-request-limit-avoid-throttling/>
57 : pub const DEFAULT_REMOTE_STORAGE_S3_CONCURRENCY_LIMIT: usize = 100;
58 : /// Set this limit analogously to the S3 limit
59 : ///
60 : /// Here, a limit of max 20k concurrent connections was noted.
61 : /// <https://learn.microsoft.com/en-us/answers/questions/1301863/is-there-any-limitation-to-concurrent-connections>
62 : pub const DEFAULT_REMOTE_STORAGE_AZURE_CONCURRENCY_LIMIT: usize = 100;
63 : /// No limits on the client side, which currenltly means 1000 for AWS S3.
64 : /// <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax>
65 : pub const DEFAULT_MAX_KEYS_PER_LIST_RESPONSE: Option<i32> = None;
66 :
67 : /// As defined in S3 docs
68 : pub const MAX_KEYS_PER_DELETE: usize = 1000;
69 :
70 : const REMOTE_STORAGE_PREFIX_SEPARATOR: char = '/';
71 :
72 : /// Path on the remote storage, relative to some inner prefix.
73 : /// The prefix is an implementation detail, that allows representing local paths
74 : /// as the remote ones, stripping the local storage prefix away.
75 : #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
76 : pub struct RemotePath(Utf8PathBuf);
77 :
78 : impl Serialize for RemotePath {
79 0 : fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80 0 : where
81 0 : S: serde::Serializer,
82 0 : {
83 0 : serializer.collect_str(self)
84 0 : }
85 : }
86 :
87 : impl<'de> Deserialize<'de> for RemotePath {
88 0 : fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89 0 : where
90 0 : D: serde::Deserializer<'de>,
91 0 : {
92 0 : let str = String::deserialize(deserializer)?;
93 0 : Ok(Self(Utf8PathBuf::from(&str)))
94 0 : }
95 : }
96 :
97 : impl std::fmt::Display for RemotePath {
98 184 : fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 184 : std::fmt::Display::fmt(&self.0, f)
100 184 : }
101 : }
102 :
103 : impl RemotePath {
104 2866 : pub fn new(relative_path: &Utf8Path) -> anyhow::Result<Self> {
105 2866 : anyhow::ensure!(
106 2866 : relative_path.is_relative(),
107 4 : "Path {relative_path:?} is not relative"
108 : );
109 2862 : Ok(Self(relative_path.to_path_buf()))
110 2866 : }
111 :
112 2584 : pub fn from_string(relative_path: &str) -> anyhow::Result<Self> {
113 2584 : Self::new(Utf8Path::new(relative_path))
114 2584 : }
115 :
116 2685 : pub fn with_base(&self, base_path: &Utf8Path) -> Utf8PathBuf {
117 2685 : base_path.join(&self.0)
118 2685 : }
119 :
120 14 : pub fn object_name(&self) -> Option<&str> {
121 14 : self.0.file_name()
122 14 : }
123 :
124 99 : pub fn join(&self, segment: &Utf8Path) -> Self {
125 99 : Self(self.0.join(segment))
126 99 : }
127 :
128 493 : pub fn get_path(&self) -> &Utf8PathBuf {
129 493 : &self.0
130 493 : }
131 :
132 0 : pub fn extension(&self) -> Option<&str> {
133 0 : self.0.extension()
134 0 : }
135 :
136 56 : pub fn strip_prefix(&self, p: &RemotePath) -> Result<&Utf8Path, std::path::StripPrefixError> {
137 56 : self.0.strip_prefix(&p.0)
138 56 : }
139 :
140 121 : pub fn add_trailing_slash(&self) -> Self {
141 121 : // Unwrap safety inputs are guararnteed to be valid UTF-8
142 121 : Self(format!("{}/", self.0).try_into().unwrap())
143 121 : }
144 : }
145 :
146 : /// We don't need callers to be able to pass arbitrary delimiters: just control
147 : /// whether listings will use a '/' separator or not.
148 : ///
149 : /// The WithDelimiter mode will populate `prefixes` and `keys` in the result. The
150 : /// NoDelimiter mode will only populate `keys`.
151 : pub enum ListingMode {
152 : WithDelimiter,
153 : NoDelimiter,
154 : }
155 :
156 : #[derive(Default)]
157 : pub struct Listing {
158 : pub prefixes: Vec<RemotePath>,
159 : pub keys: Vec<RemotePath>,
160 : }
161 :
162 : /// Storage (potentially remote) API to manage its state.
163 : /// This storage tries to be unaware of any layered repository context,
164 : /// providing basic CRUD operations for storage files.
165 : #[allow(async_fn_in_trait)]
166 : pub trait RemoteStorage: Send + Sync + 'static {
167 : /// List objects in remote storage, with semantics matching AWS S3's ListObjectsV2.
168 : /// (see `<https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html>`)
169 : ///
170 : /// Note that the prefix is relative to any `prefix_in_bucket` configured for the client, not
171 : /// from the absolute root of the bucket.
172 : ///
173 : /// `mode` configures whether to use a delimiter. Without a delimiter all keys
174 : /// within the prefix are listed in the `keys` of the result. With a delimiter, any "directories" at the top level of
175 : /// the prefix are returned in the `prefixes` of the result, and keys in the top level of the prefix are
176 : /// returned in `keys` ().
177 : ///
178 : /// `max_keys` controls the maximum number of keys that will be returned. If this is None, this function
179 : /// will iteratively call listobjects until it runs out of keys. Note that this is not safe to use on
180 : /// unlimted size buckets, as the full list of objects is allocated into a monolithic data structure.
181 : ///
182 : async fn list(
183 : &self,
184 : prefix: Option<&RemotePath>,
185 : _mode: ListingMode,
186 : max_keys: Option<NonZeroU32>,
187 : cancel: &CancellationToken,
188 : ) -> Result<Listing, DownloadError>;
189 :
190 : /// Streams the local file contents into remote into the remote storage entry.
191 : ///
192 : /// If the operation fails because of timeout or cancellation, the root cause of the error will be
193 : /// set to `TimeoutOrCancel`.
194 : async fn upload(
195 : &self,
196 : from: impl Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static,
197 : // S3 PUT request requires the content length to be specified,
198 : // otherwise it starts to fail with the concurrent connection count increasing.
199 : data_size_bytes: usize,
200 : to: &RemotePath,
201 : metadata: Option<StorageMetadata>,
202 : cancel: &CancellationToken,
203 : ) -> anyhow::Result<()>;
204 :
205 : /// Streams the remote storage entry contents.
206 : ///
207 : /// The returned download stream will obey initial timeout and cancellation signal by erroring
208 : /// on whichever happens first. Only one of the reasons will fail the stream, which is usually
209 : /// enough for `tokio::io::copy_buf` usage. If needed the error can be filtered out.
210 : ///
211 : /// Returns the metadata, if any was stored with the file previously.
212 : async fn download(
213 : &self,
214 : from: &RemotePath,
215 : cancel: &CancellationToken,
216 : ) -> Result<Download, DownloadError>;
217 :
218 : /// Streams a given byte range of the remote storage entry contents.
219 : ///
220 : /// The returned download stream will obey initial timeout and cancellation signal by erroring
221 : /// on whichever happens first. Only one of the reasons will fail the stream, which is usually
222 : /// enough for `tokio::io::copy_buf` usage. If needed the error can be filtered out.
223 : ///
224 : /// Returns the metadata, if any was stored with the file previously.
225 : async fn download_byte_range(
226 : &self,
227 : from: &RemotePath,
228 : start_inclusive: u64,
229 : end_exclusive: Option<u64>,
230 : cancel: &CancellationToken,
231 : ) -> Result<Download, DownloadError>;
232 :
233 : /// Delete a single path from remote storage.
234 : ///
235 : /// If the operation fails because of timeout or cancellation, the root cause of the error will be
236 : /// set to `TimeoutOrCancel`. In such situation it is unknown if the deletion went through.
237 : async fn delete(&self, path: &RemotePath, cancel: &CancellationToken) -> anyhow::Result<()>;
238 :
239 : /// Delete a multiple paths from remote storage.
240 : ///
241 : /// If the operation fails because of timeout or cancellation, the root cause of the error will be
242 : /// set to `TimeoutOrCancel`. In such situation it is unknown which deletions, if any, went
243 : /// through.
244 : async fn delete_objects<'a>(
245 : &self,
246 : paths: &'a [RemotePath],
247 : cancel: &CancellationToken,
248 : ) -> anyhow::Result<()>;
249 :
250 : /// Copy a remote object inside a bucket from one path to another.
251 : async fn copy(
252 : &self,
253 : from: &RemotePath,
254 : to: &RemotePath,
255 : cancel: &CancellationToken,
256 : ) -> anyhow::Result<()>;
257 :
258 : /// Resets the content of everything with the given prefix to the given state
259 : async fn time_travel_recover(
260 : &self,
261 : prefix: Option<&RemotePath>,
262 : timestamp: SystemTime,
263 : done_if_after: SystemTime,
264 : cancel: &CancellationToken,
265 : ) -> Result<(), TimeTravelError>;
266 : }
267 :
268 : /// DownloadStream is sensitive to the timeout and cancellation used with the original
269 : /// [`RemoteStorage::download`] request. The type yields `std::io::Result<Bytes>` to be compatible
270 : /// with `tokio::io::copy_buf`.
271 : // This has 'static because safekeepers do not use cancellation tokens (yet)
272 : pub type DownloadStream =
273 : Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static>>;
274 :
275 : pub struct Download {
276 : pub download_stream: DownloadStream,
277 : /// The last time the file was modified (`last-modified` HTTP header)
278 : pub last_modified: SystemTime,
279 : /// A way to identify this specific version of the resource (`etag` HTTP header)
280 : pub etag: Etag,
281 : /// Extra key-value data, associated with the current remote file.
282 : pub metadata: Option<StorageMetadata>,
283 : }
284 :
285 : impl Debug for Download {
286 0 : fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 0 : f.debug_struct("Download")
288 0 : .field("metadata", &self.metadata)
289 0 : .finish()
290 0 : }
291 : }
292 :
293 : /// Every storage, currently supported.
294 : /// Serves as a simple way to pass around the [`RemoteStorage`] without dealing with generics.
295 : #[derive(Clone)]
296 : // Require Clone for `Other` due to https://github.com/rust-lang/rust/issues/26925
297 : pub enum GenericRemoteStorage<Other: Clone = Arc<UnreliableWrapper>> {
298 : LocalFs(LocalFs),
299 : AwsS3(Arc<S3Bucket>),
300 : AzureBlob(Arc<AzureBlobStorage>),
301 : Unreliable(Other),
302 : }
303 :
304 : impl<Other: RemoteStorage> GenericRemoteStorage<Arc<Other>> {
305 124 : pub async fn list(
306 124 : &self,
307 124 : prefix: Option<&RemotePath>,
308 124 : mode: ListingMode,
309 124 : max_keys: Option<NonZeroU32>,
310 124 : cancel: &CancellationToken,
311 124 : ) -> anyhow::Result<Listing, DownloadError> {
312 124 : match self {
313 459 : Self::LocalFs(s) => s.list(prefix, mode, max_keys, cancel).await,
314 0 : Self::AwsS3(s) => s.list(prefix, mode, max_keys, cancel).await,
315 0 : Self::AzureBlob(s) => s.list(prefix, mode, max_keys, cancel).await,
316 0 : Self::Unreliable(s) => s.list(prefix, mode, max_keys, cancel).await,
317 : }
318 124 : }
319 :
320 : /// See [`RemoteStorage::upload`]
321 2325 : pub async fn upload(
322 2325 : &self,
323 2325 : from: impl Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static,
324 2325 : data_size_bytes: usize,
325 2325 : to: &RemotePath,
326 2325 : metadata: Option<StorageMetadata>,
327 2325 : cancel: &CancellationToken,
328 2325 : ) -> anyhow::Result<()> {
329 2325 : match self {
330 31211 : Self::LocalFs(s) => s.upload(from, data_size_bytes, to, metadata, cancel).await,
331 0 : Self::AwsS3(s) => s.upload(from, data_size_bytes, to, metadata, cancel).await,
332 0 : Self::AzureBlob(s) => s.upload(from, data_size_bytes, to, metadata, cancel).await,
333 76 : Self::Unreliable(s) => s.upload(from, data_size_bytes, to, metadata, cancel).await,
334 : }
335 2213 : }
336 :
337 42 : pub async fn download(
338 42 : &self,
339 42 : from: &RemotePath,
340 42 : cancel: &CancellationToken,
341 42 : ) -> Result<Download, DownloadError> {
342 42 : match self {
343 64 : Self::LocalFs(s) => s.download(from, cancel).await,
344 0 : Self::AwsS3(s) => s.download(from, cancel).await,
345 0 : Self::AzureBlob(s) => s.download(from, cancel).await,
346 0 : Self::Unreliable(s) => s.download(from, cancel).await,
347 : }
348 42 : }
349 :
350 0 : pub async fn download_byte_range(
351 0 : &self,
352 0 : from: &RemotePath,
353 0 : start_inclusive: u64,
354 0 : end_exclusive: Option<u64>,
355 0 : cancel: &CancellationToken,
356 0 : ) -> Result<Download, DownloadError> {
357 0 : match self {
358 0 : Self::LocalFs(s) => {
359 0 : s.download_byte_range(from, start_inclusive, end_exclusive, cancel)
360 0 : .await
361 : }
362 0 : Self::AwsS3(s) => {
363 0 : s.download_byte_range(from, start_inclusive, end_exclusive, cancel)
364 0 : .await
365 : }
366 0 : Self::AzureBlob(s) => {
367 0 : s.download_byte_range(from, start_inclusive, end_exclusive, cancel)
368 0 : .await
369 : }
370 0 : Self::Unreliable(s) => {
371 0 : s.download_byte_range(from, start_inclusive, end_exclusive, cancel)
372 0 : .await
373 : }
374 : }
375 0 : }
376 :
377 : /// See [`RemoteStorage::delete`]
378 2 : pub async fn delete(
379 2 : &self,
380 2 : path: &RemotePath,
381 2 : cancel: &CancellationToken,
382 2 : ) -> anyhow::Result<()> {
383 2 : match self {
384 2 : Self::LocalFs(s) => s.delete(path, cancel).await,
385 0 : Self::AwsS3(s) => s.delete(path, cancel).await,
386 0 : Self::AzureBlob(s) => s.delete(path, cancel).await,
387 0 : Self::Unreliable(s) => s.delete(path, cancel).await,
388 : }
389 2 : }
390 :
391 : /// See [`RemoteStorage::delete_objects`]
392 6 : pub async fn delete_objects(
393 6 : &self,
394 6 : paths: &[RemotePath],
395 6 : cancel: &CancellationToken,
396 6 : ) -> anyhow::Result<()> {
397 6 : match self {
398 6 : Self::LocalFs(s) => s.delete_objects(paths, cancel).await,
399 0 : Self::AwsS3(s) => s.delete_objects(paths, cancel).await,
400 0 : Self::AzureBlob(s) => s.delete_objects(paths, cancel).await,
401 0 : Self::Unreliable(s) => s.delete_objects(paths, cancel).await,
402 : }
403 6 : }
404 :
405 : /// See [`RemoteStorage::copy`]
406 0 : pub async fn copy_object(
407 0 : &self,
408 0 : from: &RemotePath,
409 0 : to: &RemotePath,
410 0 : cancel: &CancellationToken,
411 0 : ) -> anyhow::Result<()> {
412 0 : match self {
413 0 : Self::LocalFs(s) => s.copy(from, to, cancel).await,
414 0 : Self::AwsS3(s) => s.copy(from, to, cancel).await,
415 0 : Self::AzureBlob(s) => s.copy(from, to, cancel).await,
416 0 : Self::Unreliable(s) => s.copy(from, to, cancel).await,
417 : }
418 0 : }
419 :
420 : /// See [`RemoteStorage::time_travel_recover`].
421 0 : pub async fn time_travel_recover(
422 0 : &self,
423 0 : prefix: Option<&RemotePath>,
424 0 : timestamp: SystemTime,
425 0 : done_if_after: SystemTime,
426 0 : cancel: &CancellationToken,
427 0 : ) -> Result<(), TimeTravelError> {
428 0 : match self {
429 0 : Self::LocalFs(s) => {
430 0 : s.time_travel_recover(prefix, timestamp, done_if_after, cancel)
431 0 : .await
432 : }
433 0 : Self::AwsS3(s) => {
434 0 : s.time_travel_recover(prefix, timestamp, done_if_after, cancel)
435 0 : .await
436 : }
437 0 : Self::AzureBlob(s) => {
438 0 : s.time_travel_recover(prefix, timestamp, done_if_after, cancel)
439 0 : .await
440 : }
441 0 : Self::Unreliable(s) => {
442 0 : s.time_travel_recover(prefix, timestamp, done_if_after, cancel)
443 0 : .await
444 : }
445 : }
446 0 : }
447 : }
448 :
449 : impl GenericRemoteStorage {
450 158 : pub fn from_config(storage_config: &RemoteStorageConfig) -> anyhow::Result<Self> {
451 158 : let timeout = storage_config.timeout;
452 158 : Ok(match &storage_config.storage {
453 134 : RemoteStorageKind::LocalFs(path) => {
454 134 : info!("Using fs root '{path}' as a remote storage");
455 134 : Self::LocalFs(LocalFs::new(path.clone(), timeout)?)
456 : }
457 18 : RemoteStorageKind::AwsS3(s3_config) => {
458 18 : // The profile and access key id are only printed here for debugging purposes,
459 18 : // their values don't indicate the eventually taken choice for auth.
460 18 : let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "<none>".into());
461 18 : let access_key_id =
462 18 : std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "<none>".into());
463 18 : info!("Using s3 bucket '{}' in region '{}' as a remote storage, prefix in bucket: '{:?}', bucket endpoint: '{:?}', profile: {profile}, access_key_id: {access_key_id}",
464 : s3_config.bucket_name, s3_config.bucket_region, s3_config.prefix_in_bucket, s3_config.endpoint);
465 18 : Self::AwsS3(Arc::new(S3Bucket::new(s3_config, timeout)?))
466 : }
467 6 : RemoteStorageKind::AzureContainer(azure_config) => {
468 6 : info!("Using azure container '{}' in region '{}' as a remote storage, prefix in container: '{:?}'",
469 : azure_config.container_name, azure_config.container_region, azure_config.prefix_in_container);
470 6 : Self::AzureBlob(Arc::new(AzureBlobStorage::new(azure_config, timeout)?))
471 : }
472 : })
473 158 : }
474 :
475 4 : pub fn unreliable_wrapper(s: Self, fail_first: u64) -> Self {
476 4 : Self::Unreliable(Arc::new(UnreliableWrapper::new(s, fail_first)))
477 4 : }
478 :
479 : /// See [`RemoteStorage::upload`], which this method calls with `None` as metadata.
480 1150 : pub async fn upload_storage_object(
481 1150 : &self,
482 1150 : from: impl Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static,
483 1150 : from_size_bytes: usize,
484 1150 : to: &RemotePath,
485 1150 : cancel: &CancellationToken,
486 1150 : ) -> anyhow::Result<()> {
487 1150 : self.upload(from, from_size_bytes, to, None, cancel)
488 3592 : .await
489 1144 : .with_context(|| {
490 0 : format!("Failed to upload data of length {from_size_bytes} to storage path {to:?}")
491 1144 : })
492 1144 : }
493 :
494 : /// Downloads the storage object into the `to_path` provided.
495 : /// `byte_range` could be specified to dowload only a part of the file, if needed.
496 0 : pub async fn download_storage_object(
497 0 : &self,
498 0 : byte_range: Option<(u64, Option<u64>)>,
499 0 : from: &RemotePath,
500 0 : cancel: &CancellationToken,
501 0 : ) -> Result<Download, DownloadError> {
502 0 : match byte_range {
503 0 : Some((start, end)) => self.download_byte_range(from, start, end, cancel).await,
504 0 : None => self.download(from, cancel).await,
505 : }
506 0 : }
507 : }
508 :
509 : /// Extra set of key-value pairs that contain arbitrary metadata about the storage entry.
510 : /// Immutable, cannot be changed once the file is created.
511 : #[derive(Debug, Clone, PartialEq, Eq)]
512 : pub struct StorageMetadata(HashMap<String, String>);
513 :
514 : impl<const N: usize> From<[(&str, &str); N]> for StorageMetadata {
515 0 : fn from(arr: [(&str, &str); N]) -> Self {
516 0 : let map: HashMap<String, String> = arr
517 0 : .iter()
518 0 : .map(|(k, v)| (k.to_string(), v.to_string()))
519 0 : .collect();
520 0 : Self(map)
521 0 : }
522 : }
523 :
524 : /// External backup storage configuration, enough for creating a client for that storage.
525 : #[derive(Debug, Clone, PartialEq, Eq)]
526 : pub struct RemoteStorageConfig {
527 : /// The storage connection configuration.
528 : pub storage: RemoteStorageKind,
529 : /// A common timeout enforced for all requests after concurrency limiter permit has been
530 : /// acquired.
531 : pub timeout: Duration,
532 : }
533 :
534 : /// A kind of a remote storage to connect to, with its connection configuration.
535 : #[derive(Debug, Clone, PartialEq, Eq)]
536 : pub enum RemoteStorageKind {
537 : /// Storage based on local file system.
538 : /// Specify a root folder to place all stored files into.
539 : LocalFs(Utf8PathBuf),
540 : /// AWS S3 based storage, storing all files in the S3 bucket
541 : /// specified by the config
542 : AwsS3(S3Config),
543 : /// Azure Blob based storage, storing all files in the container
544 : /// specified by the config
545 : AzureContainer(AzureConfig),
546 : }
547 :
548 : /// AWS S3 bucket coordinates and access credentials to manage the bucket contents (read and write).
549 : #[derive(Clone, PartialEq, Eq)]
550 : pub struct S3Config {
551 : /// Name of the bucket to connect to.
552 : pub bucket_name: String,
553 : /// The region where the bucket is located at.
554 : pub bucket_region: String,
555 : /// A "subfolder" in the bucket, to use the same bucket separately by multiple remote storage users at once.
556 : pub prefix_in_bucket: Option<String>,
557 : /// A base URL to send S3 requests to.
558 : /// By default, the endpoint is derived from a region name, assuming it's
559 : /// an AWS S3 region name, erroring on wrong region name.
560 : /// Endpoint provides a way to support other S3 flavors and their regions.
561 : ///
562 : /// Example: `http://127.0.0.1:5000`
563 : pub endpoint: Option<String>,
564 : /// AWS S3 has various limits on its API calls, we need not to exceed those.
565 : /// See [`DEFAULT_REMOTE_STORAGE_S3_CONCURRENCY_LIMIT`] for more details.
566 : pub concurrency_limit: NonZeroUsize,
567 : pub max_keys_per_list_response: Option<i32>,
568 : pub upload_storage_class: Option<StorageClass>,
569 : }
570 :
571 : impl Debug for S3Config {
572 0 : fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573 0 : f.debug_struct("S3Config")
574 0 : .field("bucket_name", &self.bucket_name)
575 0 : .field("bucket_region", &self.bucket_region)
576 0 : .field("prefix_in_bucket", &self.prefix_in_bucket)
577 0 : .field("concurrency_limit", &self.concurrency_limit)
578 0 : .field(
579 0 : "max_keys_per_list_response",
580 0 : &self.max_keys_per_list_response,
581 0 : )
582 0 : .finish()
583 0 : }
584 : }
585 :
586 : /// Azure bucket coordinates and access credentials to manage the bucket contents (read and write).
587 : #[derive(Clone, PartialEq, Eq)]
588 : pub struct AzureConfig {
589 : /// Name of the container to connect to.
590 : pub container_name: String,
591 : /// The region where the bucket is located at.
592 : pub container_region: String,
593 : /// A "subfolder" in the container, to use the same container separately by multiple remote storage users at once.
594 : pub prefix_in_container: Option<String>,
595 : /// Azure has various limits on its API calls, we need not to exceed those.
596 : /// See [`DEFAULT_REMOTE_STORAGE_AZURE_CONCURRENCY_LIMIT`] for more details.
597 : pub concurrency_limit: NonZeroUsize,
598 : pub max_keys_per_list_response: Option<i32>,
599 : }
600 :
601 : impl Debug for AzureConfig {
602 0 : fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603 0 : f.debug_struct("AzureConfig")
604 0 : .field("bucket_name", &self.container_name)
605 0 : .field("bucket_region", &self.container_region)
606 0 : .field("prefix_in_bucket", &self.prefix_in_container)
607 0 : .field("concurrency_limit", &self.concurrency_limit)
608 0 : .field(
609 0 : "max_keys_per_list_response",
610 0 : &self.max_keys_per_list_response,
611 0 : )
612 0 : .finish()
613 0 : }
614 : }
615 :
616 : impl RemoteStorageConfig {
617 : pub const DEFAULT_TIMEOUT: Duration = std::time::Duration::from_secs(120);
618 :
619 34 : pub fn from_toml(toml: &toml_edit::Item) -> anyhow::Result<Option<RemoteStorageConfig>> {
620 34 : let local_path = toml.get("local_path");
621 34 : let bucket_name = toml.get("bucket_name");
622 34 : let bucket_region = toml.get("bucket_region");
623 34 : let container_name = toml.get("container_name");
624 34 : let container_region = toml.get("container_region");
625 :
626 34 : let use_azure = container_name.is_some() && container_region.is_some();
627 :
628 34 : let default_concurrency_limit = if use_azure {
629 0 : DEFAULT_REMOTE_STORAGE_AZURE_CONCURRENCY_LIMIT
630 : } else {
631 34 : DEFAULT_REMOTE_STORAGE_S3_CONCURRENCY_LIMIT
632 : };
633 34 : let concurrency_limit = NonZeroUsize::new(
634 34 : parse_optional_integer("concurrency_limit", toml)?.unwrap_or(default_concurrency_limit),
635 34 : )
636 34 : .context("Failed to parse 'concurrency_limit' as a positive integer")?;
637 :
638 34 : let max_keys_per_list_response =
639 34 : parse_optional_integer::<i32, _>("max_keys_per_list_response", toml)
640 34 : .context("Failed to parse 'max_keys_per_list_response' as a positive integer")?
641 34 : .or(DEFAULT_MAX_KEYS_PER_LIST_RESPONSE);
642 :
643 34 : let endpoint = toml
644 34 : .get("endpoint")
645 34 : .map(|endpoint| parse_toml_string("endpoint", endpoint))
646 34 : .transpose()?;
647 :
648 34 : let timeout = toml
649 34 : .get("timeout")
650 34 : .map(|timeout| {
651 2 : timeout
652 2 : .as_str()
653 2 : .ok_or_else(|| anyhow::Error::msg("timeout was not a string"))
654 34 : })
655 34 : .transpose()
656 34 : .and_then(|timeout| {
657 34 : timeout
658 34 : .map(humantime::parse_duration)
659 34 : .transpose()
660 34 : .map_err(anyhow::Error::new)
661 34 : })
662 34 : .context("parse timeout")?
663 34 : .unwrap_or(Self::DEFAULT_TIMEOUT);
664 34 :
665 34 : if timeout < Duration::from_secs(1) {
666 0 : bail!("timeout was specified as {timeout:?} which is too low");
667 34 : }
668 :
669 12 : let storage = match (
670 34 : local_path,
671 34 : bucket_name,
672 34 : bucket_region,
673 34 : container_name,
674 34 : container_region,
675 : ) {
676 : // no 'local_path' nor 'bucket_name' options are provided, consider this remote storage disabled
677 22 : (None, None, None, None, None) => return Ok(None),
678 : (_, Some(_), None, ..) => {
679 0 : bail!("'bucket_region' option is mandatory if 'bucket_name' is given ")
680 : }
681 : (_, None, Some(_), ..) => {
682 0 : bail!("'bucket_name' option is mandatory if 'bucket_region' is given ")
683 : }
684 6 : (None, Some(bucket_name), Some(bucket_region), ..) => {
685 6 : RemoteStorageKind::AwsS3(S3Config {
686 6 : bucket_name: parse_toml_string("bucket_name", bucket_name)?,
687 6 : bucket_region: parse_toml_string("bucket_region", bucket_region)?,
688 6 : prefix_in_bucket: toml
689 6 : .get("prefix_in_bucket")
690 6 : .map(|prefix_in_bucket| {
691 6 : parse_toml_string("prefix_in_bucket", prefix_in_bucket)
692 6 : })
693 6 : .transpose()?,
694 6 : endpoint,
695 6 : concurrency_limit,
696 6 : max_keys_per_list_response,
697 6 : upload_storage_class: toml
698 6 : .get("upload_storage_class")
699 6 : .map(|prefix_in_bucket| -> anyhow::Result<_> {
700 0 : let s = parse_toml_string("upload_storage_class", prefix_in_bucket)?;
701 0 : let storage_class = StorageClass::from_str(&s).expect("infallible");
702 : #[allow(deprecated)]
703 0 : if matches!(storage_class, StorageClass::Unknown(_)) {
704 0 : bail!("Specified storage class unknown to SDK: '{s}'. Allowed values: {:?}", StorageClass::values());
705 0 : }
706 0 : Ok(storage_class)
707 6 : })
708 6 : .transpose()?,
709 : })
710 : }
711 : (_, _, _, Some(_), None) => {
712 0 : bail!("'container_name' option is mandatory if 'container_region' is given ")
713 : }
714 : (_, _, _, None, Some(_)) => {
715 0 : bail!("'container_name' option is mandatory if 'container_region' is given ")
716 : }
717 0 : (None, None, None, Some(container_name), Some(container_region)) => {
718 0 : RemoteStorageKind::AzureContainer(AzureConfig {
719 0 : container_name: parse_toml_string("container_name", container_name)?,
720 0 : container_region: parse_toml_string("container_region", container_region)?,
721 0 : prefix_in_container: toml
722 0 : .get("prefix_in_container")
723 0 : .map(|prefix_in_container| {
724 0 : parse_toml_string("prefix_in_container", prefix_in_container)
725 0 : })
726 0 : .transpose()?,
727 0 : concurrency_limit,
728 0 : max_keys_per_list_response,
729 : })
730 : }
731 6 : (Some(local_path), None, None, None, None) => RemoteStorageKind::LocalFs(
732 6 : Utf8PathBuf::from(parse_toml_string("local_path", local_path)?),
733 : ),
734 : (Some(_), Some(_), ..) => {
735 0 : bail!("'local_path' and 'bucket_name' are mutually exclusive")
736 : }
737 : (Some(_), _, _, Some(_), Some(_)) => {
738 0 : bail!("local_path and 'container_name' are mutually exclusive")
739 : }
740 : };
741 :
742 12 : Ok(Some(RemoteStorageConfig { storage, timeout }))
743 34 : }
744 : }
745 :
746 : // Helper functions to parse a toml Item
747 68 : fn parse_optional_integer<I, E>(name: &str, item: &toml_edit::Item) -> anyhow::Result<Option<I>>
748 68 : where
749 68 : I: TryFrom<i64, Error = E>,
750 68 : E: std::error::Error + Send + Sync + 'static,
751 68 : {
752 68 : let toml_integer = match item.get(name) {
753 4 : Some(item) => item
754 4 : .as_integer()
755 4 : .with_context(|| format!("configure option {name} is not an integer"))?,
756 64 : None => return Ok(None),
757 : };
758 :
759 4 : I::try_from(toml_integer)
760 4 : .map(Some)
761 4 : .with_context(|| format!("configure option {name} is too large"))
762 68 : }
763 :
764 30 : fn parse_toml_string(name: &str, item: &Item) -> anyhow::Result<String> {
765 30 : let s = item
766 30 : .as_str()
767 30 : .with_context(|| format!("configure option {name} is not a string"))?;
768 30 : Ok(s.to_string())
769 30 : }
770 :
771 : struct ConcurrencyLimiter {
772 : // Every request to S3 can be throttled or cancelled, if a certain number of requests per second is exceeded.
773 : // Same goes to IAM, which is queried before every S3 request, if enabled. IAM has even lower RPS threshold.
774 : // The helps to ensure we don't exceed the thresholds.
775 : write: Arc<Semaphore>,
776 : read: Arc<Semaphore>,
777 : }
778 :
779 : impl ConcurrencyLimiter {
780 373 : fn for_kind(&self, kind: RequestKind) -> &Arc<Semaphore> {
781 373 : match kind {
782 31 : RequestKind::Get => &self.read,
783 154 : RequestKind::Put => &self.write,
784 30 : RequestKind::List => &self.read,
785 149 : RequestKind::Delete => &self.write,
786 3 : RequestKind::Copy => &self.write,
787 6 : RequestKind::TimeTravel => &self.write,
788 : }
789 373 : }
790 :
791 349 : async fn acquire(
792 349 : &self,
793 349 : kind: RequestKind,
794 349 : ) -> Result<tokio::sync::SemaphorePermit<'_>, tokio::sync::AcquireError> {
795 0 : self.for_kind(kind).acquire().await
796 0 : }
797 :
798 24 : async fn acquire_owned(
799 24 : &self,
800 24 : kind: RequestKind,
801 24 : ) -> Result<tokio::sync::OwnedSemaphorePermit, tokio::sync::AcquireError> {
802 0 : Arc::clone(self.for_kind(kind)).acquire_owned().await
803 0 : }
804 :
805 34 : fn new(limit: usize) -> ConcurrencyLimiter {
806 34 : Self {
807 34 : read: Arc::new(Semaphore::new(limit)),
808 34 : write: Arc::new(Semaphore::new(limit)),
809 34 : }
810 34 : }
811 : }
812 :
813 : #[cfg(test)]
814 : mod tests {
815 : use super::*;
816 :
817 : #[test]
818 2 : fn test_object_name() {
819 2 : let k = RemotePath::new(Utf8Path::new("a/b/c")).unwrap();
820 2 : assert_eq!(k.object_name(), Some("c"));
821 :
822 2 : let k = RemotePath::new(Utf8Path::new("a/b/c/")).unwrap();
823 2 : assert_eq!(k.object_name(), Some("c"));
824 :
825 2 : let k = RemotePath::new(Utf8Path::new("a/")).unwrap();
826 2 : assert_eq!(k.object_name(), Some("a"));
827 :
828 : // XXX is it impossible to have an empty key?
829 2 : let k = RemotePath::new(Utf8Path::new("")).unwrap();
830 2 : assert_eq!(k.object_name(), None);
831 2 : }
832 :
833 : #[test]
834 2 : fn rempte_path_cannot_be_created_from_absolute_ones() {
835 2 : let err = RemotePath::new(Utf8Path::new("/")).expect_err("Should fail on absolute paths");
836 2 : assert_eq!(err.to_string(), "Path \"/\" is not relative");
837 2 : }
838 :
839 : #[test]
840 2 : fn parse_localfs_config_with_timeout() {
841 2 : let input = "local_path = '.'
842 2 : timeout = '5s'";
843 2 :
844 2 : let toml = input.parse::<toml_edit::Document>().unwrap();
845 2 :
846 2 : let config = RemoteStorageConfig::from_toml(toml.as_item())
847 2 : .unwrap()
848 2 : .expect("it exists");
849 2 :
850 2 : assert_eq!(
851 2 : config,
852 2 : RemoteStorageConfig {
853 2 : storage: RemoteStorageKind::LocalFs(Utf8PathBuf::from(".")),
854 2 : timeout: Duration::from_secs(5)
855 2 : }
856 2 : );
857 2 : }
858 : }
|