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(LocalFileSystem::new_with_prefix(path).map_err(
266                    |e| ObjStoreError::InvalidConfig {
267                        detail: format!("invalid local path: {e}"),
268                    },
269                )?))
270            }
271            "s3" | "gs" | "az" => {
272                // For real cloud stores, we build from the URL.
273                // The `object_store` crate reads credentials from
274                // environment variables (AWS_ACCESS_KEY_ID, etc.)
275                // or instance metadata automatically.
276                let (store, _) =
277                    object_store::parse_url(&url).map_err(|e| ObjStoreError::InvalidConfig {
278                        detail: format!("failed to parse object store URL '{url_str}': {e}"),
279                    })?;
280                Ok(Arc::new(store))
281            }
282            "memory" => {
283                // In-memory store for testing
284                Ok(Arc::new(object_store::memory::InMemory::new()))
285            }
286            scheme => Err(ObjStoreError::InvalidConfig {
287                detail: format!(
288                    "unsupported URL scheme '{scheme}' — use s3://, gs://, az://, file://, or memory://"
289                ),
290            }),
291        }
292    }
293
294    /// Convert a `SegmentPath` to an object store path.
295    /// Map a segment to its object key, using **Hive-style partition
296    /// directories**.
297    ///
298    /// `namespace=<ns>/shard=<n>/<file>` rather than `ns_<ns>/shard_<n>/<file>`,
299    /// because the `key=value` form is what the rest of the ecosystem reads as
300    /// a partitioned dataset with standard edges. Three things follow from
301    /// it, and none from the underscore form:
302    ///
303    /// DuckDB, Spark and Polars expose `namespace` and `shard` as **columns**
304    ///   and prune whole directories on a predicate over them.
305    /// DataFusion's listing tables treat a `key=value` segment as a partition
306    ///   rather than as a subdirectory, so the cold tier registers without
307    ///   having to relax `listing_table_ignore_subdirectory` on the shared
308    ///   session — the mutation the read-only admission check exists to stop.
309    /// The archive layout is self-describing to somebody who has only the
310    ///   bucket and no chronix.
311    fn to_obj_path(path: &SegmentPath) -> ObjPath {
312        ObjPath::from(format!(
313            "namespace={}/shard={}/{}",
314            path.namespace().as_str(),
315            path.shard_id().0,
316            path.segment_name()
317        ))
318    }
319
320    /// Convert a namespace + shard to a prefix path for listing.
321    fn shard_prefix(namespace: &NamespaceId, shard_id: ShardId) -> ObjPath {
322        ObjPath::from(format!(
323            "namespace={}/shard={}/",
324            namespace.as_str(),
325            shard_id.0
326        ))
327    }
328
329    /// Cache key for a segment.
330    fn cache_key(path: &SegmentPath) -> String {
331        format!(
332            "ns_{}/shard_{}/{}",
333            path.namespace().as_str(),
334            path.shard_id().0,
335            path.segment_name()
336        )
337    }
338
339    /// Return the size of a remote segment via a HEAD request.
340    ///
341    /// This avoids re-downloading the entire segment just to verify its
342    /// size after upload. Returns `None` if the object does not exist.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if the HEAD request fails (other than `NotFound`).
347    pub async fn segment_size(&self, path: &SegmentPath) -> Result<Option<usize>> {
348        let obj_path = Self::to_obj_path(path);
349        let store = &self.store;
350        match retry_with_backoff("segment_size", &self.backoff, || {
351            let op = obj_path.clone();
352            async move { store.head(&op).await }
353        })
354        .await
355        {
356            Ok(meta) => Ok(Some(usize::try_from(meta.size).unwrap_or(usize::MAX))),
357            Err(object_store::Error::NotFound { .. }) => Ok(None),
358            Err(e) => Err(ObjStoreError::Store(e)),
359        }
360    }
361
362    /// Return the size of an arbitrary object via a HEAD request.
363    ///
364    /// `None` means the object is not there. Used to verify an upload landed
365    /// intact before the caller deletes the source.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the HEAD request fails for any reason other than
370    /// `NotFound`.
371    pub async fn object_size(&self, key: &str) -> Result<Option<usize>> {
372        let obj_path = ObjPath::from(key);
373        let store = &self.store;
374        match retry_with_backoff("object_size", &self.backoff, || {
375            let op = obj_path.clone();
376            async move { store.head(&op).await }
377        })
378        .await
379        {
380            Ok(meta) => Ok(Some(usize::try_from(meta.size).unwrap_or(usize::MAX))),
381            Err(object_store::Error::NotFound { .. }) => Ok(None),
382            Err(e) => Err(ObjStoreError::Store(e)),
383        }
384    }
385
386    /// Put an object into the store.
387    ///
388    /// # Errors
389    ///
390    /// Returns `ObjStoreError::Store` on object store failures.
391    pub async fn put(&self, key: &str, data: Bytes) -> Result<()> {
392        let path = ObjPath::from(key);
393        let start = Instant::now();
394        self.store
395            .put(&path, PutPayload::from(data))
396            .await
397            .map_err(ObjStoreError::Store)?;
398
399        let elapsed = start.elapsed();
400        metrics::histogram!("chronix_objstore_put_duration_seconds").record(elapsed.as_secs_f64());
401        debug!(key, elapsed_ms = elapsed.as_millis(), "object put");
402        Ok(())
403    }
404
405    /// Get an object from the store.
406    ///
407    /// # Errors
408    ///
409    /// Returns `ObjStoreError::NotFound` if the object does not exist,
410    /// or `ObjStoreError::Store` on other failures.
411    pub async fn get(&self, key: &str) -> Result<Bytes> {
412        let path = ObjPath::from(key);
413        let start = Instant::now();
414
415        let result = self.store.get(&path).await.map_err(|e| match e {
416            object_store::Error::NotFound { .. } => ObjStoreError::NotFound {
417                path: key.to_string(),
418            },
419            other => ObjStoreError::Store(other),
420        })?;
421
422        let data = result.bytes().await.map_err(ObjStoreError::Store)?;
423
424        let elapsed = start.elapsed();
425        metrics::histogram!("chronix_objstore_get_duration_seconds").record(elapsed.as_secs_f64());
426        debug!(
427            key,
428            size = data.len(),
429            elapsed_ms = elapsed.as_millis(),
430            "object get"
431        );
432        Ok(data)
433    }
434
435    /// Delete an object from the store.
436    ///
437    /// # Errors
438    ///
439    /// Returns `ObjStoreError::Store` on object store failures.
440    pub async fn delete(&self, key: &str) -> Result<()> {
441        let path = ObjPath::from(key);
442        self.store
443            .delete(&path)
444            .await
445            .map_err(ObjStoreError::Store)?;
446        debug!(key, "object deleted");
447        Ok(())
448    }
449
450    /// List objects under a prefix.
451    ///
452    /// # Errors
453    ///
454    /// Returns `ObjStoreError::Store` on object store failures.
455    pub async fn list(&self, prefix: &str) -> Result<Vec<String>> {
456        use futures::TryStreamExt;
457        let path = ObjPath::from(prefix);
458        let objects: Vec<_> = self
459            .store
460            .list(Some(&path))
461            .try_collect()
462            .await
463            .map_err(ObjStoreError::Store)?;
464
465        Ok(objects
466            .into_iter()
467            .map(|meta| meta.location.to_string())
468            .collect())
469    }
470}
471
472impl StorageBackend for ObjectStoreBackend {
473    async fn put_segment(
474        &self,
475        path: &SegmentPath,
476        data: &[u8],
477    ) -> crate::storage::error::Result<()> {
478        let obj_path = Self::to_obj_path(path);
479        let start = Instant::now();
480        let store = &self.store;
481
482        if data.len() >= self.multipart_threshold_bytes {
483            // Use multipart upload for large segments so that a
484            // network interruption at 99 % doesn't force starting over.
485            //
486            // On failure, orphaned parts may remain in cloud storage
487            // (e.g., S3 charges for incomplete multipart uploads).
488            // Operators should configure lifecycle rules to auto-expire
489            // incomplete multipart uploads after 24–48h.
490            let multipart_result =
491                retry_with_backoff("put_segment_multipart", &self.backoff, || {
492                    let op = obj_path.clone();
493                    let data_ref = data;
494                    async move {
495                        let upload = store.put_multipart(&op).await?;
496                        let mut writer = WriteMultipart::new(upload);
497                        writer.write(data_ref);
498                        writer.finish().await?;
499                        Ok(())
500                    }
501                })
502                .await;
503
504            if let Err(e) = multipart_result {
505                // Track orphaned multipart uploads
506                metrics::counter!("chronix_objstore_multipart_orphaned_total").increment(1);
507                warn!(
508                    path = %path,
509                    error = %e,
510                    "multipart upload failed — orphaned parts may exist; \
511                     configure S3 lifecycle rules to auto-expire incomplete uploads"
512                );
513                return Err(crate::storage::StorageError::InvalidPath {
514                    detail: format!("object store multipart put failed: {e}"),
515                });
516            }
517
518            debug!(
519                path = %path,
520                size = data.len(),
521                "segment put via multipart upload"
522            );
523        } else {
524            // Small segment — single PUT with retry.
525            let payload_data = Bytes::copy_from_slice(data);
526            retry_with_backoff("put_segment", &self.backoff, || {
527                let p = payload_data.clone();
528                let op = obj_path.clone();
529                async move { store.put(&op, PutPayload::from(p)).await }
530            })
531            .await
532            .map_err(|e| crate::storage::StorageError::InvalidPath {
533                detail: format!("object store put failed: {e}"),
534            })?;
535        }
536
537        // Also cache the data locally
538        if let Some(ref cache) = self.cache {
539            let key = Self::cache_key(path);
540            if let Err(e) = cache.put(&key, data).await {
541                warn!(key, error = %e, "failed to cache segment after put");
542            }
543        }
544
545        let elapsed = start.elapsed();
546        metrics::histogram!("chronix_objstore_put_duration_seconds").record(elapsed.as_secs_f64());
547        // Track bytes transferred for throughput/cost accounting.
548        metrics::counter!("chronix_objstore_put_bytes_total").increment(data.len() as u64);
549        debug!(
550            path = %path,
551            size = data.len(),
552            elapsed_ms = elapsed.as_millis(),
553            "segment put to object store"
554        );
555        Ok(())
556    }
557
558    async fn get_segment(&self, path: &SegmentPath) -> crate::storage::error::Result<Vec<u8>> {
559        let key = Self::cache_key(path);
560
561        // Try cache first
562        if let Some(ref cache) = self.cache {
563            if let Some(data) = cache.get(&key).await {
564                metrics::counter!("chronix_objstore_cache_hits_total").increment(1);
565                return Ok(data);
566            }
567            metrics::counter!("chronix_objstore_cache_misses_total").increment(1);
568        }
569
570        // Acquire download semaphore permit to throttle concurrent
571        // downloads during recovery/replication bursts.
572        let _permit = self.download_semaphore.acquire().await.map_err(|_| {
573            crate::storage::StorageError::InvalidPath {
574                detail: "download semaphore closed".to_string(),
575            }
576        })?;
577
578        // Cache miss — fetch from remote with retry
579        let obj_path = Self::to_obj_path(path);
580        let start = Instant::now();
581        let store = &self.store;
582        let path_str = path.to_string();
583
584        // Retry with exponential backoff for transient errors
585        let result = retry_with_backoff("get_segment", &self.backoff, || {
586            let op = obj_path.clone();
587            async move { store.get(&op).await }
588        })
589        .await
590        .map_err(|e| match e {
591            object_store::Error::NotFound { .. } => crate::storage::StorageError::NotFound {
592                path: std::path::PathBuf::from(&path_str),
593            },
594            other => crate::storage::StorageError::InvalidPath {
595                detail: format!("object store get failed: {other}"),
596            },
597        })?;
598
599        let data = result
600            .bytes()
601            .await
602            .map_err(|e| crate::storage::StorageError::InvalidPath {
603                detail: format!("object store read failed: {e}"),
604            })?;
605
606        let elapsed = start.elapsed();
607        metrics::histogram!("chronix_objstore_get_duration_seconds").record(elapsed.as_secs_f64());
608        // Track bytes transferred for throughput/cost accounting.
609        metrics::counter!("chronix_objstore_get_bytes_total").increment(data.len() as u64);
610
611        // Populate cache
612        if let Some(ref cache) = self.cache
613            && let Err(e) = cache.put(&key, &data).await
614        {
615            warn!(%key, error = %e, "failed to cache segment after fetch");
616        }
617
618        Ok(data.to_vec())
619    }
620
621    async fn get_range(
622        &self,
623        path: &SegmentPath,
624        offset: u64,
625        length: usize,
626    ) -> crate::storage::error::Result<Vec<u8>> {
627        // For range reads, try to serve from cache if the full object is cached
628        let key = Self::cache_key(path);
629
630        if let Some(ref cache) = self.cache
631            && let Some(data) = cache.get(&key).await
632        {
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        // Fetch the range directly from remote with retry for transient errors.
647        // object_store 0.13 ranges are `u64`, so this no longer narrows the
648        // offset through `usize` on a 32-bit host.
649        let obj_path = Self::to_obj_path(path);
650        let end = offset.checked_add(length as u64).ok_or_else(|| {
651            crate::storage::StorageError::InvalidPath {
652                detail: "offset + length overflow".to_string(),
653            }
654        })?;
655        let range = offset..end;
656
657        // Retry with exponential backoff for transient errors
658        let store = &self.store;
659        let data = retry_with_backoff("get_range", &self.backoff, || {
660            let op = obj_path.clone();
661            let r = range.clone();
662            async move { store.get_range(&op, r).await }
663        })
664        .await
665        .map_err(|e| match e {
666            object_store::Error::NotFound { .. } => crate::storage::StorageError::NotFound {
667                path: std::path::PathBuf::from(path.to_string()),
668            },
669            other => crate::storage::StorageError::InvalidPath {
670                detail: format!("object store get_range failed: {other}"),
671            },
672        })?;
673
674        Ok(data.to_vec())
675    }
676
677    async fn delete_segment(&self, path: &SegmentPath) -> crate::storage::error::Result<()> {
678        let obj_path = Self::to_obj_path(path);
679        // Retry with exponential backoff for transient errors
680        let store = &self.store;
681        retry_with_backoff("delete_segment", &self.backoff, || {
682            let op = obj_path.clone();
683            async move { store.delete(&op).await }
684        })
685        .await
686        .map_err(|e| crate::storage::StorageError::InvalidPath {
687            detail: format!("object store delete failed: {e}"),
688        })?;
689
690        // Remove from cache too
691        if let Some(ref cache) = self.cache {
692            let key = Self::cache_key(path);
693            if let Err(e) = cache.remove(&key).await {
694                warn!(key, error = %e, "failed to remove cached segment");
695            }
696        }
697
698        Ok(())
699    }
700
701    async fn list_segments(
702        &self,
703        namespace: &NamespaceId,
704        shard_id: &ShardId,
705    ) -> crate::storage::error::Result<Vec<SegmentPath>> {
706        use futures::TryStreamExt;
707        let prefix = Self::shard_prefix(namespace, *shard_id);
708        let ns = namespace.clone();
709        let shard = *shard_id;
710
711        // Retry with exponential backoff for transient errors
712        let store = &self.store;
713        let objects: Vec<_> = retry_with_backoff("list_segments", &self.backoff, || {
714            let p = prefix.clone();
715            async move { store.list(Some(&p)).try_collect::<Vec<_>>().await }
716        })
717        .await
718        .map_err(|e| crate::storage::StorageError::InvalidPath {
719            detail: format!("object store list failed: {e}"),
720        })?;
721
722        Ok(objects
723            .into_iter()
724            .filter_map(|meta| {
725                let name = meta.location.filename()?;
726                SegmentPath::new(ns.clone(), shard, name.to_string()).ok()
727            })
728            .collect())
729    }
730
731    async fn exists(&self, path: &SegmentPath) -> crate::storage::error::Result<bool> {
732        // Check cache first
733        if let Some(ref cache) = self.cache
734            && cache.contains(&Self::cache_key(path))
735        {
736            return Ok(true);
737        }
738
739        let obj_path = Self::to_obj_path(path);
740        // Retry with exponential backoff for transient errors
741        let store = &self.store;
742        match retry_with_backoff("exists", &self.backoff, || {
743            let op = obj_path.clone();
744            async move { store.head(&op).await }
745        })
746        .await
747        {
748            Ok(_) => Ok(true),
749            Err(object_store::Error::NotFound { .. }) => Ok(false),
750            Err(e) => Err(crate::storage::StorageError::InvalidPath {
751                detail: format!("object store exists check failed: {e}"),
752            }),
753        }
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use chronix_core::ShardId;
761    use tempfile::TempDir;
762
763    /// Create a backend backed by in-memory object store (no cache).
764    fn make_in_memory() -> ObjectStoreBackend {
765        let store = Arc::new(object_store::memory::InMemory::new());
766        ObjectStoreBackend::from_store(store, None)
767    }
768
769    /// Create a backend backed by in-memory object store with cache.
770    async fn make_in_memory_cached() -> (ObjectStoreBackend, TempDir) {
771        let tmp = TempDir::new().unwrap();
772        let cache_config = crate::objstore::cache::CacheConfig {
773            cache_dir: tmp.path().to_path_buf(),
774            max_size_bytes: 1024 * 1024,
775        };
776        let cache = DiskCache::new(cache_config).await.unwrap();
777        let store = Arc::new(object_store::memory::InMemory::new());
778        let backend = ObjectStoreBackend::from_store(store, Some(cache));
779        (backend, tmp)
780    }
781
782    // ── Raw put/get/delete/list API tests ──────────────────────────
783
784    #[tokio::test]
785    async fn raw_put_and_get() {
786        let backend = make_in_memory();
787        backend
788            .put("test/object", Bytes::from_static(b"hello"))
789            .await
790            .unwrap();
791
792        let data = backend.get("test/object").await.unwrap();
793        assert_eq!(&*data, b"hello");
794    }
795
796    #[tokio::test]
797    async fn raw_get_not_found() {
798        let backend = make_in_memory();
799        let err = backend.get("nonexistent").await.unwrap_err();
800        assert!(matches!(err, ObjStoreError::NotFound { .. }));
801    }
802
803    #[tokio::test]
804    async fn raw_delete() {
805        let backend = make_in_memory();
806        backend
807            .put("to_delete", Bytes::from_static(b"data"))
808            .await
809            .unwrap();
810        backend.delete("to_delete").await.unwrap();
811        assert!(backend.get("to_delete").await.is_err());
812    }
813
814    #[tokio::test]
815    async fn raw_list() {
816        let backend = make_in_memory();
817        backend
818            .put("prefix/a", Bytes::from_static(b"1"))
819            .await
820            .unwrap();
821        backend
822            .put("prefix/b", Bytes::from_static(b"2"))
823            .await
824            .unwrap();
825        backend
826            .put("other/c", Bytes::from_static(b"3"))
827            .await
828            .unwrap();
829
830        let items = backend.list("prefix").await.unwrap();
831        assert_eq!(items.len(), 2);
832    }
833
834    fn default_ns() -> NamespaceId {
835        NamespaceId::default_namespace()
836    }
837
838    // ── StorageBackend trait tests ─────────────────────────────────
839
840    #[tokio::test]
841    async fn storage_put_and_get_segment() {
842        let backend = make_in_memory();
843        let path = SegmentPath::new(default_ns(), ShardId(1), "seg_001.csx").unwrap();
844
845        backend.put_segment(&path, b"segment-data").await.unwrap();
846        let data = backend.get_segment(&path).await.unwrap();
847        assert_eq!(data, b"segment-data");
848    }
849
850    #[tokio::test]
851    async fn storage_get_range() {
852        let backend = make_in_memory();
853        let path = SegmentPath::new(default_ns(), ShardId(2), "seg.csx").unwrap();
854        backend
855            .put_segment(&path, b"0123456789abcdef")
856            .await
857            .unwrap();
858
859        let range = backend.get_range(&path, 4, 6).await.unwrap();
860        assert_eq!(range, b"456789");
861    }
862
863    #[tokio::test]
864    async fn storage_delete_segment() {
865        let backend = make_in_memory();
866        let path = SegmentPath::new(default_ns(), ShardId(3), "seg.csx").unwrap();
867        backend.put_segment(&path, b"data").await.unwrap();
868
869        backend.delete_segment(&path).await.unwrap();
870        assert!(!backend.exists(&path).await.unwrap());
871    }
872
873    #[tokio::test]
874    async fn storage_list_segments() {
875        let backend = make_in_memory();
876        let ns = default_ns();
877        let p1 = SegmentPath::new(ns.clone(), ShardId(5), "a.csx").unwrap();
878        let p2 = SegmentPath::new(ns.clone(), ShardId(5), "b.csx").unwrap();
879        let p3 = SegmentPath::new(ns.clone(), ShardId(6), "c.csx").unwrap();
880
881        backend.put_segment(&p1, b"1").await.unwrap();
882        backend.put_segment(&p2, b"2").await.unwrap();
883        backend.put_segment(&p3, b"3").await.unwrap();
884
885        let segments = backend.list_segments(&ns, &ShardId(5)).await.unwrap();
886        assert_eq!(segments.len(), 2);
887
888        let names: Vec<_> = segments
889            .iter()
890            .map(|s| s.segment_name().to_string())
891            .collect();
892        assert!(names.contains(&"a.csx".to_string()));
893        assert!(names.contains(&"b.csx".to_string()));
894    }
895
896    #[tokio::test]
897    async fn storage_exists() {
898        let backend = make_in_memory();
899        let path = SegmentPath::new(default_ns(), ShardId(1), "test.csx").unwrap();
900
901        assert!(!backend.exists(&path).await.unwrap());
902        backend.put_segment(&path, b"data").await.unwrap();
903        assert!(backend.exists(&path).await.unwrap());
904    }
905
906    // ── Cache integration tests ───────────────────────────────────
907
908    #[tokio::test]
909    async fn cached_backend_serves_from_cache_on_hit() {
910        let (backend, _tmp) = make_in_memory_cached().await;
911        let path = SegmentPath::new(default_ns(), ShardId(1), "cached.csx").unwrap();
912
913        backend.put_segment(&path, b"cached-data").await.unwrap();
914
915        // First get populates cache, second serves from it
916        let data1 = backend.get_segment(&path).await.unwrap();
917        let data2 = backend.get_segment(&path).await.unwrap();
918        assert_eq!(data1, data2);
919        assert_eq!(data1, b"cached-data");
920    }
921
922    #[tokio::test]
923    async fn cached_backend_invalidates_on_delete() {
924        let (backend, _tmp) = make_in_memory_cached().await;
925        let path = SegmentPath::new(default_ns(), ShardId(1), "to-delete.csx").unwrap();
926
927        backend.put_segment(&path, b"data").await.unwrap();
928        // Populate cache
929        let _ = backend.get_segment(&path).await.unwrap();
930
931        backend.delete_segment(&path).await.unwrap();
932        assert!(!backend.exists(&path).await.unwrap());
933    }
934
935    #[tokio::test]
936    async fn cached_backend_get_range_from_cache() {
937        let (backend, _tmp) = make_in_memory_cached().await;
938        let path = SegmentPath::new(default_ns(), ShardId(2), "ranged.csx").unwrap();
939
940        backend
941            .put_segment(&path, b"0123456789abcdef")
942            .await
943            .unwrap();
944
945        // Populate cache via full get
946        let _ = backend.get_segment(&path).await.unwrap();
947
948        // Range served from cache
949        let range = backend.get_range(&path, 10, 4).await.unwrap();
950        assert_eq!(range, b"abcd");
951    }
952
953    // ── Multipart upload tests ────────────────────────────────────
954
955    #[tokio::test]
956    async fn small_segment_uses_single_put() {
957        // Threshold set high — small data goes through single PUT path.
958        let store = Arc::new(object_store::memory::InMemory::new());
959        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 1024);
960        let path = SegmentPath::new(default_ns(), ShardId(1), "small.csx").unwrap();
961        let data = vec![0u8; 512]; // below 1024 threshold
962
963        backend.put_segment(&path, &data).await.unwrap();
964        let got = backend.get_segment(&path).await.unwrap();
965        assert_eq!(got.len(), 512);
966    }
967
968    #[tokio::test]
969    async fn large_segment_uses_multipart_upload() {
970        // Threshold set low so our test data exceeds it.
971        let store = Arc::new(object_store::memory::InMemory::new());
972        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 256);
973        let path = SegmentPath::new(default_ns(), ShardId(1), "large.csx").unwrap();
974        let data = vec![42u8; 1024]; // above 256 threshold
975
976        backend.put_segment(&path, &data).await.unwrap();
977        let got = backend.get_segment(&path).await.unwrap();
978        assert_eq!(got, data);
979    }
980
981    #[tokio::test]
982    async fn multipart_threshold_boundary_exact() {
983        // Data exactly at threshold should use multipart.
984        let store = Arc::new(object_store::memory::InMemory::new());
985        let backend = ObjectStoreBackend::from_store_with_threshold(store, None, 512);
986        let path = SegmentPath::new(default_ns(), ShardId(1), "exact.csx").unwrap();
987        let data = vec![7u8; 512]; // exactly at threshold
988
989        backend.put_segment(&path, &data).await.unwrap();
990        let got = backend.get_segment(&path).await.unwrap();
991        assert_eq!(got, data);
992    }
993
994    #[tokio::test]
995    async fn default_threshold_is_8mib() {
996        let backend = make_in_memory();
997        assert_eq!(
998            backend.multipart_threshold_bytes,
999            DEFAULT_MULTIPART_THRESHOLD
1000        );
1001        assert_eq!(DEFAULT_MULTIPART_THRESHOLD, 8 * 1024 * 1024);
1002    }
1003
1004    // ── URL parsing tests ─────────────────────────────────────────
1005
1006    #[tokio::test]
1007    async fn build_store_memory() {
1008        let store = ObjectStoreBackend::build_store("memory://test").unwrap();
1009        // Verify it's usable
1010        store
1011            .put(
1012                &ObjPath::from("test"),
1013                PutPayload::from(Bytes::from_static(b"ok")),
1014            )
1015            .await
1016            .unwrap();
1017    }
1018
1019    #[tokio::test]
1020    async fn build_store_local_path() {
1021        let tmp = TempDir::new().unwrap();
1022        let path = tmp.path().to_str().unwrap();
1023        let store = ObjectStoreBackend::build_store(path).unwrap();
1024        store
1025            .put(
1026                &ObjPath::from("test"),
1027                PutPayload::from(Bytes::from_static(b"ok")),
1028            )
1029            .await
1030            .unwrap();
1031    }
1032
1033    #[test]
1034    fn build_store_unsupported_scheme() {
1035        let err = ObjectStoreBackend::build_store("ftp://host/path").unwrap_err();
1036        assert!(matches!(err, ObjStoreError::InvalidConfig { .. }));
1037    }
1038
1039    // ── Debug / Display ───────────────────────────────────────────
1040
1041    #[test]
1042    fn debug_format() {
1043        let backend = make_in_memory();
1044        let d = format!("{backend:?}");
1045        assert!(d.contains("ObjectStoreBackend"));
1046        assert!(d.contains("custom://"));
1047    }
1048
1049    // ── Additional edge-case tests ────────────────────────────────
1050
1051    #[tokio::test]
1052    async fn get_segment_not_found() {
1053        let backend = make_in_memory();
1054        let path = SegmentPath::new(default_ns(), ShardId(99), "missing.csx").unwrap();
1055        let err = backend.get_segment(&path).await.unwrap_err();
1056        assert!(
1057            matches!(err, crate::storage::StorageError::NotFound { .. }),
1058            "expected NotFound, got: {err:?}"
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn list_segments_empty_shard() {
1064        let backend = make_in_memory();
1065        let segments = backend
1066            .list_segments(&default_ns(), &ShardId(42))
1067            .await
1068            .unwrap();
1069        assert!(segments.is_empty());
1070    }
1071
1072    #[tokio::test]
1073    async fn delete_segment_idempotent() {
1074        let backend = make_in_memory();
1075        let path = SegmentPath::new(default_ns(), ShardId(1), "gone.csx").unwrap();
1076        // Deleting a non-existent segment should not error.
1077        backend.delete_segment(&path).await.unwrap();
1078    }
1079
1080    #[tokio::test]
1081    async fn segment_size_returns_correct_len() {
1082        let backend = make_in_memory();
1083        let path = SegmentPath::new(default_ns(), ShardId(1), "sized.csx").unwrap();
1084
1085        // Not found
1086        assert_eq!(backend.segment_size(&path).await.unwrap(), None);
1087
1088        // After put
1089        backend.put_segment(&path, b"twelve bytes").await.unwrap();
1090        assert_eq!(backend.segment_size(&path).await.unwrap(), Some(12));
1091    }
1092
1093    #[tokio::test]
1094    async fn exists_nonexistent_returns_false() {
1095        let backend = make_in_memory();
1096        let path = SegmentPath::new(default_ns(), ShardId(1), "nope.csx").unwrap();
1097        assert!(!backend.exists(&path).await.unwrap());
1098    }
1099
1100    #[tokio::test]
1101    async fn build_store_file_scheme() {
1102        let tmp = TempDir::new().unwrap();
1103        let url = format!("file://{}", tmp.path().to_str().unwrap());
1104        let store = ObjectStoreBackend::build_store(&url).unwrap();
1105        store
1106            .put(
1107                &ObjPath::from("test"),
1108                PutPayload::from(Bytes::from_static(b"ok")),
1109            )
1110            .await
1111            .unwrap();
1112    }
1113
1114    #[tokio::test]
1115    async fn put_segment_overwrite() {
1116        let backend = make_in_memory();
1117        let path = SegmentPath::new(default_ns(), ShardId(1), "overwrite.csx").unwrap();
1118
1119        backend.put_segment(&path, b"version-1").await.unwrap();
1120        backend.put_segment(&path, b"version-2").await.unwrap();
1121
1122        let data = backend.get_segment(&path).await.unwrap();
1123        assert_eq!(data, b"version-2");
1124    }
1125}