Skip to main content

deltalake_aws/
lib.rs

1//! AWS S3 and similar tooling for delta-rs
2//!
3//! This module also contains the [S3DynamoDbLogStore](crate::logstore::S3DynamoDbLogStore)
4//! implementation for concurrent writer support with AWS S3 specifically.
5
6pub mod constants;
7mod credentials;
8pub mod errors;
9pub mod logstore;
10pub mod storage;
11
12use aws_config::Region;
13use aws_config::SdkConfig;
14pub use aws_credential_types::provider::SharedCredentialsProvider;
15use aws_sdk_dynamodb::error::SdkError;
16use aws_sdk_dynamodb::{
17    Client,
18    operation::{
19        create_table::CreateTableError, delete_item::DeleteItemError, get_item::GetItemError,
20        put_item::PutItemError, query::QueryError, update_item::UpdateItemError,
21    },
22    types::{
23        AttributeDefinition, AttributeValue, BillingMode, KeySchemaElement, KeyType,
24        ScalarAttributeType,
25    },
26};
27use deltalake_core::kernel::Version;
28use deltalake_core::logstore::{
29    LogStore, LogStoreFactory, ObjectStoreRef, StorageConfig, default_logstore, logstore_factories,
30    object_store_factories,
31};
32use deltalake_core::{DeltaResult, Path};
33use errors::{DynamoDbConfigError, LockClientError};
34use regex::Regex;
35use std::{
36    collections::HashMap,
37    str::FromStr,
38    sync::{Arc, LazyLock},
39    time::{Duration, SystemTime},
40};
41use storage::S3StorageOptionsConversion;
42use storage::{S3ObjectStoreFactory, S3StorageOptions};
43use tracing::log::*;
44use typed_builder::TypedBuilder;
45use url::Url;
46
47#[derive(Clone, Debug, Default)]
48pub struct S3LogStoreFactory {}
49
50impl S3StorageOptionsConversion for S3LogStoreFactory {}
51
52impl LogStoreFactory for S3LogStoreFactory {
53    fn with_options(
54        &self,
55        prefixed_store: ObjectStoreRef,
56        root_store: ObjectStoreRef,
57        location: &Url,
58        options: &StorageConfig,
59    ) -> DeltaResult<Arc<dyn LogStore>> {
60        let s3_options = self.with_env_s3(&options.raw.clone());
61        let s3_options = S3StorageOptions::from_map(&s3_options)?;
62        if s3_options.locking_provider.as_deref() == Some("dynamodb") {
63            debug!(
64                "S3LogStoreFactory has been asked to create a LogStore with the dynamodb locking provider"
65            );
66            return Ok(Arc::new(logstore::S3DynamoDbLogStore::try_new(
67                location.clone(),
68                options,
69                &s3_options,
70                prefixed_store,
71                root_store,
72            )?));
73        }
74        Ok(default_logstore(
75            prefixed_store,
76            root_store,
77            location,
78            options,
79        ))
80    }
81}
82
83/// Register an [ObjectStoreFactory] for common S3 url schemes.
84///
85/// [ObjectStoreFactory]: deltalake_core::logstore::ObjectStoreFactory
86pub fn register_handlers(_additional_prefixes: Option<Url>) {
87    let object_stores = Arc::new(S3ObjectStoreFactory::default());
88    let log_stores = Arc::new(S3LogStoreFactory::default());
89    for scheme in ["s3", "s3a"].iter() {
90        let url = Url::parse(&format!("{scheme}://")).unwrap();
91        object_store_factories().insert(url.clone(), object_stores.clone());
92        logstore_factories().insert(url.clone(), log_stores.clone());
93    }
94}
95
96/// Representation of a log entry stored in DynamoDb
97/// dynamo db item consists of:
98/// - table_path: String - tracked in the log store implementation
99/// - file_name: String - commit version.json (part of primary key), stored as u64 in this struct
100/// - temp_path: String - name of temporary file containing commit info
101/// - complete: bool - operation completed, i.e. atomic rename from `tempPath` to `fileName` succeeded
102/// - expire_time: `Option<SystemTime>` - epoch seconds at which this external commit entry is safe to be deleted
103#[derive(Debug, PartialEq, TypedBuilder)]
104#[builder(doc)]
105pub struct CommitEntry {
106    /// Commit version, stored as file name (e.g., 00000N.json) in dynamodb (relative to `_delta_log/`)
107    pub version: Version,
108    /// Path to temp file for this commit, relative to the `_delta_log` directory
109    #[builder(setter(into))]
110    pub temp_path: Path,
111    /// true if delta json file is successfully copied to its destination location, else false
112    #[builder(default = false)]
113    pub complete: bool,
114    /// If complete = true, epoch seconds at which this external commit entry is safe to be deleted
115    #[builder(default, setter(strip_option))]
116    pub expire_time: Option<SystemTime>,
117}
118
119/// Lock client backed by DynamoDb.
120#[derive(TypedBuilder)]
121#[builder(doc)]
122pub struct DynamoDbLockClient {
123    /// DynamoDb client
124    dynamodb_client: Client,
125    /// Configuration of the lock client
126    config: DynamoDbConfig,
127}
128
129#[cfg(test)]
130impl Default for DynamoDbLockClient {
131    fn default() -> Self {
132        let sdk_config = aws_config::SdkConfig::builder().build();
133        Self::try_new(&sdk_config, None, None, None, None, None, None, None, None)
134            .expect("Failed to create a default DynamoDbLockClient for testing purpose")
135    }
136}
137
138impl std::fmt::Debug for DynamoDbLockClient {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
140        write!(f, "DynamoDbLockClient(config: {:?})", self.config)
141    }
142}
143
144impl DynamoDbLockClient {
145    /// Creates a new DynamoDbLockClient from the supplied storage options.
146    #[allow(clippy::too_many_arguments)]
147    pub fn try_new(
148        sdk_config: &SdkConfig,
149        lock_table_name: Option<String>,
150        billing_mode: Option<String>,
151        max_elapsed_request_time: Option<String>,
152        dynamodb_override_endpoint: Option<String>,
153        dynamodb_override_region: Option<String>,
154        dynamodb_override_access_key_id: Option<String>,
155        dynamodb_override_secret_access_key: Option<String>,
156        dynamodb_override_session_token: Option<String>,
157    ) -> Result<Self, DynamoDbConfigError> {
158        let dynamodb_sdk_config = Self::create_dynamodb_sdk_config(
159            sdk_config,
160            dynamodb_override_endpoint,
161            dynamodb_override_region,
162            dynamodb_override_access_key_id,
163            dynamodb_override_secret_access_key,
164            dynamodb_override_session_token,
165        );
166
167        let dynamodb_client = aws_sdk_dynamodb::Client::new(&dynamodb_sdk_config);
168
169        let lock_table_name = lock_table_name
170            .or_else(|| std::env::var(constants::LOCK_TABLE_KEY_NAME).ok())
171            .unwrap_or(constants::DEFAULT_LOCK_TABLE_NAME.to_owned());
172
173        let billing_mode = if let Some(bm) = billing_mode
174            .or_else(|| std::env::var(constants::BILLING_MODE_KEY_NAME).ok())
175            .as_ref()
176        {
177            BillingMode::try_parse(bm.to_ascii_uppercase().as_str())
178                .map_err(|_| DynamoDbConfigError::InvalidBillingMode(String::default()))?
179        } else {
180            BillingMode::PayPerRequest
181        };
182
183        let max_elapsed_request_time = max_elapsed_request_time
184            .or_else(|| std::env::var(constants::MAX_ELAPSED_REQUEST_TIME_KEY_NAME).ok())
185            .map_or_else(
186                || Ok(Duration::from_secs(60)),
187                |secs| u64::from_str(&secs).map(Duration::from_secs),
188            )
189            .map_err(|err| DynamoDbConfigError::ParseMaxElapsedRequestTime { source: err })?;
190
191        let config = DynamoDbConfig::builder()
192            .billing_mode(billing_mode)
193            .lock_table_name(lock_table_name)
194            .max_elapsed_request_time(max_elapsed_request_time)
195            .sdk_config(sdk_config.clone())
196            .build();
197        Ok(Self::builder()
198            .dynamodb_client(dynamodb_client)
199            .config(config)
200            .build())
201    }
202    fn create_dynamodb_sdk_config(
203        sdk_config: &SdkConfig,
204        dynamodb_override_endpoint: Option<String>,
205        dynamodb_override_region: Option<String>,
206        dynamodb_override_access_key_id: Option<String>,
207        dynamodb_override_secret_access_key: Option<String>,
208        dynamodb_override_session_token: Option<String>,
209    ) -> SdkConfig {
210        /*
211        if dynamodb_override_endpoint exists/AWS_ENDPOINT_URL_DYNAMODB is specified by user
212        override the endpoint in the sdk_config
213        if dynamodb_override_region exists/AWS_REGION_DYNAMODB is specified by user
214        override the region in the sdk_config
215        if dynamodb_override_access_key_id exists/AWS_ACCESS_KEY_ID_DYNAMODB is specified by user
216        override the access_key_id in the sdk_config
217        if dynamodb_override_secret_access_key exists/AWS_SECRET_ACCESS_KEY_DYNAMODB is specified by user
218        override the secret_access_key in the sdk_config
219        */
220
221        let mut config_builder = sdk_config.to_owned().to_builder();
222
223        if let Some(dynamodb_endpoint_url) = dynamodb_override_endpoint {
224            config_builder = config_builder.endpoint_url(dynamodb_endpoint_url);
225        }
226
227        if let Some(dynamodb_region) = dynamodb_override_region {
228            config_builder = config_builder.region(Region::new(dynamodb_region));
229        }
230
231        if let (Some(access_key_id), Some(secret_access_key)) = (
232            dynamodb_override_access_key_id,
233            dynamodb_override_secret_access_key,
234        ) {
235            config_builder = config_builder.credentials_provider(SharedCredentialsProvider::new(
236                aws_credential_types::Credentials::from_keys(
237                    access_key_id,
238                    secret_access_key,
239                    dynamodb_override_session_token,
240                ),
241            ));
242        }
243        config_builder.build()
244    }
245
246    /// Create the lock table where DynamoDb stores the commit information for all delta tables.
247    ///
248    /// Transparently handles the case where that table already exists, so it's safe to call.
249    /// After `create_table` operation is executed, the table state in DynamoDb is `creating`, and
250    /// it's not immediately usable. This method does not wait for the table state to become
251    /// `active`, so transient failures might occur when immediately using the lock client.
252    pub async fn try_create_lock_table(&self) -> Result<CreateLockTableResult, LockClientError> {
253        let attribute_definitions = vec![
254            AttributeDefinition::builder()
255                .attribute_name(constants::ATTR_TABLE_PATH)
256                .attribute_type(ScalarAttributeType::S)
257                .build()
258                .unwrap(),
259            AttributeDefinition::builder()
260                .attribute_name(constants::ATTR_FILE_NAME)
261                .attribute_type(ScalarAttributeType::S)
262                .build()
263                .unwrap(),
264        ];
265        let request = self
266            .dynamodb_client
267            .create_table()
268            .set_attribute_definitions(Some(attribute_definitions))
269            .set_key_schema(Some(vec![
270                KeySchemaElement::builder()
271                    .attribute_name(constants::ATTR_TABLE_PATH.to_owned())
272                    .key_type(KeyType::Hash)
273                    .build()
274                    .unwrap(),
275                KeySchemaElement::builder()
276                    .attribute_name(constants::ATTR_FILE_NAME.to_owned())
277                    .key_type(KeyType::Range)
278                    .build()
279                    .unwrap(),
280            ]))
281            .billing_mode(self.config.billing_mode.clone())
282            .table_name(&self.config.lock_table_name)
283            .send();
284        match request.await {
285            Ok(_) => Ok(CreateLockTableResult::TableCreated),
286            Err(sdk_err) => match sdk_err.as_service_error() {
287                Some(CreateTableError::ResourceInUseException(_)) => {
288                    Ok(CreateLockTableResult::TableAlreadyExists)
289                }
290                Some(_) => Err(LockClientError::LockTableCreateFailure {
291                    name: self.config.lock_table_name.clone(),
292                    source: Box::new(sdk_err.into_service_error()),
293                }),
294                _ => Err(LockClientError::GenericDynamoDb {
295                    source: Box::new(sdk_err),
296                }),
297            },
298        }
299    }
300
301    /// Get the name of the lock table for transactional commits used by the DynamoDb lock client.
302    pub fn get_lock_table_name(&self) -> String {
303        self.config.lock_table_name.clone()
304    }
305
306    pub fn get_dynamodb_config(&self) -> &DynamoDbConfig {
307        &self.config
308    }
309
310    /// Read a log entry from DynamoDb.
311    pub async fn get_commit_entry(
312        &self,
313        table_path: &str,
314        version: Version,
315    ) -> Result<Option<CommitEntry>, LockClientError> {
316        let item = self
317            .retry(
318                || async {
319                    self.dynamodb_client
320                        .get_item()
321                        .consistent_read(true)
322                        .table_name(&self.config.lock_table_name)
323                        .set_key(Some(get_primary_key(version, table_path)))
324                        .send()
325                        .await
326                },
327                |err| {
328                    matches!(
329                        err.as_service_error(),
330                        Some(GetItemError::ProvisionedThroughputExceededException(_))
331                    )
332                },
333            )
334            .await
335            .map_err(|err| match err.as_service_error() {
336                Some(GetItemError::ProvisionedThroughputExceededException(_)) => {
337                    LockClientError::ProvisionedThroughputExceeded
338                }
339                _ => err.into(),
340            })?;
341        item.item.as_ref().map(CommitEntry::try_from).transpose()
342    }
343
344    /// write new entry to to DynamoDb lock table.
345    pub async fn put_commit_entry(
346        &self,
347        table_path: &str,
348        entry: &CommitEntry,
349    ) -> Result<(), LockClientError> {
350        self.retry(
351            || async {
352                let item = create_value_map(entry, table_path);
353                let _ = self
354                    .dynamodb_client
355                    .put_item()
356                    .condition_expression(constants::CONDITION_EXPR_CREATE.as_str())
357                    .table_name(self.get_lock_table_name())
358                    .set_item(Some(item))
359                    .send()
360                    .await?;
361                Ok(())
362            },
363            |err: &SdkError<_, _>| {
364                matches!(
365                    err.as_service_error(),
366                    Some(PutItemError::ProvisionedThroughputExceededException(_))
367                )
368            },
369        )
370        .await
371        .map_err(|err| match err.as_service_error() {
372            Some(PutItemError::ProvisionedThroughputExceededException(_)) => {
373                LockClientError::ProvisionedThroughputExceeded
374            }
375            Some(PutItemError::ConditionalCheckFailedException(_)) => {
376                LockClientError::VersionAlreadyExists {
377                    table_path: table_path.to_owned(),
378                    version: entry.version,
379                }
380            }
381            Some(PutItemError::ResourceNotFoundException(_)) => LockClientError::LockTableNotFound,
382            _ => err.into(),
383        })
384    }
385
386    /// Get the latest entry (entry with highest version).
387    pub async fn get_latest_entry(
388        &self,
389        table_path: &str,
390    ) -> Result<Option<CommitEntry>, LockClientError> {
391        Ok(self
392            .get_latest_entries(table_path, 1)
393            .await?
394            .into_iter()
395            .next())
396    }
397
398    /// Find the latest entry in the lock table for the delta table on the specified `table_path`.
399    pub async fn get_latest_entries(
400        &self,
401        table_path: &str,
402        limit: u64,
403    ) -> Result<Vec<CommitEntry>, LockClientError> {
404        let query_result = self
405            .retry(
406                || async {
407                    self.dynamodb_client
408                        .query()
409                        .table_name(self.get_lock_table_name())
410                        .consistent_read(true)
411                        .limit(limit.try_into().unwrap_or(i32::MAX))
412                        .scan_index_forward(false)
413                        .key_condition_expression(format!("{} = :tn", constants::ATTR_TABLE_PATH))
414                        .set_expression_attribute_values(Some(HashMap::from([(
415                            ":tn".into(),
416                            // NOTE: the lack of trailing slashes is a load-bearing implementation
417                            // detail between the Delta/Spark and delta-rs S3DynamoDbLogStore
418                            string_attr(table_path.trim_end_matches('/')),
419                        )])))
420                        .send()
421                        .await
422                },
423                |err: &SdkError<_, _>| {
424                    matches!(
425                        err.as_service_error(),
426                        Some(QueryError::ProvisionedThroughputExceededException(_))
427                    )
428                },
429            )
430            .await
431            .map_err(|err| match err.as_service_error() {
432                Some(QueryError::ProvisionedThroughputExceededException(_)) => {
433                    LockClientError::ProvisionedThroughputExceeded
434                }
435                _ => err.into(),
436            })?;
437
438        query_result
439            .items
440            .unwrap()
441            .iter()
442            .map(CommitEntry::try_from)
443            .collect()
444    }
445
446    /// Update existing log entry
447    pub async fn update_commit_entry(
448        &self,
449        version: Version,
450        table_path: &str,
451    ) -> Result<UpdateLogEntryResult, LockClientError> {
452        let seconds_since_epoch = (SystemTime::now()
453            + constants::DEFAULT_COMMIT_ENTRY_EXPIRATION_DELAY)
454            .duration_since(SystemTime::UNIX_EPOCH)
455            .unwrap()
456            .as_secs();
457        let res = self
458            .retry(
459                || async {
460                    let _ = self
461                        .dynamodb_client
462                        .update_item()
463                        .table_name(self.get_lock_table_name())
464                        .set_key(Some(get_primary_key(version, table_path)))
465                        .update_expression("SET complete = :c, expireTime = :e".to_owned())
466                        .set_expression_attribute_values(Some(HashMap::from([
467                            (":c".to_owned(), string_attr("true")),
468                            (":e".to_owned(), num_attr(seconds_since_epoch)),
469                            (":f".into(), string_attr("false")),
470                        ])))
471                        .condition_expression(constants::CONDITION_UPDATE_INCOMPLETE)
472                        .send()
473                        .await?;
474                    Ok(())
475                },
476                |err: &SdkError<_, _>| {
477                    matches!(
478                        err.as_service_error(),
479                        Some(UpdateItemError::ProvisionedThroughputExceededException(_))
480                    )
481                },
482            )
483            .await;
484
485        match res {
486            Ok(()) => Ok(UpdateLogEntryResult::UpdatePerformed),
487            Err(err) => match err.as_service_error() {
488                Some(UpdateItemError::ProvisionedThroughputExceededException(_)) => {
489                    Err(LockClientError::ProvisionedThroughputExceeded)
490                }
491                Some(UpdateItemError::ConditionalCheckFailedException(_)) => {
492                    Ok(UpdateLogEntryResult::AlreadyCompleted)
493                }
494                _ => Err(err.into()),
495            },
496        }
497    }
498
499    /// Delete existing log entry if it is not already complete
500    pub async fn delete_commit_entry(
501        &self,
502        version: Version,
503        table_path: &str,
504    ) -> Result<(), LockClientError> {
505        self.retry(
506            || async {
507                let _ = self
508                    .dynamodb_client
509                    .delete_item()
510                    .table_name(self.get_lock_table_name())
511                    .set_key(Some(get_primary_key(version, table_path)))
512                    .set_expression_attribute_values(Some(HashMap::from([(
513                        ":f".into(),
514                        string_attr("false"),
515                    )])))
516                    .condition_expression(constants::CONDITION_DELETE_INCOMPLETE.as_str())
517                    .send()
518                    .await?;
519                Ok(())
520            },
521            |err: &SdkError<_, _>| {
522                matches!(
523                    err.as_service_error(),
524                    Some(DeleteItemError::ProvisionedThroughputExceededException(_))
525                )
526            },
527        )
528        .await
529        .map_err(|err| match err.as_service_error() {
530            Some(DeleteItemError::ProvisionedThroughputExceededException(_)) => {
531                LockClientError::ProvisionedThroughputExceeded
532            }
533            Some(DeleteItemError::ConditionalCheckFailedException(_)) => {
534                LockClientError::VersionAlreadyCompleted {
535                    table_path: table_path.to_owned(),
536                    version,
537                }
538            }
539            _ => err.into(),
540        })
541    }
542
543    async fn retry<I, E, F, Fut, Wn>(&self, operation: F, when: Wn) -> Result<I, E>
544    where
545        F: FnMut() -> Fut,
546        Fut: std::future::Future<Output = Result<I, E>>,
547        Wn: Fn(&E) -> bool,
548    {
549        use backon::Retryable;
550        let backoff = backon::ExponentialBuilder::default()
551            .with_factor(2.)
552            .with_max_delay(self.config.max_elapsed_request_time);
553        operation.retry(backoff).when(when).await
554    }
555}
556
557#[derive(Debug, PartialEq)]
558pub enum UpdateLogEntryResult {
559    UpdatePerformed,
560    AlreadyCompleted,
561}
562
563impl TryFrom<&HashMap<String, AttributeValue>> for CommitEntry {
564    type Error = LockClientError;
565
566    fn try_from(item: &HashMap<String, AttributeValue>) -> Result<Self, Self::Error> {
567        let version_str = extract_required_string_field(item, constants::ATTR_FILE_NAME)?;
568        let version = extract_version_from_filename(version_str).ok_or_else(|| {
569            LockClientError::InconsistentData {
570                description: format!(
571                    "invalid log file name: can't extract version number from '{version_str}'"
572                ),
573            }
574        })?;
575        let temp_path = extract_required_string_field(item, constants::ATTR_TEMP_PATH)?;
576        let temp_path =
577            Path::from_iter(DELTA_LOG_PATH.parts().chain(Path::from(temp_path).parts()));
578        let expire_time: Option<SystemTime> =
579            extract_optional_number_field(item, constants::ATTR_EXPIRE_TIME)?
580                .map(|s| {
581                    s.parse::<u64>()
582                        .map_err(|err| LockClientError::InconsistentData {
583                            description: format!("conversion to number failed, {err}"),
584                        })
585                })
586                .transpose()?
587                .map(epoch_to_system_time);
588        let complete = extract_required_string_field(item, constants::ATTR_COMPLETE)? == "true";
589
590        Ok(Self {
591            version,
592            temp_path,
593            complete,
594            expire_time,
595        })
596    }
597}
598
599fn system_time_to_epoch(t: &SystemTime) -> u64 {
600    t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs()
601}
602
603fn epoch_to_system_time(s: u64) -> SystemTime {
604    SystemTime::UNIX_EPOCH + Duration::from_secs(s)
605}
606
607/// Return the primary key as a [HashMap] for looking up log entries in the DynamoDb table
608///
609/// The `table_path` needs to be sent into DynamoDB without a trailing slash for the [Url] since
610/// that is a load-bearing part of the contract with Delta/Spark's implementation.
611fn get_primary_key(version: Version, table_path: &str) -> HashMap<String, AttributeValue> {
612    HashMap::from([
613        (
614            constants::ATTR_TABLE_PATH.to_owned(),
615            string_attr(table_path.trim_end_matches('/')),
616        ),
617        (
618            constants::ATTR_FILE_NAME.to_owned(),
619            string_attr(format!("{version:020}.json")),
620        ),
621    ])
622}
623
624fn create_value_map(
625    commit_entry: &CommitEntry,
626    table_path: &str,
627) -> HashMap<String, AttributeValue> {
628    // cut off `_delta_log` part: temp_path in DynamoDb is relative to `_delta_log` not table root.
629    let temp_path = Path::from_iter(commit_entry.temp_path.parts().skip(1));
630    let mut value_map = get_primary_key(commit_entry.version, table_path);
631
632    value_map.extend(HashMap::from([
633        (constants::ATTR_TEMP_PATH.to_owned(), string_attr(temp_path)),
634        (
635            constants::ATTR_COMPLETE.to_owned(),
636            string_attr(if commit_entry.complete {
637                "true"
638            } else {
639                "false"
640            }),
641        ),
642    ]));
643    commit_entry.expire_time.as_ref().map(|t| {
644        value_map.insert(
645            constants::ATTR_EXPIRE_TIME.to_owned(),
646            num_attr(system_time_to_epoch(t)),
647        )
648    });
649    value_map
650}
651
652/// Configuration for DynamoDb lock client
653#[derive(Debug, TypedBuilder)]
654#[builder(doc)]
655pub struct DynamoDbConfig {
656    /// Billing mode for the DynamoDb table
657    pub billing_mode: BillingMode,
658    /// Name of the lock table
659    #[builder(setter(into))]
660    pub lock_table_name: String,
661    /// Maximum time to wait for DynamoDB requests
662    pub max_elapsed_request_time: Duration,
663    /// AWS SDK configuration
664    pub sdk_config: SdkConfig,
665}
666
667impl Eq for DynamoDbConfig {}
668impl PartialEq for DynamoDbConfig {
669    fn eq(&self, other: &Self) -> bool {
670        self.billing_mode == other.billing_mode
671            && self.lock_table_name == other.lock_table_name
672            && self.max_elapsed_request_time == other.max_elapsed_request_time
673            && self.sdk_config.endpoint_url() == other.sdk_config.endpoint_url()
674            && self.sdk_config.region() == other.sdk_config.region()
675    }
676}
677
678/// Represents the possible, positive outcomes of calling `DynamoDbClient::try_create_lock_table()`
679#[derive(Debug, PartialEq)]
680pub enum CreateLockTableResult {
681    /// Table created successfully.
682    TableCreated,
683    /// Table was not created because it already exists.
684    /// Does not imply that the table has the correct schema.
685    TableAlreadyExists,
686}
687
688/// Extract a field from an item's attribute value map, producing a descriptive error
689/// of the various failure cases.
690fn extract_required_string_field<'a>(
691    fields: &'a HashMap<String, AttributeValue>,
692    field_name: &str,
693) -> Result<&'a str, LockClientError> {
694    fields
695        .get(field_name)
696        .ok_or_else(|| LockClientError::InconsistentData {
697            description: format!("mandatory string field '{field_name}' missing"),
698        })?
699        .as_s()
700        .map_err(|v| LockClientError::InconsistentData {
701            description: format!(
702                "mandatory string field '{field_name}' exists, but is not a string: {v:#?}",
703            ),
704        })
705        .map(|s| s.as_str())
706}
707
708/// Extract an optional String field from an item's attribute value map.
709/// This call fails if the field exists, but is not of type string.
710fn extract_optional_number_field<'a>(
711    fields: &'a HashMap<String, AttributeValue>,
712    field_name: &str,
713) -> Result<Option<&'a String>, LockClientError> {
714    fields
715        .get(field_name)
716        .map(|attr| {
717            attr.as_n().map_err(|_| LockClientError::InconsistentData {
718                description: format!(
719                    "field with name '{field_name}' exists, but is not of type number"
720                ),
721            })
722        })
723        .transpose()
724}
725
726fn string_attr<T: ToString>(s: T) -> AttributeValue {
727    AttributeValue::S(s.to_string())
728}
729
730fn num_attr<T: ToString>(n: T) -> AttributeValue {
731    AttributeValue::N(n.to_string())
732}
733
734static DELTA_LOG_PATH: LazyLock<Path> = LazyLock::new(|| Path::from("_delta_log"));
735static DELTA_LOG_REGEX: LazyLock<Regex> =
736    LazyLock::new(|| Regex::new(r"(\d{20})\.(json|checkpoint).*$").unwrap());
737
738/// Extract version from a file name in the delta log
739fn extract_version_from_filename(name: &str) -> Option<Version> {
740    DELTA_LOG_REGEX
741        .captures(name)
742        .map(|captures| captures.get(1).unwrap().as_str().parse().unwrap())
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use aws_sdk_sts::config::ProvideCredentials;
749
750    use pretty_assertions::assert_eq;
751
752    use object_store::memory::InMemory;
753    use serial_test::serial;
754
755    fn commit_entry_roundtrip(c: &CommitEntry) -> Result<(), LockClientError> {
756        let item_data: HashMap<String, AttributeValue> = create_value_map(c, "some_table");
757        let c_parsed = CommitEntry::try_from(&item_data)?;
758        assert_eq!(c, &c_parsed);
759        Ok(())
760    }
761
762    #[test]
763    fn test_get_primary_key() -> DeltaResult<()> {
764        let version = 0;
765        let expected = HashMap::from([
766            (
767                constants::ATTR_TABLE_PATH.to_owned(),
768                // NOTE: the lack of a trailing slash is important for compatibility with the
769                // Delta/Spark S3DynamoDbLogStore
770                string_attr("s3://bucket/table"),
771            ),
772            (
773                constants::ATTR_FILE_NAME.to_owned(),
774                string_attr(format!("{version:020}.json")),
775            ),
776        ]);
777
778        assert_eq!(expected, get_primary_key(version, "s3://bucket/table"));
779        assert_eq!(expected, get_primary_key(version, "s3://bucket/table/"));
780        Ok(())
781    }
782
783    #[test]
784    fn commit_entry_roundtrip_test() -> Result<(), LockClientError> {
785        let system_time = SystemTime::UNIX_EPOCH
786            + Duration::from_secs(
787                SystemTime::now()
788                    .duration_since(SystemTime::UNIX_EPOCH)
789                    .unwrap()
790                    .as_secs(),
791            );
792        commit_entry_roundtrip(
793            &CommitEntry::builder()
794                .version(0)
795                .temp_path(Path::from("_delta_log/tmp/0_abc.json"))
796                .complete(true)
797                .expire_time(system_time)
798                .build(),
799        )?;
800        commit_entry_roundtrip(
801            &CommitEntry::builder()
802                .version(139)
803                .temp_path(Path::from("_delta_log/tmp/0_abc.json"))
804                .build(),
805        )?;
806        Ok(())
807    }
808
809    /// In cases where there is no dynamodb specified locking provider, this should get a default
810    /// logstore
811    #[test]
812    #[serial]
813    fn test_logstore_factory_default() {
814        let factory = S3LogStoreFactory::default();
815        let store = Arc::new(InMemory::new());
816        let url = Url::parse("s3://test-bucket").unwrap();
817        unsafe {
818            std::env::remove_var(crate::constants::AWS_S3_LOCKING_PROVIDER);
819        }
820        let logstore = factory
821            .with_options(store.clone(), store, &url, &Default::default())
822            .unwrap();
823        assert_eq!(logstore.name(), "DefaultLogStore");
824    }
825
826    #[test]
827    #[serial]
828    fn test_logstore_factory_with_locking_provider() {
829        let factory = S3LogStoreFactory::default();
830        let store = Arc::new(InMemory::new());
831        let url = Url::parse("s3://test-bucket").unwrap();
832        unsafe {
833            std::env::set_var(crate::constants::AWS_S3_LOCKING_PROVIDER, "dynamodb");
834        }
835
836        let logstore = factory
837            .with_options(store.clone(), store, &url, &Default::default())
838            .unwrap();
839        assert_eq!(logstore.name(), "S3DynamoDbLogStore");
840    }
841
842    #[test]
843    #[serial]
844    fn test_create_dynamodb_sdk_config() {
845        let sdk_config = SdkConfig::builder()
846            .region(Region::from_static("eu-west-1"))
847            .endpoint_url("http://localhost:1234")
848            .build();
849        let dynamodb_sdk_config = DynamoDbLockClient::create_dynamodb_sdk_config(
850            &sdk_config,
851            Some("http://localhost:2345".to_string()),
852            None,
853            None,
854            None,
855            None,
856        );
857        assert_eq!(
858            dynamodb_sdk_config.endpoint_url(),
859            Some("http://localhost:2345"),
860        );
861        assert_eq!(
862            dynamodb_sdk_config.region().unwrap().to_string(),
863            "eu-west-1".to_string(),
864        );
865        let dynamodb_sdk_no_override_config = DynamoDbLockClient::create_dynamodb_sdk_config(
866            &sdk_config,
867            None,
868            None,
869            None,
870            None,
871            None,
872        );
873        assert_eq!(
874            dynamodb_sdk_no_override_config.endpoint_url(),
875            Some("http://localhost:1234"),
876        );
877    }
878
879    #[tokio::test]
880    #[serial]
881    async fn test_create_dynamodb_sdk_config_override_credentials() {
882        let sdk_config = SdkConfig::builder()
883            .region(Region::from_static("eu-west-1"))
884            .endpoint_url("http://localhost:1234")
885            .build();
886        let dynamodb_sdk_config = DynamoDbLockClient::create_dynamodb_sdk_config(
887            &sdk_config,
888            Some("http://localhost:2345".to_string()),
889            Some("us-west-1".to_string()),
890            Some("access_key_dynamodb".to_string()),
891            Some("secret_access_key_dynamodb".to_string()),
892            None,
893        );
894        assert_eq!(
895            dynamodb_sdk_config.endpoint_url(),
896            Some("http://localhost:2345"),
897        );
898        assert_eq!(
899            dynamodb_sdk_config.region().unwrap().to_string(),
900            "us-west-1".to_string(),
901        );
902
903        // check that access key and secret access key are overridden
904        let credentials_provider = dynamodb_sdk_config
905            .credentials_provider()
906            .unwrap()
907            .provide_credentials()
908            .await
909            .unwrap();
910
911        assert_eq!(credentials_provider.access_key_id(), "access_key_dynamodb");
912        assert_eq!(
913            credentials_provider.secret_access_key(),
914            "secret_access_key_dynamodb"
915        );
916
917        let dynamodb_sdk_no_override_config = DynamoDbLockClient::create_dynamodb_sdk_config(
918            &sdk_config,
919            None,
920            None,
921            None,
922            None,
923            None,
924        );
925        assert_eq!(
926            dynamodb_sdk_no_override_config.endpoint_url(),
927            Some("http://localhost:1234"),
928        );
929    }
930}