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(
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 metrics::counter!("chronix_objstore_get_bytes_total").increment(data.len() as u64);
611
612 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 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 }
645 }
646
647 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 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 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 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 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 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 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 fn make_in_memory() -> ObjectStoreBackend {
766 let store = Arc::new(object_store::memory::InMemory::new());
767 ObjectStoreBackend::from_store(store, None)
768 }
769
770 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 #[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 #[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 #[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 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 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 let _ = backend.get_segment(&path).await.unwrap();
948
949 let range = backend.get_range(&path, 10, 4).await.unwrap();
951 assert_eq!(range, b"abcd");
952 }
953
954 #[tokio::test]
957 async fn small_segment_uses_single_put() {
958 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]; 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 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]; 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 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]; 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 #[tokio::test]
1008 async fn build_store_memory() {
1009 let store = ObjectStoreBackend::build_store("memory://test").unwrap();
1010 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 #[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 #[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 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 assert_eq!(backend.segment_size(&path).await.unwrap(), None);
1088
1089 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}