The storage crate had its own RetryConfig struct (max_attempts, initial_delay, max_delay, backoff_multiplier) duplicating common::resilience::retry::RetryConfig. Added backoff_multiplier field to common's RetryConfig and updated storage to re-export and use common's version with its field names (max_retries, base_delay). Updated all storage tests accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
735 lines
23 KiB
Rust
735 lines
23 KiB
Rust
//! Comprehensive tests for S3 retry logic and error handling
|
|
//!
|
|
//! Tests cover:
|
|
//! - Upload retry scenarios (transient failures)
|
|
//! - Download failure recovery
|
|
//! - Network timeout handling
|
|
//! - Connection failure scenarios
|
|
//! - Metadata operation failures
|
|
//! - List operation failures
|
|
//! - Retry backoff behavior
|
|
//! - Error categorization
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use bytes::Bytes;
|
|
use object_store::memory::InMemory;
|
|
use object_store::path::Path;
|
|
use object_store::{
|
|
Error as ObjectStoreError, GetOptions, GetResult, ListResult, ObjectMeta,
|
|
ObjectStore, PutOptions, PutResult,
|
|
};
|
|
use storage::model_helpers::RetryConfig;
|
|
use storage::Storage;
|
|
use tokio::sync::Mutex;
|
|
|
|
/// Mock ObjectStore that simulates transient failures
|
|
#[derive(Debug)]
|
|
struct FailingObjectStore {
|
|
inner: Arc<InMemory>,
|
|
/// Number of failures before success
|
|
failures_before_success: Arc<Mutex<usize>>,
|
|
/// Count of attempts made
|
|
attempt_count: Arc<AtomicUsize>,
|
|
/// Type of error to simulate
|
|
error_type: Arc<Mutex<ErrorType>>,
|
|
}
|
|
|
|
impl std::fmt::Display for FailingObjectStore {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "FailingObjectStore")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum ErrorType {
|
|
NotFound,
|
|
Generic,
|
|
Unauthenticated,
|
|
}
|
|
|
|
impl FailingObjectStore {
|
|
fn new(failures: usize, error_type: ErrorType) -> Self {
|
|
Self {
|
|
inner: Arc::new(InMemory::new()),
|
|
failures_before_success: Arc::new(Mutex::new(failures)),
|
|
attempt_count: Arc::new(AtomicUsize::new(0)),
|
|
error_type: Arc::new(Mutex::new(error_type)),
|
|
}
|
|
}
|
|
|
|
async fn should_fail(&self) -> bool {
|
|
let mut failures = self.failures_before_success.lock().await;
|
|
if *failures > 0 {
|
|
*failures -= 1;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn get_attempt_count(&self) -> usize {
|
|
self.attempt_count.load(Ordering::SeqCst)
|
|
}
|
|
|
|
async fn get_error(&self) -> ObjectStoreError {
|
|
let error_type = *self.error_type.lock().await;
|
|
match error_type {
|
|
ErrorType::NotFound => ObjectStoreError::NotFound {
|
|
path: "test/path".to_string(),
|
|
source: Box::new(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"not found",
|
|
)),
|
|
},
|
|
ErrorType::Generic => ObjectStoreError::Generic {
|
|
store: "test",
|
|
source: Box::new(std::io::Error::other("generic error")),
|
|
},
|
|
ErrorType::Unauthenticated => ObjectStoreError::Unauthenticated {
|
|
path: "test/path".to_string(),
|
|
source: Box::new(std::io::Error::new(
|
|
std::io::ErrorKind::PermissionDenied,
|
|
"unauthenticated",
|
|
)),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ObjectStore for FailingObjectStore {
|
|
async fn put(&self, location: &Path, payload: PutPayload) -> object_store::Result<PutResult> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.put(location, payload).await
|
|
}
|
|
}
|
|
|
|
async fn put_opts(
|
|
&self,
|
|
location: &Path,
|
|
payload: PutPayload,
|
|
opts: PutOptions,
|
|
) -> object_store::Result<PutResult> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.put_opts(location, payload, opts).await
|
|
}
|
|
}
|
|
|
|
async fn get(&self, location: &Path) -> object_store::Result<GetResult> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.get(location).await
|
|
}
|
|
}
|
|
|
|
async fn get_opts(
|
|
&self,
|
|
location: &Path,
|
|
options: GetOptions,
|
|
) -> object_store::Result<GetResult> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.get_opts(location, options).await
|
|
}
|
|
}
|
|
|
|
async fn get_range(
|
|
&self,
|
|
location: &Path,
|
|
range: std::ops::Range<usize>,
|
|
) -> object_store::Result<Bytes> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.get_range(location, range).await
|
|
}
|
|
}
|
|
|
|
async fn head(&self, location: &Path) -> object_store::Result<ObjectMeta> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.head(location).await
|
|
}
|
|
}
|
|
|
|
async fn delete(&self, location: &Path) -> object_store::Result<()> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.delete(location).await
|
|
}
|
|
}
|
|
|
|
fn list(
|
|
&self,
|
|
prefix: Option<&Path>,
|
|
) -> futures::stream::BoxStream<'_, object_store::Result<ObjectMeta>> {
|
|
// For simplicity, list doesn't fail in this mock
|
|
self.inner.list(prefix)
|
|
}
|
|
|
|
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.list_with_delimiter(prefix).await
|
|
}
|
|
}
|
|
|
|
async fn copy(&self, from: &Path, to: &Path) -> object_store::Result<()> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.copy(from, to).await
|
|
}
|
|
}
|
|
|
|
async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> object_store::Result<()> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.copy_if_not_exists(from, to).await
|
|
}
|
|
}
|
|
|
|
async fn put_multipart_opts(
|
|
&self,
|
|
location: &Path,
|
|
opts: object_store::PutMultipartOpts,
|
|
) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
|
|
self.attempt_count.fetch_add(1, Ordering::SeqCst);
|
|
if self.should_fail().await {
|
|
Err(self.get_error().await)
|
|
} else {
|
|
self.inner.put_multipart_opts(location, opts).await
|
|
}
|
|
}
|
|
}
|
|
|
|
use object_store::PutPayload;
|
|
|
|
// Test 1: Upload retry with transient failures
|
|
#[tokio::test]
|
|
async fn test_upload_retry_transient_failures() {
|
|
// Configure to fail twice, then succeed
|
|
let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let test_data = b"test data for retry";
|
|
let result = backend.store("test/retry.txt", test_data).await;
|
|
|
|
// Should succeed on third attempt
|
|
assert!(result.is_ok(), "Upload should succeed after retries");
|
|
assert_eq!(
|
|
store.get_attempt_count(),
|
|
3,
|
|
"Should have made exactly 3 attempts"
|
|
);
|
|
}
|
|
|
|
// Test 2: Upload failure after exhausting retries
|
|
#[tokio::test]
|
|
async fn test_upload_failure_max_retries_exceeded() {
|
|
// Configure to fail 5 times (more than max retries)
|
|
let store = Arc::new(FailingObjectStore::new(5, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let test_data = b"test data that will fail";
|
|
let result = backend.store("test/fail.txt", test_data).await;
|
|
|
|
// Should fail after max retries
|
|
assert!(
|
|
result.is_err(),
|
|
"Upload should fail after exhausting retries"
|
|
);
|
|
assert_eq!(
|
|
store.get_attempt_count(),
|
|
3,
|
|
"Should have made exactly max_retries"
|
|
);
|
|
}
|
|
|
|
// Test 3: Download retry with transient failures
|
|
#[tokio::test]
|
|
async fn test_download_retry_transient_failures() {
|
|
// First store data successfully
|
|
let inner_store = Arc::new(InMemory::new());
|
|
let test_data = b"test data for download";
|
|
inner_store
|
|
.put(
|
|
&Path::from("test/download.txt"),
|
|
Bytes::from_static(test_data).into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create failing store that wraps the inner store
|
|
let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic));
|
|
// Manually store the data in the failing store's inner store
|
|
store
|
|
.inner
|
|
.put(
|
|
&Path::from("test/download.txt"),
|
|
Bytes::from_static(test_data).into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
// Download should succeed after retries (but retrieve doesn't use with_retry)
|
|
let result = backend.retrieve("test/download.txt").await;
|
|
|
|
// Note: retrieve() doesn't use with_retry in current implementation
|
|
// So this will fail on first attempt
|
|
assert!(
|
|
result.is_err(),
|
|
"Retrieve doesn't use retry logic currently"
|
|
);
|
|
}
|
|
|
|
// Test 4: Metadata operation with retry
|
|
#[tokio::test]
|
|
async fn test_metadata_retry_transient_failures() {
|
|
// First store data successfully
|
|
let store = Arc::new(FailingObjectStore::new(0, ErrorType::Generic));
|
|
let test_data = b"test data";
|
|
store
|
|
.inner
|
|
.put(
|
|
&Path::from("test/metadata.txt"),
|
|
Bytes::from_static(test_data).into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Now configure to fail on head operations
|
|
*store.failures_before_success.lock().await = 2;
|
|
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
// Metadata should succeed after retries (but metadata doesn't use with_retry)
|
|
let result = backend.metadata("test/metadata.txt").await;
|
|
|
|
// Note: metadata() doesn't use with_retry in current implementation
|
|
assert!(
|
|
result.is_err(),
|
|
"Metadata doesn't use retry logic currently"
|
|
);
|
|
}
|
|
|
|
// Test 5: NotFound error should not trigger retry
|
|
#[tokio::test]
|
|
async fn test_exists_not_found_no_retry() {
|
|
let store = Arc::new(FailingObjectStore::new(0, ErrorType::NotFound));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.exists("nonexistent.txt").await;
|
|
|
|
// Should return Ok(false) for NotFound
|
|
assert!(result.is_ok(), "exists should handle NotFound gracefully");
|
|
assert!(
|
|
!result.unwrap(),
|
|
"Should return false for non-existent file"
|
|
);
|
|
}
|
|
|
|
// Test 6: Delete nonexistent file
|
|
#[tokio::test]
|
|
async fn test_delete_not_found() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.delete("nonexistent.txt").await;
|
|
|
|
// Should return Ok(false) for NotFound
|
|
// Note: InMemory store returns Ok(()) even for non-existent files,
|
|
// so delete() won't return false for InMemory. This tests the happy path.
|
|
assert!(result.is_ok(), "delete should handle NotFound gracefully");
|
|
}
|
|
|
|
// Test 7: Retry backoff behavior
|
|
#[tokio::test]
|
|
async fn test_retry_backoff_timing() {
|
|
use std::time::Instant;
|
|
|
|
let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(50),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let start = Instant::now();
|
|
let _ = backend.store("test/backoff.txt", b"data").await;
|
|
let elapsed = start.elapsed();
|
|
|
|
// Should take at least initial_delay + (initial_delay * backoff_multiplier)
|
|
// = 50ms + 100ms = 150ms (with some tolerance)
|
|
assert!(
|
|
elapsed >= Duration::from_millis(140),
|
|
"Should respect backoff delays, took {:?}",
|
|
elapsed
|
|
);
|
|
}
|
|
|
|
// Test 8: Zero max_attempts should use default
|
|
#[tokio::test]
|
|
async fn test_retry_config_validation() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 1,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let test_data = b"test data";
|
|
let result = backend.store("test/single.txt", test_data).await;
|
|
|
|
assert!(result.is_ok(), "Should work with single attempt");
|
|
}
|
|
|
|
// Test 9: Download with progress - failure scenarios
|
|
#[tokio::test]
|
|
async fn test_download_with_progress_file_not_found() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend
|
|
.download_with_progress("nonexistent.txt", None)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Should fail for non-existent file");
|
|
}
|
|
|
|
// Test 10: Stream download failure
|
|
#[tokio::test]
|
|
async fn test_stream_download_file_not_found() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let progress_callback = Arc::new(|_downloaded: u64, _total: u64| {});
|
|
let result = backend
|
|
.stream_download_with_progress("nonexistent.txt", 1024, progress_callback)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Should fail for non-existent file");
|
|
}
|
|
|
|
// Test 11: Parallel download with empty list
|
|
#[tokio::test]
|
|
async fn test_parallel_download_empty_list() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.parallel_download(vec![], None).await;
|
|
|
|
assert!(result.is_ok(), "Should handle empty list gracefully");
|
|
assert_eq!(result.unwrap().len(), 0, "Should return empty result");
|
|
}
|
|
|
|
// Test 12: Parallel download with some failures
|
|
#[tokio::test]
|
|
async fn test_parallel_download_partial_failure() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone(),
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
// Store only one file
|
|
backend.store("file1.txt", b"data1").await.unwrap();
|
|
|
|
let paths = vec!["file1.txt".to_string(), "file2.txt".to_string()];
|
|
|
|
let result = backend.parallel_download(paths, None).await;
|
|
|
|
// Should fail because file2 doesn't exist
|
|
assert!(result.is_err(), "Should fail if any file is missing");
|
|
}
|
|
|
|
// Test 13: Very large retry delay capping
|
|
#[tokio::test]
|
|
async fn test_retry_max_delay_capping() {
|
|
let store = Arc::new(FailingObjectStore::new(3, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 4,
|
|
base_delay: Duration::from_millis(100),
|
|
max_delay: Duration::from_millis(150), // Cap at 150ms
|
|
backoff_multiplier: 10.0, // Very aggressive multiplier
|
|
..Default::default()
|
|
});
|
|
|
|
let start = std::time::Instant::now();
|
|
let _ = backend.store("test/capped.txt", b"data").await;
|
|
let elapsed = start.elapsed();
|
|
|
|
// With 3 failures: delay1=100ms, delay2=150ms (capped), delay3=150ms (capped)
|
|
// Total should be around 400ms, not 100 + 1000 + 10000 = 11100ms
|
|
assert!(
|
|
elapsed < Duration::from_millis(600),
|
|
"Delays should be capped at max_delay, took {:?}",
|
|
elapsed
|
|
);
|
|
}
|
|
|
|
// Test 14: Upload with authentication error (not retryable)
|
|
#[tokio::test]
|
|
async fn test_upload_auth_error_no_retry() {
|
|
let store = Arc::new(FailingObjectStore::new(5, ErrorType::Unauthenticated));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let test_data = b"test data";
|
|
let result = backend.store("test/auth.txt", test_data).await;
|
|
|
|
// Should still retry because with_retry retries all errors
|
|
assert!(result.is_err(), "Should fail with auth error");
|
|
assert_eq!(
|
|
store.get_attempt_count(),
|
|
3,
|
|
"Should retry even for auth errors in current implementation"
|
|
);
|
|
}
|
|
|
|
// Test 15: List operation error handling
|
|
#[tokio::test]
|
|
async fn test_list_operation_error() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
// List with invalid prefix should still work
|
|
let result = backend.list("invalid/prefix/").await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"List should succeed even with empty results"
|
|
);
|
|
assert_eq!(result.unwrap().len(), 0, "Should return empty list");
|
|
}
|
|
|
|
// Test 16: Metadata for non-existent file
|
|
#[tokio::test]
|
|
async fn test_metadata_not_found() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.metadata("nonexistent.txt").await;
|
|
|
|
assert!(result.is_err(), "Should fail for non-existent file");
|
|
}
|
|
|
|
// Test 17: Exists with generic error
|
|
#[tokio::test]
|
|
async fn test_exists_generic_error() {
|
|
let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.exists("test.txt").await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Should propagate non-NotFound errors in exists"
|
|
);
|
|
}
|
|
|
|
// Test 18: Delete with generic error
|
|
#[tokio::test]
|
|
async fn test_delete_generic_error() {
|
|
let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic));
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
let result = backend.delete("test.txt").await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Should propagate non-NotFound errors in delete"
|
|
);
|
|
}
|
|
|
|
// Test 19: Concurrent uploads with retry
|
|
#[tokio::test]
|
|
async fn test_concurrent_uploads_with_retry() {
|
|
let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic));
|
|
let backend = Arc::new(
|
|
storage::object_store_backend::test_helpers::new_for_testing(
|
|
store.clone() as Arc<dyn ObjectStore>,
|
|
"test-bucket".to_string(),
|
|
)
|
|
.with_retry_config(RetryConfig {
|
|
max_retries: 3,
|
|
base_delay: Duration::from_millis(10),
|
|
max_delay: Duration::from_secs(1),
|
|
backoff_multiplier: 2.0,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
|
|
let mut handles = vec![];
|
|
for i in 0..5 {
|
|
let backend = Arc::clone(&backend);
|
|
let handle = tokio::spawn(async move {
|
|
let path = format!("concurrent_{}.txt", i);
|
|
let data = format!("data_{}", i);
|
|
backend.store(&path, data.as_bytes()).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut successes = 0;
|
|
for handle in handles {
|
|
if let Ok(Ok(_)) = handle.await {
|
|
successes += 1;
|
|
}
|
|
}
|
|
|
|
// Some should succeed after retry
|
|
assert!(
|
|
successes > 0,
|
|
"At least some concurrent uploads should succeed"
|
|
);
|
|
}
|
|
|
|
// Test 20: Download with progress - zero size file
|
|
#[tokio::test]
|
|
async fn test_download_with_progress_empty_file() {
|
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
|
let backend = storage::object_store_backend::test_helpers::new_for_testing(
|
|
store,
|
|
"test-bucket".to_string(),
|
|
);
|
|
|
|
// Store empty file
|
|
backend.store("empty.txt", b"").await.unwrap();
|
|
|
|
let progress_calls = Arc::new(AtomicUsize::new(0));
|
|
let progress_calls_clone = Arc::clone(&progress_calls);
|
|
|
|
let callback = Arc::new(move |_downloaded: u64, _total: u64| {
|
|
progress_calls_clone.fetch_add(1, Ordering::SeqCst);
|
|
});
|
|
|
|
let result = backend
|
|
.download_with_progress("empty.txt", Some(callback))
|
|
.await;
|
|
|
|
assert!(result.is_ok(), "Should handle empty file");
|
|
assert_eq!(result.unwrap().len(), 0, "Should return empty data");
|
|
assert!(
|
|
progress_calls.load(Ordering::SeqCst) >= 2,
|
|
"Should call progress callback for empty file"
|
|
);
|
|
}
|