Skip to main content

chronix_engine/objstore/
backend.rs

1//! Object storage backend wrapping the [`object_store`] crate.
2//!
3//! Provides a [`ObjectStoreBackend`] that implements [`StorageBackend`]
4//! for cloud object storage (S3, GCS, Azure Blob Storage) with an
5//! optional local disk cache for frequently accessed segments.
6//!
7//! ## URL Scheme
8//!
9//! The backend is configured via URL:
10//! - `s3://bucket/prefix` — Amazon S3 (or S3-compatible)
11//! - `gs://bucket/prefix` — Google Cloud Storage
12//! - `az://container/prefix` — Azure Blob Storage
13//! - `file:///tmp/data` or `/tmp/data` — Local filesystem (for testing)
14
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use bytes::Bytes;
19use object_store::local::LocalFileSystem;
20use object_store::path::Path as ObjPath;
21use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
22use tokio::sync::Semaphore;
23use tracing::{debug, warn};
24use url::Url;
25
26use crate::storage::{SegmentPath, StorageBackend};
27use chronix_core::{NamespaceId, ShardId};
28
29use crate::objstore::cache::DiskCache;
30use crate::objstore::error::{ObjStoreError, Result};
31
32/// Maximum number of retry attempts for transient object store errors.
33const MAX_RETRIES: u32 = 3;
34
35/// Initial backoff duration (doubles each retry, with jitter).
36const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
37
38/// Configurable backoff strategy for object store retries.
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct BackoffConfig {
41    /// Maximum number of retry attempts.
42    pub max_retries: u32,
43    /// Initial backoff duration before the first retry.
44    pub initial_backoff: Duration,
45    /// Maximum backoff duration (caps exponential growth).
46    pub max_backoff: Duration,
47}
48
49impl Default for BackoffConfig {
50    fn default() -> Self {
51        Self {
52            max_retries: MAX_RETRIES,
53            initial_backoff: INITIAL_BACKOFF,
54            max_backoff: Duration::from_secs(30),
55        }
56    }
57}
58
59/// Retry an async operation with exponential backoff + jitter for
60/// transient object store errors (429, 503, timeouts, connection resets).
61///
62/// Uses configurable backoff parameters with a cap to avoid
63/// unbounded delays.
64async fn retry_with_backoff<F, Fut, T>(
65    op_name: &str,
66    backoff: &BackoffConfig,
67    f: F,
68) -> std::result::Result<T, object_store::Error>
69where
70    F: Fn() -> Fut,
71    Fut: std::future::Future<Output = std::result::Result<T, object_store::Error>>,
72{
73    let mut attempt = 0u32;
74    loop {
75        match f().await {
76            Ok(val) => return Ok(val),
77            Err(e) if is_retryable(&e) && attempt < backoff.max_retries => {
78                attempt += 1;
79                let base = backoff.initial_backoff * 2u32.saturating_pow(attempt - 1);
80                let capped = base.min(backoff.max_backoff);
81                // Add jitter: 50-150% of base
82                let jitter_factor = 0.5 + rand_jitter();
83                let delay = Duration::from_secs_f64(capped.as_secs_f64() * jitter_factor);
84                warn!(
85                    op = op_name,
86                    attempt,
87                    max = backoff.max_retries,
88                    delay_ms = delay.as_millis(),
89                    error = %e,
90                    "retrying transient object store error"
91                );
92                tokio::time::sleep(delay).await;
93            }
94            Err(e) => return Err(e),
95        }
96    }
97}
98
99/// Determine if an object store error is retryable (transient).
100fn is_retryable(err: &object_store::Error) -> bool {
101    matches!(err, object_store::Error::Generic { .. })
102        || err.to_string().contains("429")
103        || err.to_string().contains("503")
104        || err.to_string().contains("timeout")
105        || err.to_string().contains("connection")
106        || err.to_string().contains("reset")
107}
108
109/// Simple pseudo-random jitter in [0.0, 1.0] using thread-local state.
110/// Avoids pulling in a full RNG crate for this one use case.
111fn rand_jitter() -> f64 {
112    use std::collections::hash_map::DefaultHasher;
113    use std::hash::{Hash, Hasher};
114    let mut hasher = DefaultHasher::new();
115    std::thread::current().id().hash(&mut hasher);
116    Instant::now().hash(&mut hasher);
117    (hasher.finish() % 1000) as f64 / 1000.0
118}
119
120/// Default multipart upload threshold: 8 MiB.
121const DEFAULT_MULTIPART_THRESHOLD: usize = 8 * 1024 * 1024;
122
123/// Default maximum number of concurrent segment downloads.
124const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 8;
125
126/// Configuration for the object store backend.
127#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct ObjectStoreConfig {
129    /// Object store URL (e.g., `s3://bucket/prefix`, `gs://bucket/prefix`).
130    pub url: String,
131    /// Optional local disk cache configuration.
132    pub cache: Option<crate::objstore::cache::CacheConfig>,
133    /// Segments larger than this threshold (in bytes) are uploaded via
134    /// multipart upload instead of a single PUT. Defaults to 8 MiB.
135    #[serde(default = "default_multipart_threshold")]
136    pub multipart_threshold_bytes: usize,
137    /// Maximum number of concurrent segment downloads. Prevents bandwidth
138    /// saturation during recovery or replication bursts. Defaults to 8.
139    #[serde(default = "default_max_concurrent_downloads")]
140    pub max_concurrent_downloads: usize,
141    /// Configurable retry backoff strategy.
142    #[serde(default)]
143    pub backoff: BackoffConfig,
144}
145
146fn default_max_concurrent_downloads() -> usize {
147    DEFAULT_MAX_CONCURRENT_DOWNLOADS
148}
149
150fn default_multipart_threshold() -> usize {
151    DEFAULT_MULTIPART_THRESHOLD
152}
153
154/// Object storage backend with optional local caching.
155///
156/// Delegates storage operations to a cloud object store (S3, GCS, Azure)
157/// via the [`object_store`] crate. When a [`DiskCache`] is configured,
158/// reads are served from the local cache when available.
159///
160/// ## Segment Path Mapping
161///
162/// `SegmentPath { namespace: "default", shard_id: 3, name: "seg_001.csx" }` maps to
163/// the object key `ns_default/shard_3/seg_001.csx` relative to the configured prefix.
164pub struct ObjectStoreBackend {
165    /// The underlying object store implementation.
166    store: Arc<dyn ObjectStore>,
167    /// Optional local disk cache.
168    cache: Option<DiskCache>,
169    /// URL prefix for display/debug.
170    url: String,
171    /// Segments larger than this are uploaded via multipart put.
172    multipart_threshold_bytes: usize,
173    /// Semaphore limiting concurrent segment downloads to prevent
174    /// bandwidth saturation during recovery or replication.
175    download_semaphore: Arc<Semaphore>,
176    /// Retry backoff configuration.
177    backoff: BackoffConfig,
178}
179
180impl std::fmt::Debug for ObjectStoreBackend {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("ObjectStoreBackend")
183            .field("url", &self.url)
184            .field("cached", &self.cache.is_some())
185            .finish_non_exhaustive()
186    }
187}
188
189impl ObjectStoreBackend {
190    /// Create a new object store backend from configuration.
191    ///
192    /// # Errors
193    ///
194    /// Returns `InvalidConfig` if the URL scheme is not supported.
195    pub async fn new(config: ObjectStoreConfig) -> Result<Self> {
196        let store = Self::build_store(&config.url)?;
197
198        let cache = if let Some(cache_config) = config.cache {
199            Some(DiskCache::new(cache_config).await?)
200        } else {
201            None
202        };
203
204        Ok(Self {
205            store,
206            cache,
207            url: config.url,
208            multipart_threshold_bytes: config.multipart_threshold_bytes,
209            download_semaphore: Arc::new(Semaphore::new(config.max_concurrent_downloads)),
210            backoff: config.backoff,
211        })
212    }
213
214    /// Create a backend from an existing [`ObjectStore`] instance.
215    ///
216    /// Useful for testing with in-memory stores.
217    /// Create a backend from an existing [`ObjectStore`] instance.
218    ///
219    /// Uses the default multipart threshold (8 MiB).
220    pub fn from_store(store: Arc<dyn ObjectStore>, cache: Option<DiskCache>) -> Self {
221        Self {
222            url: "custom://".to_string(),
223            store,
224            cache,
225            multipart_threshold_bytes: DEFAULT_MULTIPART_THRESHOLD,
226            download_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_DOWNLOADS)),
227            backoff: BackoffConfig::default(),
228        }
229    }
230
231    /// Create a backend from an existing [`ObjectStore`] with a custom
232    /// multipart threshold.
233    pub fn from_store_with_threshold(
234        store: Arc<dyn ObjectStore>,
235        cache: Option<DiskCache>,
236        multipart_threshold_bytes: usize,
237    ) -> Self {
238        Self {
239            url: "custom://".to_string(),
240            store,
241            cache,
242            multipart_threshold_bytes,
243            download_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_DOWNLOADS)),
244            backoff: BackoffConfig::default(),
245        }
246    }
247
248    /// Build the underlying [`ObjectStore`] from a URL string.
249    fn build_store(url_str: &str) -> Result<Arc<dyn ObjectStore>> {
250        // Handle plain filesystem paths (no scheme)
251        if url_str.starts_with('/') {
252            return Ok(Arc::new(
253                LocalFileSystem::new_with_prefix(url_str).map_err(|e| {
254                    ObjStoreError::InvalidConfig {
255                        detail: format!("invalid local path: {e}"),
256                    }
257                })?,
258            ));
259        }
260
261        let url = Url::parse(url_str)?;
262        match url.scheme() {
263            "file" => {
264                let path = url.path();
265                Ok(Arc::new(
266                    LocalFileSystem::new_with_prefix(path)
267                        .map_err(|e| ObjStoreError::InvalidConfig {
268                            detail: format!("invalid local path: {e}"),
269                        })?,
270                ))
271            }
272            "s3" | "gs" | "az" => {
273                // For real cloud stores, we build from the URL.
274                // The `object_store` crate reads credentials from
275                // environment variables (AWS_ACCESS_KEY_ID, etc.)
276                // or instance metadata automatically.
277                let (store, _) = object_store::parse_url(&url)
278                    .map_err(|e| ObjStoreError::InvalidConfig {
279                        detail: format!("failed to parse object store URL '{url_str}': {e}"),
280                    })?;
281                Ok(Arc::new(store))
282            }
283            "memory" => {
284                // In-memory store for testing
285                Ok(Arc::new(object_store::memory::InMemory::new()))
286            }
287            scheme => Err(ObjStoreError::InvalidConfig {
288                detail: format!(
289                    "unsupported URL scheme '{scheme}' — use s3://, gs://, az://, file://, or memory://"
290                ),
291            }),
292        }
293    }
294
295    /// Convert a `SegmentPath` to an object store path.
296    /// Map a segment to its object key, using **Hive-style partition
297    /// directories**.
298    ///
299    /// `namespace=<ns>/shard=<n>/<file>` rather than `ns_<ns>/shard_<n>/<file>`,
300    /// because the `key=value` form is what the rest of the ecosystem reads as
301    /// a partitioned dataset with standard edges. Three things follow from
302    /// it, and none from the underscore form:
303    ///
304    /// DuckDB, Spark and Polars expose `namespace` and `shard` as **columns**
305    ///   and prune whole directories on a predicate over them.
306    /// DataFusion's listing tables treat a `key=value` segment as a partition
307    ///   rather than as a subdirectory, so the cold tier registers without
308    ///   having to relax `listing_table_ignore_subdirectory` on the shared
309    ///   session — the mutation the read-only admission check exists to stop.
310    /// The archive layout is self-describing to somebody who has only the
311    ///   bucket and no chronix.
312    fn to_obj_path(path: &SegmentPath) -> ObjPath {
313        ObjPath::from(format!(
314            "namespace={}/shard={}/{}",
315            path.namespace().as_str(),
316            path.shard_id().0,
317            path.segment_name()
318        ))
319    }
320
321    /// Convert a namespace + shard to a prefix path for listing.
322    fn shard_prefix(namespace: &NamespaceId, shard_id: ShardId) -> ObjPath {
323        ObjPath::from(format!(
324            "namespace={}/shard={}/",
325            namespace.as_str(),
326            shard_id.0
327        ))
328    }
329
330    /// Cache key for a segment.
331    fn cache_key(path: &SegmentPath) -> String {
332        format!(
333            "ns_{}/shard_{}/{}",
334            path.namespace().as_str(),
335            path.shard_id().0,
336            path.segment_name()
337        )
338    }
339
340    /// Return the size of a remote segment via a HEAD request.
341    ///
342    /// This avoids re-downloading the entire segment just to verify its
343    /// size after upload. Returns `None` if the object does not exist.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if the HEAD request fails (other than `NotFound`).
348    pub async fn segment_size(&self, path: &SegmentPath) -> Result<Option<usize>> {
349        let obj_path = Self::to_obj_path(path);
350        let store = &self.store;
351        match retry_with_backoff("segment_size", &self.backoff, || {
352            let op = obj_path.clone();
353            async move { store.head(&op).await }
354        })
355        .await
356        {
357            Ok(meta) => Ok(Some(usize::try_from(meta.size).unwrap_or(usize::MAX))),
358            Err(object_store::Error::NotFound { .. }) => Ok(None),
359            Err(e) => Err(ObjStoreError::Store(e)),
360        }
361    }
362
363    /// Return the size of an arbitrary object via a HEAD request.
364    ///
365    /// `None` means the object is not there. Used to verify an upload landed
366    /// intact before the caller deletes the source.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the HEAD request fails for any reason other than
371    /// `NotFound`.
372    pub async fn object_size(&self, key: &str) -> Result<Option<usize>> {
373        let obj_path = ObjPath::from(key);
374        let store = &self.store;
375        match retry_with_backoff("object_size", &self.backoff, || {
376            let op = obj_path.clone();
377            async move { store.head(&op).await }
378        })
379        .await
380        {
381            Ok(meta) => Ok(Some(usize::try_from(meta.size).unwrap_or(usize::MAX))),
382            Err(object_store::Error::NotFound { .. }) => Ok(None),
383            Err(e) => Err(ObjStoreError::Store(e)),
384        }
385    }
386
387    /// Put an object into the store.
388    ///
389    /// # Errors
390    ///
391    /// Returns `ObjStoreError::Store` on object store failures.
392    pub async fn put(&self, key: &str, data: Bytes) -> Result<()> {
393        let path = ObjPath::from(key);
394        let start = Instant::now();
395        self.store
396            .put(&path, PutPayload::from(data))
397            .await
398            .map_err(ObjStoreError::Store)?;
399
400        let elapsed = start.elapsed();
401        metrics::histogram!("chronix_objstore_put_duration_seconds").record(elapsed.as_secs_f64());
402        debug!(key, elapsed_ms = elapsed.as_millis(), "object put");
403        Ok(())
404    }
405
406    /// Get an object from the store.
407    ///
408    /// # Errors
409    ///
410    /// Returns `ObjStoreError::NotFound` if the object does not exist,
411    /// or `ObjStoreError::Store` on other failures.
412    pub async fn get(&self, key: &str) -> Result<Bytes> {
413        let path = ObjPath::from(key);
414        let start = Instant::now();
415
416        let result = self.store.get(&path).await.map_err(|e| match e {
417            object_store::Error::NotFound { .. } => ObjStoreError::NotFound {
418                path: key.to_string(),
419            },
420            other => ObjStoreError::Store(other),
421        })?;
422
423        let data = result.bytes().await.map_err(ObjStoreError::Store)?;
424
425        let elapsed = start.elapsed();
426        metrics::histogram!("chronix_objstore_get_duration_seconds").record(elapsed.as_secs_f64());
427        debug!(
428            key,
429            size = data.len(),
430            elapsed_ms = elapsed.as_millis(),
431            "object get"
432        );
433        Ok(data)
434    }
435
436    /// Delete an object from the store.
437    ///
438    /// # Errors
439    ///
440    /// Returns `ObjStoreError::Store` on object store failures.
441    pub async fn delete(&self, key: &str) -> Result<()> {
442        let path = ObjPath::from(key);
443        self.store
444            .delete(&path)
445            .await
446            .map_err(ObjStoreError::Store)?;
447        debug!(key, "object deleted");
448        Ok(())
449    }
450
451    /// List objects under a prefix.
452    ///
453    /// # Errors
454    ///
455    /// Returns `ObjStoreError::Store` on object store failures.
456    pub async fn list(&self, prefix: &str) -> Result<Vec<String>> {
457        use futures::TryStreamExt;
458        let path = ObjPath::from(prefix);
459        let objects: Vec<_> = self
460            .store
461            .list(Some(&path))
462            .try_collect()
463            .await
464            .map_err(ObjStoreError::Store)?;
465
466        Ok(objects
467            .into_iter()
468            .map(|meta| meta.location.to_string())
469            .collect())
470    }
471}
472
473impl StorageBackend for ObjectStoreBackend {
474    async fn put_segment(
475        &self,
476        path: &SegmentPath,
477        data: &[u8],
478    ) -> crate::storage::error::Result<()> {
479        let obj_path = Self::to_obj_path(path);
480        let start = Instant::now();
481        let store = &self.store;
482
483        if data.len() >= self.multipart_threshold_bytes {
484            // Use multipart upload for large segments so that a
485            // network interruption at 99 % doesn't force starting over.
486            //
487            // On failure, orphaned parts may remain in cloud storage
488            // (e.g., S3 charges for incomplete multipart uploads).
489            // Operators should configure lifecycle rules to auto-expire
490            // incomplete multipart uploads after 24–48h.
491            let multipart_result =
492                retry_with_backoff("put_segment_multipart", &self.backoff, || {
493                    let op = obj_path.clone();
494                    let data_ref = data;
495                    async move {
496                        let upload = store.put_multipart(&op).await?;
497                        let mut writer = WriteMultipart::new(upload);
498                        writer.write(data_ref);
499                        writer.finish().await?;
500                        Ok(())
501                    }
502                })
503                .await;
504
505            if let Err(e) = multipart_result {
506                // Track orphaned multipart uploads
507                metrics::counter!("chronix_objstore_multipart_orphaned_total").increment(1);
508                warn!(
509                    path = %path,
510                    error = %e,
511                    "multipart upload failed — orphaned parts may exist; \
512                     configure S3 lifecycle rules to auto-expire incomplete uploads"
513                );
514                return Err(crate::storage::StorageError::InvalidPath {
515                    detail: format!("object store multipart put failed: {e}"),
516                });
517            }
518
519            debug!(
520                path = %path,
521                size = data.len(),
522                "segment put via multipart upload"
523            );
524        } else {
525            // Small segment — single PUT with retry.
526            let payload_data = Bytes::copy_from_slice(data);
527            retry_with_backoff("put_segment", &self.backoff, || {
528                let p = payload_data.clone();
529                let op = obj_path.clone();
530                async move { store.put(&op, PutPayload::from(p)).await }
531            })
532            .await
533            .map_err(|e| crate::storage::StorageError::InvalidPath {
534                detail: format!("object store put failed: {e}"),
535            })?;
536        }
537
538        // Also cache the data locally
539        if let Some(ref cache) = self.cache {
540            let key = Self::cache_key(path);
541            if let Err(e) = cache.put(&key, data).await {
542                warn!(key, error = %e, "failed to cache segment after put");
543            }
544        }
545
546        let elapsed = start.elapsed();
547        metrics::histogram!("chronix_objstore_put_duration_seconds").record(elapsed.as_secs_f64());
548        // Track bytes transferred for throughput/cost accounting.
549        metrics::counter!("chronix_objstore_put_bytes_total").increment(data.len() as u64);
550        debug!(
551            path = %path,
552            size = data.len(),
553            elapsed_ms = elapsed.as_millis(),
554            "segment put to object store"
555        );
556        Ok(())
557    }
558
559    async fn get_segment(&self, path: &SegmentPath) -> crate::storage::error::Result<Vec<u8>> {
560        let key = Self::cache_key(path);
561
562        // Try cache first
563        if let Some(ref cache) = self.cache {
564            if let Some(data) = cache.get(&key).await {
565                metrics::counter!("chronix_objstore_cache_hits_total").increment(1);
566                return Ok(data);
567            }
568            metrics::counter!("chronix_objstore_cache_misses_total").increment(1);
569        }
570
571        // Acquire download semaphore permit to throttle concurrent
572        // downloads during recovery/replication bursts.
573        let _permit = self.download_semaphore.acquire().await.map_err(|_| {
574            crate::storage::StorageError::InvalidPath {
575                detail: "download semaphore closed".to_string(),
576            }
577        })?;
578
579        // Cache miss — fetch from remote with retry
580        let obj_path = Self::to_obj_path(path);
581        let start = Instant::now();
582        let store = &self.store;
583        let path_str = path.to_string();
584
585        // Retry with exponential backoff for transient errors
586        let result = retry_with_backoff("get_segment", &self.backoff, || {
587            let op = obj_path.clone();
588            async move { store.get(&op).await }
589        })
590        .await
591        .map_err(|e| match e {
592            object_store::Error::NotFound { .. } => crate::storage::StorageError::NotFound {
593                path: std::path::PathBuf::from(&path_str),
594            },
595            other => crate::storage::StorageError::InvalidPath {
596                detail: format!("object store get failed: {other}"),
597            },
598        })?;
599
600        let data = result
601            .bytes()
602            .await
603            .map_err(|e| crate::storage::StorageError::InvalidPath {
604                detail: format!("object store read failed: {e}"),
605            })?;
606
607        let elapsed = start.elapsed();
608        metrics::histogram!("chronix_objstore_get_duration_seconds").record(elapsed.as_secs_f64());
609        // Track bytes transferred for throughput/cost accounting.
610        metrics::counter!("chronix_objstore_get_bytes_total").increment(data.len() as u64);
611
612        // Populate cache
613        if let Some(ref cache) = self.cache {
614            if let Err(e) = cache.put(&key, &data).await {
615                warn!(%key, error = %e, "failed to cache segment after fetch");
616            }
617        }
618
619        Ok(data.to_vec())
620    }
621
622    async fn get_range(
623        &self,
624        path: &SegmentPath,
625        offset: u64,
626        length: usize,
627    ) -> crate::storage::error::Result<Vec<u8>> {
628        // For range reads, try to serve from cache if the full object is cached
629        let key = Self::cache_key(path);
630
631        if let Some(ref cache) = self.cache {
632            if let Some(data) = cache.get(&key).await {
633                #[allow(clippy::cast_possible_truncation)]
634                let start = offset as usize;
635                let end = start.checked_add(length).ok_or_else(|| {
636                    crate::storage::StorageError::InvalidPath {
637                        detail: "offset + length overflow".to_string(),
638                    }
639                })?;
640                if end <= data.len() {
641                    return Ok(data[start..end].to_vec());
642                }
643                // Cache entry is too small (corrupt?) — fall through to remote
644            }
645        }
646
647        // Fetch the range directly from remote with retry for transient errors.
648        // object_store 0.13 ranges are `u64`, so this no longer narrows the
649        // offset through `usize` on a 32-bit host.
650        let obj_path = Self::to_obj_path(path);
651        let end = offset.checked_add(length as u64).ok_or_else(|| {
652            crate::storage::StorageError::InvalidPath {
653                detail: "offset + length overflow".to_string(),
654            }
655        })?;
656        let range = offset..end;
657
658        // Retry with exponential backoff for transient errors
659        let store = &self.store;
660        let data = retry_with_backoff("get_range", &self.backoff, || {
661            let op = obj_path.clone();
662            let r = range.clone();
663            async move { store.get_range(&op, r).await }
664        })
665        .await
666        .map_err(|e| match e {
667            object_store::Error::NotFound { .. } => crate::storage::StorageError::NotFound {
668                path: std::path::PathBuf::from(path.to_string()),
669            },
670            other => crate::storage::StorageError::InvalidPath {
671                detail: format!("object store get_range failed: {other}"),
672            },
673        })?;
674
675        Ok(data.to_vec())
676    }
677
678    async fn delete_segment(&self, path: &SegmentPath) -> crate::storage::error::Result<()> {
679        let obj_path = Self::to_obj_path(path);
680        // Retry with exponential backoff for transient errors
681        let store = &self.store;
682        retry_with_backoff("delete_segment", &self.backoff, || {
683            let op = obj_path.clone();
684            async move { store.delete(&op).await }
685        })
686        .await
687        .map_err(|e| crate::storage::StorageError::InvalidPath {
688            detail: format!("object store delete failed: {e}"),
689        })?;
690
691        // Remove from cache too
692        if let Some(ref cache) = self.cache {
693            let key = Self::cache_key(path);
694            if let Err(e) = cache.remove(&key).await {
695                warn!(key, error = %e, "failed to remove cached segment");
696            }
697        }
698
699        Ok(())
700    }
701
702    async fn list_segments(
703        &self,
704        namespace: &NamespaceId,
705        shard_id: &ShardId,
706    ) -> crate::storage::error::Result<Vec<SegmentPath>> {
707        use futures::TryStreamExt;
708        let prefix = Self::shard_prefix(namespace, *shard_id);
709        let ns = namespace.clone();
710        let shard = *shard_id;
711
712        // Retry with exponential backoff for transient errors
713        let store = &self.store;
714        let objects: Vec<_> = retry_with_backoff("list_segments", &self.backoff, || {
715            let p = prefix.clone();
716            async move { store.list(Some(&p)).try_collect::<Vec<_>>().await }
717        })
718        .await
719        .map_err(|e| crate::storage::StorageError::InvalidPath {
720            detail: format!("object store list failed: {e}"),
721        })?;
722
723        Ok(objects
724            .into_iter()
725            .filter_map(|meta| {
726                let name = meta.location.filename()?;
727                SegmentPath::new(ns.clone(), shard, name.to_string()).ok()
728            })
729            .collect())
730    }
731
732    async fn exists(&self, path: &SegmentPath) -> crate::storage::error::Result<bool> {
733        // Check cache first
734        if let Some(ref cache) = self.cache {
735            if cache.contains(&Self::cache_key(path)) {
736                return Ok(true);
737            }
738        }
739
740        let obj_path = Self::to_obj_path(path);
741        // Retry with exponential backoff for transient errors
742        let store = &self.store;
743        match retry_with_backoff("exists", &self.backoff, || {
744            let op = obj_path.clone();
745            async move { store.head(&op).await }
746        })
747        .await
748        {
749            Ok(_) => Ok(true),
750            Err(object_store::Error::NotFound { .. }) => Ok(false),
751            Err(e) => Err(crate::storage::StorageError::InvalidPath {
752                detail: format!("object store exists check failed: {e}"),
753            }),
754        }
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use chronix_core::ShardId;
762    use tempfile::TempDir;
763
764    /// Create a backend backed by in-memory object store (no cache).
765    fn make_in_memory() -> ObjectStoreBackend {
766        let store = Arc::new(object_store::memory::InMemory::new());
767        ObjectStoreBackend::from_store(store, None)
768    }
769
770    /// Create a backend backed by in-memory object store with cache.
771    async fn make_in_memory_cached() -> (ObjectStoreBackend, TempDir) {
772        let tmp = TempDir::new().unwrap();
773        let cache_config = crate::objstore::cache::CacheConfig {
774            cache_dir: tmp.path().to_path_buf(),
775            max_size_bytes: 1024 * 1024,
776        };
777        let cache = DiskCache::new(cache_config).await.unwrap();
778        let store = Arc::new(object_store::memory::InMemory::new());
779        let backend = ObjectStoreBackend::from_store(store, Some(cache));
780        (backend, tmp)
781    }
782
783    // ── Raw put/get/delete/list API tests ──────────────────────────
784
785    #[tokio::test]
786    async fn raw_put_and_get() {
787        let backend = make_in_memory();
788        backend
789            .put("test/object", Bytes::from_static(b"hello"))
790            .await
791            .unwrap();
792
793        let data = backend.get("test/object").await.unwrap();
794        assert_eq!(&*data, b"hello");
795    }
796
797    #[tokio::test]
798    async fn raw_get_not_found() {
799        let backend = make_in_memory();
800        let err = backend.get("nonexistent").await.unwrap_err();
801        assert!(matches!(err, ObjStoreError::NotFound { .. }));
802    }
803
804    #[tokio::test]
805    async fn raw_delete() {
806        let backend = make_in_memory();
807        backend
808            .put("to_delete", Bytes::from_static(b"data"))
809            .await
810            .unwrap();
811        backend.delete("to_delete").await.unwrap();
812        assert!(backend.get("to_delete").await.is_err());
813    }
814
815    #[tokio::test]
816    async fn raw_list() {
817        let backend = make_in_memory();
818        backend
819            .put("prefix/a", Bytes::from_static(b"1"))
820            .await
821            .unwrap();
822        backend
823            .put("prefix/b", Bytes::from_static(b"2"))
824            .await
825            .unwrap();
826        backend
827            .put("other/c", Bytes::from_static(b"3"))
828            .await
829            .unwrap();
830
831        let items = backend.list("prefix").await.unwrap();
832        assert_eq!(items.len(), 2);
833    }
834
835    fn default_ns() -> NamespaceId {
836        NamespaceId::default_namespace()
837    }
838
839    // ── StorageBackend trait tests ─────────────────────────────────
840
841    #[tokio::test]
842    async fn storage_put_and_get_segment() {
843        let backend = make_in_memory();
844        let path = SegmentPath::new(default_ns(), ShardId(1), "seg_001.csx").unwrap();
845
846        backend.put_segment(&path, b"segment-data").await.unwrap();
847        let data = backend.get_segment(&path).await.unwrap();
848        assert_eq!(data, b"segment-data");
849    }
850
851    #[tokio::test]
852    async fn storage_get_range() {
853        let backend = make_in_memory();
854        let path = SegmentPath::new(default_ns(), ShardId(2), "seg.csx").unwrap();
855        backend
856            .put_segment(&path, b"0123456789abcdef")
857            .await
858            .unwrap();
859
860        let range = backend.get_range(&path, 4, 6).await.unwrap();
861        assert_eq!(range, b"456789");
862    }
863
864    #[tokio::test]
865    async fn storage_delete_segment() {
866        let backend = make_in_memory();
867        let path = SegmentPath::new(default_ns(), ShardId(3), "seg.csx").unwrap();
868        backend.put_segment(&path, b"data").await.unwrap();
869
870        backend.delete_segment(&path).await.unwrap();
871        assert!(!backend.exists(&path).await.unwrap());
872    }
873
874    #[tokio::test]
875    async fn storage_list_segments() {
876        let backend = make_in_memory();
877        let ns = default_ns();
878        let p1 = SegmentPath::new(ns.clone(), ShardId(5), "a.csx").unwrap();
879        let p2 = SegmentPath::new(ns.clone(), ShardId(5), "b.csx").unwrap();
880        let p3 = SegmentPath::new(ns.clone(), ShardId(6), "c.csx").unwrap();
881
882        backend.put_segment(&p1, b"1").await.unwrap();
883        backend.put_segment(&p2, b"2").await.unwrap();
884        backend.put_segment(&p3, b"3").await.unwrap();
885
886        let segments = backend.list_segments(&ns, &ShardId(5)).await.unwrap();
887        assert_eq!(segments.len(), 2);
888
889        let names: Vec<_> = segments
890            .iter()
891            .map(|s| s.segment_name().to_string())
892            .collect();
893        assert!(names.contains(&"a.csx".to_string()));
894        assert!(names.contains(&"b.csx".to_string()));
895    }
896
897    #[tokio::test]
898    async fn storage_exists() {
899        let backend = make_in_memory();
900        let path = SegmentPath::new(default_ns(), ShardId(1), "test.csx").unwrap();
901
902        assert!(!backend.exists(&path).await.unwrap());
903        backend.put_segment(&path, b"data").await.unwrap();
904        assert!(backend.exists(&path).await.unwrap());
905    }
906
907    // ── Cache integration tests ───────────────────────────────────
908
909    #[tokio::test]
910    async fn cached_backend_serves_from_cache_on_hit() {
911        let (backend, _tmp) = make_in_memory_cached().await;
912        let path = SegmentPath::new(default_ns(), ShardId(1), "cached.csx").unwrap();
913
914        backend.put_segment(&path, b"cached-data").await.unwrap();
915
916        // First get populates cache, second serves from it
917        let data1 = backend.get_segment(&path).await.unwrap();
918        let data2 = backend.get_segment(&path).await.unwrap();
919        assert_eq!(data1, data2);
920        assert_eq!(data1, b"cached-data");
921    }
922
923    #[tokio::test]
924    async fn cached_backend_invalidates_on_delete() {
925        let (backend, _tmp) = make_in_memory_cached().await;
926        let path = SegmentPath::new(default_ns(), ShardId(1), "to-delete.csx").unwrap();
927
928        backend.put_segment(&path, b"data").await.unwrap();
929        // Populate cache
930        let _ = backend.get_segment(&path).await.unwrap();
931
932        backend.delete_segment(&path).await.unwrap();
933        assert!(!backend.exists(&path).await.unwrap());
934    }
935
936    #[tokio::test]
937    async fn cached_backend_get_range_from_cache() {
938        let (backend, _tmp) = make_in_memory_cached().await;
939        let path = SegmentPath::new(default_ns(), ShardId(2), "ranged.csx").unwrap();
940
941        backend
942            .put_segment(&path, b"0123456789abcdef")
943            .await
944            .unwrap();
945
946        // Populate cache via full get
947        let _ = backend.get_segment(&path).await.unwrap();
948
949        // Range served from cache
950        let range = backend.get_range(&path, 10, 4).await.unwrap();
951        assert_eq!(range, b"abcd");
952    }
953
954    // ── Multipart upload tests ────────────────────────────────────
955
956    #[tokio::test]
957    async fn small_segment_uses_single_put() {
958        // Threshold set high — small data goes through single PUT path.
959        let store = Arc::new(object_store::memory::InMemory::new());
960        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 1024);
961        let path = SegmentPath::new(default_ns(), ShardId(1), "small.csx").unwrap();
962        let data = vec![0u8; 512]; // below 1024 threshold
963
964        backend.put_segment(&path, &data).await.unwrap();
965        let got = backend.get_segment(&path).await.unwrap();
966        assert_eq!(got.len(), 512);
967    }
968
969    #[tokio::test]
970    async fn large_segment_uses_multipart_upload() {
971        // Threshold set low so our test data exceeds it.
972        let store = Arc::new(object_store::memory::InMemory::new());
973        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 256);
974        let path = SegmentPath::new(default_ns(), ShardId(1), "large.csx").unwrap();
975        let data = vec![42u8; 1024]; // above 256 threshold
976
977        backend.put_segment(&path, &data).await.unwrap();
978        let got = backend.get_segment(&path).await.unwrap();
979        assert_eq!(got, data);
980    }
981
982    #[tokio::test]
983    async fn multipart_threshold_boundary_exact() {
984        // Data exactly at threshold should use multipart.
985        let store = Arc::new(object_store::memory::InMemory::new());
986        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 512);
987        let path = SegmentPath::new(default_ns(), ShardId(1), "exact.csx").unwrap();
988        let data = vec![7u8; 512]; // exactly at threshold
989
990        backend.put_segment(&path, &data).await.unwrap();
991        let got = backend.get_segment(&path).await.unwrap();
992        assert_eq!(got, data);
993    }
994
995    #[tokio::test]
996    async fn default_threshold_is_8mib() {
997        let backend = make_in_memory();
998        assert_eq!(
999            backend.multipart_threshold_bytes,
1000            DEFAULT_MULTIPART_THRESHOLD
1001        );
1002        assert_eq!(DEFAULT_MULTIPART_THRESHOLD, 8 * 1024 * 1024);
1003    }
1004
1005    // ── URL parsing tests ─────────────────────────────────────────
1006
1007    #[tokio::test]
1008    async fn build_store_memory() {
1009        let store = ObjectStoreBackend::build_store("memory://test").unwrap();
1010        // Verify it's usable
1011        store
1012            .put(
1013                &ObjPath::from("test"),
1014                PutPayload::from(Bytes::from_static(b"ok")),
1015            )
1016            .await
1017            .unwrap();
1018    }
1019
1020    #[tokio::test]
1021    async fn build_store_local_path() {
1022        let tmp = TempDir::new().unwrap();
1023        let path = tmp.path().to_str().unwrap();
1024        let store = ObjectStoreBackend::build_store(path).unwrap();
1025        store
1026            .put(
1027                &ObjPath::from("test"),
1028                PutPayload::from(Bytes::from_static(b"ok")),
1029            )
1030            .await
1031            .unwrap();
1032    }
1033
1034    #[test]
1035    fn build_store_unsupported_scheme() {
1036        let err = ObjectStoreBackend::build_store("ftp://host/path").unwrap_err();
1037        assert!(matches!(err, ObjStoreError::InvalidConfig { .. }));
1038    }
1039
1040    // ── Debug / Display ───────────────────────────────────────────
1041
1042    #[test]
1043    fn debug_format() {
1044        let backend = make_in_memory();
1045        let d = format!("{backend:?}");
1046        assert!(d.contains("ObjectStoreBackend"));
1047        assert!(d.contains("custom://"));
1048    }
1049
1050    // ── Additional edge-case tests ────────────────────────────────
1051
1052    #[tokio::test]
1053    async fn get_segment_not_found() {
1054        let backend = make_in_memory();
1055        let path = SegmentPath::new(default_ns(), ShardId(99), "missing.csx").unwrap();
1056        let err = backend.get_segment(&path).await.unwrap_err();
1057        assert!(
1058            matches!(err, crate::storage::StorageError::NotFound { .. }),
1059            "expected NotFound, got: {err:?}"
1060        );
1061    }
1062
1063    #[tokio::test]
1064    async fn list_segments_empty_shard() {
1065        let backend = make_in_memory();
1066        let segments = backend
1067            .list_segments(&default_ns(), &ShardId(42))
1068            .await
1069            .unwrap();
1070        assert!(segments.is_empty());
1071    }
1072
1073    #[tokio::test]
1074    async fn delete_segment_idempotent() {
1075        let backend = make_in_memory();
1076        let path = SegmentPath::new(default_ns(), ShardId(1), "gone.csx").unwrap();
1077        // Deleting a non-existent segment should not error.
1078        backend.delete_segment(&path).await.unwrap();
1079    }
1080
1081    #[tokio::test]
1082    async fn segment_size_returns_correct_len() {
1083        let backend = make_in_memory();
1084        let path = SegmentPath::new(default_ns(), ShardId(1), "sized.csx").unwrap();
1085
1086        // Not found
1087        assert_eq!(backend.segment_size(&path).await.unwrap(), None);
1088
1089        // After put
1090        backend.put_segment(&path, b"twelve bytes").await.unwrap();
1091        assert_eq!(backend.segment_size(&path).await.unwrap(), Some(12));
1092    }
1093
1094    #[tokio::test]
1095    async fn exists_nonexistent_returns_false() {
1096        let backend = make_in_memory();
1097        let path = SegmentPath::new(default_ns(), ShardId(1), "nope.csx").unwrap();
1098        assert!(!backend.exists(&path).await.unwrap());
1099    }
1100
1101    #[tokio::test]
1102    async fn build_store_file_scheme() {
1103        let tmp = TempDir::new().unwrap();
1104        let url = format!("file://{}", tmp.path().to_str().unwrap());
1105        let store = ObjectStoreBackend::build_store(&url).unwrap();
1106        store
1107            .put(
1108                &ObjPath::from("test"),
1109                PutPayload::from(Bytes::from_static(b"ok")),
1110            )
1111            .await
1112            .unwrap();
1113    }
1114
1115    #[tokio::test]
1116    async fn put_segment_overwrite() {
1117        let backend = make_in_memory();
1118        let path = SegmentPath::new(default_ns(), ShardId(1), "overwrite.csx").unwrap();
1119
1120        backend.put_segment(&path, b"version-1").await.unwrap();
1121        backend.put_segment(&path, b"version-2").await.unwrap();
1122
1123        let data = backend.get_segment(&path).await.unwrap();
1124        assert_eq!(data, b"version-2");
1125    }
1126}