cachey 0.10.8

Read-through cache for object storage
Documentation
use std::time::Duration;

use aws_sdk_s3::config::{retry::RetryConfig, timeout::TimeoutConfig};

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RequestConfig {
    pub connect_timeout: Option<Duration>,
    pub read_timeout: Option<Duration>,
    pub operation_timeout: Option<Duration>,
    pub operation_attempt_timeout: Option<Duration>,
    pub max_attempts: Option<u32>,
    pub initial_backoff: Option<Duration>,
    pub max_backoff: Option<Duration>,
}

impl RequestConfig {
    #[must_use]
    pub fn is_noop(&self) -> bool {
        self.connect_timeout.is_none()
            && self.read_timeout.is_none()
            && self.operation_timeout.is_none()
            && self.operation_attempt_timeout.is_none()
            && self.max_attempts.is_none()
            && self.initial_backoff.is_none()
            && self.max_backoff.is_none()
    }

    fn has_timeout_overrides(&self) -> bool {
        self.connect_timeout.is_some()
            || self.read_timeout.is_some()
            || self.operation_timeout.is_some()
            || self.operation_attempt_timeout.is_some()
    }

    fn has_retry_overrides(&self) -> bool {
        self.max_attempts.is_some() || self.initial_backoff.is_some() || self.max_backoff.is_some()
    }

    #[must_use]
    pub fn merged_timeout_config(&self, base: Option<&TimeoutConfig>) -> Option<TimeoutConfig> {
        if !self.has_timeout_overrides() {
            return None;
        }

        let mut builder = base.map_or_else(TimeoutConfig::builder, TimeoutConfig::to_builder);

        if let Some(connect_timeout) = self.connect_timeout {
            builder = builder.connect_timeout(connect_timeout);
        }
        if let Some(read_timeout) = self.read_timeout {
            builder = builder.read_timeout(read_timeout);
        }
        if let Some(timeout) = self.operation_timeout {
            builder = builder.operation_timeout(timeout);
        }
        if let Some(attempt_timeout) = self.operation_attempt_timeout {
            builder = builder.operation_attempt_timeout(attempt_timeout);
        }

        Some(builder.build())
    }

    #[must_use]
    pub fn merged_retry_config(&self, base: Option<&RetryConfig>) -> Option<RetryConfig> {
        if !self.has_retry_overrides() {
            return None;
        }

        let mut config = base.cloned().unwrap_or_else(RetryConfig::standard);

        if let Some(max_attempts) = self.max_attempts {
            config = config.with_max_attempts(max_attempts);
        }
        if let Some(initial_backoff) = self.initial_backoff {
            config = config.with_initial_backoff(initial_backoff);
        }
        if let Some(max_backoff) = self.max_backoff {
            config = config.with_max_backoff(max_backoff);
        }

        Some(config)
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use aws_sdk_s3::config::{retry::RetryConfig, timeout::TimeoutConfig};

    use crate::object_store::config::RequestConfig;

    #[test]
    fn merged_timeout_config_preserves_unset_base_fields() {
        let base = TimeoutConfig::builder()
            .connect_timeout(Duration::from_secs(10))
            .read_timeout(Duration::from_secs(30))
            .operation_timeout(Duration::from_mins(1))
            .operation_attempt_timeout(Duration::from_secs(20))
            .build();
        let request = RequestConfig {
            connect_timeout: Some(Duration::from_secs(5)),
            ..RequestConfig::default()
        };

        let merged = request
            .merged_timeout_config(Some(&base))
            .expect("timeout overrides are set");

        assert_eq!(merged.connect_timeout(), Some(Duration::from_secs(5)));
        assert_eq!(merged.read_timeout(), Some(Duration::from_secs(30)));
        assert_eq!(merged.operation_timeout(), Some(Duration::from_mins(1)));
        assert_eq!(
            merged.operation_attempt_timeout(),
            Some(Duration::from_secs(20))
        );
    }

    #[test]
    fn merged_timeout_config_without_timeout_overrides_is_none() {
        let request = RequestConfig {
            max_attempts: Some(5),
            ..RequestConfig::default()
        };

        assert!(request.merged_timeout_config(None).is_none());
    }

    #[test]
    fn merged_retry_config_preserves_unset_base_fields() {
        let base = RetryConfig::standard()
            .with_max_attempts(7)
            .with_initial_backoff(Duration::from_millis(250))
            .with_max_backoff(Duration::from_mins(1));
        let request = RequestConfig {
            max_attempts: Some(5),
            ..RequestConfig::default()
        };

        let merged = request
            .merged_retry_config(Some(&base))
            .expect("retry overrides are set");

        assert_eq!(merged.max_attempts(), 5);
        assert_eq!(merged.initial_backoff(), Duration::from_millis(250));
        assert_eq!(merged.max_backoff(), Duration::from_mins(1));
    }

    #[test]
    fn merged_retry_config_without_base_uses_standard_defaults() {
        let request = RequestConfig {
            max_attempts: Some(9),
            ..RequestConfig::default()
        };

        let merged = request
            .merged_retry_config(None)
            .expect("retry overrides are set");

        assert_eq!(merged.max_attempts(), 9);
        assert_eq!(merged.initial_backoff(), Duration::from_secs(1));
        assert_eq!(merged.max_backoff(), Duration::from_secs(20));
    }

    #[test]
    fn merged_retry_config_without_retry_overrides_is_none() {
        let request = RequestConfig {
            connect_timeout: Some(Duration::from_secs(5)),
            ..RequestConfig::default()
        };

        assert!(request.merged_retry_config(None).is_none());
    }
}