1use 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
32const MAX_RETRIES: u32 = 3;
34
35const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct BackoffConfig {
41 pub max_retries: u32,
43 pub initial_backoff: Duration,
45 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
59async 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 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
99fn 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
109fn 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
120const DEFAULT_MULTIPART_THRESHOLD: usize = 8 * 1024 * 1024;
122
123const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 8;
125
126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct ObjectStoreConfig {
129 pub url: String,
131 pub cache: Option<crate::objstore::cache::CacheConfig>,
133 #[serde(default = "default_multipart_threshold")]
136 pub multipart_threshold_bytes: usize,
137 #[serde(default = "default_max_concurrent_downloads")]
140 pub max_concurrent_downloads: usize,
141 #[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
154pub struct ObjectStoreBackend {
165 store: Arc<dyn ObjectStore>,
167 cache: Option<DiskCache>,
169 url: String,
171 multipart_threshold_bytes: usize,
173 download_semaphore: Arc<Semaphore>,
176 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 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 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 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 fn build_store(url_str: &str) -> Result<Arc<dyn ObjectStore>> {
250 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 metrics::counter!("chronix_objstore_get_bytes_total").increment(data.len() as u64);
610
611 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 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 }
645
646 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 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 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 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 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 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 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 fn make_in_memory() -> ObjectStoreBackend {
765 let store = Arc::new(object_store::memory::InMemory::new());
766 ObjectStoreBackend::from_store(store, None)
767 }
768
769 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 #[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 #[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 #[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 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 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 let _ = backend.get_segment(&path).await.unwrap();
947
948 let range = backend.get_range(&path, 10, 4).await.unwrap();
950 assert_eq!(range, b"abcd");
951 }
952
953 #[tokio::test]
956 async fn small_segment_uses_single_put() {
957 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]; 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 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]; 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 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]; 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 #[tokio::test]
1007 async fn build_store_memory() {
1008 let store = ObjectStoreBackend::build_store("memory://test").unwrap();
1009 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 #[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 #[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 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 assert_eq!(backend.segment_size(&path).await.unwrap(), None);
1087
1088 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}