1use async_trait::async_trait;
11use common::{DakeraError, NamespaceId, Result, Vector, VectorId};
12use futures_util::stream::{self, StreamExt};
13use opendal::{layers::RetryLayer, services, Operator};
14use serde::{Deserialize, Serialize};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20use crate::traits::{IndexStorage, IndexType, VectorStorage};
21
22fn s3_concurrent_ops() -> usize {
23 std::env::var("DAKERA_S3_CONCURRENT_OPS")
24 .ok()
25 .and_then(|v| v.parse().ok())
26 .unwrap_or(16)
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31struct NamespaceMetadata {
32 dimension: Option<usize>,
33 vector_count: usize,
34 created_at: u64,
35 updated_at: u64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40struct StoredVector {
41 id: String,
42 values: Vec<f32>,
43 metadata: Option<serde_json::Value>,
44}
45
46impl From<Vector> for StoredVector {
47 fn from(v: Vector) -> Self {
48 Self {
49 id: v.id,
50 values: v.values,
51 metadata: v.metadata,
52 }
53 }
54}
55
56impl From<StoredVector> for Vector {
57 fn from(v: StoredVector) -> Self {
58 Self {
59 id: v.id,
60 values: v.values,
61 metadata: v.metadata,
62 ttl_seconds: None,
63 expires_at: None,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default)]
70pub enum ObjectStorageConfig {
71 #[default]
73 Memory,
74 Filesystem { root: String },
76 S3 {
78 bucket: String,
79 region: Option<String>,
80 endpoint: Option<String>,
81 access_key_id: Option<String>,
82 secret_access_key: Option<String>,
83 },
84 Azure {
86 container: String,
87 account_name: String,
88 account_key: Option<String>,
89 sas_token: Option<String>,
90 endpoint: Option<String>,
91 },
92 Gcs {
94 bucket: String,
95 credential_path: Option<String>,
96 endpoint: Option<String>,
97 },
98}
99
100#[derive(Clone)]
102pub struct ObjectStorage {
103 operator: Operator,
104 fs_root: Option<std::path::PathBuf>,
109 needs_tmp_rename: bool,
121 namespace_locks: std::sync::Arc<Vec<tokio::sync::Mutex<()>>>,
132 corruption_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
139}
140
141const NAMESPACE_LOCK_SHARDS: usize = 256;
145
146impl ObjectStorage {
147 pub fn new(config: ObjectStorageConfig) -> Result<Self> {
149 let operator = Self::build_operator(&config)?;
150 let fs_root = match &config {
151 ObjectStorageConfig::Filesystem { root } => Some(std::path::PathBuf::from(root)),
152 _ => None,
153 };
154 let needs_tmp_rename = Self::needs_tmp_rename_for(&config);
155 Ok(Self {
156 operator,
157 fs_root,
158 needs_tmp_rename,
159 namespace_locks: std::sync::Arc::new(
160 (0..NAMESPACE_LOCK_SHARDS)
161 .map(|_| tokio::sync::Mutex::new(()))
162 .collect(),
163 ),
164 corruption_count: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
165 })
166 }
167
168 pub fn corruption_count(&self) -> u64 {
173 self.corruption_count
174 .load(std::sync::atomic::Ordering::Relaxed)
175 }
176
177 fn namespace_lock(&self, namespace: &str) -> &tokio::sync::Mutex<()> {
180 use std::hash::{Hash, Hasher};
181 let mut hasher = std::collections::hash_map::DefaultHasher::new();
182 namespace.hash(&mut hasher);
183 let idx = (hasher.finish() as usize) % self.namespace_locks.len();
184 &self.namespace_locks[idx]
185 }
186
187 fn needs_tmp_rename_for(config: &ObjectStorageConfig) -> bool {
194 matches!(config, ObjectStorageConfig::Filesystem { .. })
195 }
196
197 fn is_fs_safe_segment(s: &str) -> bool {
201 !s.is_empty()
202 && s != "."
203 && s != ".."
204 && !s.contains('/')
205 && !s.contains('\\')
206 && !s.contains('\0')
207 }
208
209 async fn write_vectors_fs(
223 &self,
224 root: &std::path::Path,
225 namespace: &NamespaceId,
226 vectors: Vec<Vector>,
227 ) -> Result<usize> {
228 let vectors_dir = root.join(format!("namespaces/{}/vectors", namespace));
229 tokio::fs::create_dir_all(&vectors_dir)
230 .await
231 .map_err(|e| DakeraError::Storage(e.to_string()))?;
232
233 let results: Vec<Result<bool>> = stream::iter(vectors)
234 .map(|vector| {
235 let dir = vectors_dir.clone();
236 async move {
237 let id = vector.id.clone();
238 let stored: StoredVector = vector.into();
239 let data = serde_json::to_vec(&stored)
240 .map_err(|e| DakeraError::Storage(e.to_string()))?;
241 let final_path = dir.join(format!("{}.json", id));
242 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
243 let tmp_path = dir.join(format!(".{}.tmp.{}", id, seq));
245 tokio::task::spawn_blocking(move || -> Result<bool> {
246 use std::io::Write;
247 let is_new = !final_path.exists();
250 let mut f = std::fs::File::create(&tmp_path)
251 .map_err(|e| DakeraError::Storage(e.to_string()))?;
252 f.write_all(&data)
253 .map_err(|e| DakeraError::Storage(e.to_string()))?;
254 f.sync_all()
257 .map_err(|e| DakeraError::Storage(e.to_string()))?;
258 drop(f);
259 if let Err(e) = std::fs::rename(&tmp_path, &final_path) {
260 let _ = std::fs::remove_file(&tmp_path);
261 return Err(DakeraError::Storage(e.to_string()));
262 }
263 Ok(is_new)
264 })
265 .await
266 .map_err(|e| DakeraError::Storage(e.to_string()))?
267 }
268 })
269 .buffer_unordered(s3_concurrent_ops())
270 .collect()
271 .await;
272
273 let mut new_inserts = 0usize;
274 for r in results {
275 if r? {
276 new_inserts += 1;
277 }
278 }
279
280 let dir = vectors_dir;
287 let _ = tokio::task::spawn_blocking(move || {
288 if let Ok(f) = std::fs::File::open(&dir) {
289 let _ = f.sync_all();
290 }
291 })
292 .await;
293
294 Ok(new_inserts)
295 }
296
297 pub fn memory() -> Result<Self> {
299 Self::new(ObjectStorageConfig::Memory)
300 }
301
302 pub fn filesystem(root: impl Into<String>) -> Result<Self> {
304 Self::new(ObjectStorageConfig::Filesystem { root: root.into() })
305 }
306
307 pub fn s3(bucket: impl Into<String>) -> Result<Self> {
309 Self::new(ObjectStorageConfig::S3 {
310 bucket: bucket.into(),
311 region: None,
312 endpoint: None,
313 access_key_id: None,
314 secret_access_key: None,
315 })
316 }
317
318 pub fn azure(container: impl Into<String>, account_name: impl Into<String>) -> Result<Self> {
320 Self::new(ObjectStorageConfig::Azure {
321 container: container.into(),
322 account_name: account_name.into(),
323 account_key: None,
324 sas_token: None,
325 endpoint: None,
326 })
327 }
328
329 pub fn gcs(bucket: impl Into<String>) -> Result<Self> {
331 Self::new(ObjectStorageConfig::Gcs {
332 bucket: bucket.into(),
333 credential_path: None,
334 endpoint: None,
335 })
336 }
337
338 pub fn build_operator(config: &ObjectStorageConfig) -> Result<Operator> {
339 match config {
340 ObjectStorageConfig::Memory => {
341 let builder = services::Memory::default();
342 Operator::new(builder)
343 .map(|op| op.finish())
344 .map_err(|e| DakeraError::Storage(e.to_string()))
345 }
346 ObjectStorageConfig::Filesystem { root } => {
347 let builder = services::Fs::default().root(root);
348 Operator::new(builder)
349 .map(|op| op.layer(Self::retry_layer()).finish())
350 .map_err(|e| DakeraError::Storage(e.to_string()))
351 }
352 ObjectStorageConfig::S3 {
353 bucket,
354 region,
355 endpoint,
356 access_key_id,
357 secret_access_key,
358 } => {
359 let mut builder = services::S3::default().bucket(bucket);
360
361 if let Some(region) = region {
362 builder = builder.region(region);
363 }
364 if let Some(endpoint) = endpoint {
365 builder = builder.endpoint(endpoint);
366 }
367 if let Some(key) = access_key_id {
368 builder = builder.access_key_id(key);
369 }
370 if let Some(secret) = secret_access_key {
371 builder = builder.secret_access_key(secret);
372 }
373
374 Operator::new(builder)
375 .map(|op| op.layer(Self::retry_layer()).finish())
376 .map_err(|e| DakeraError::Storage(e.to_string()))
377 }
378 ObjectStorageConfig::Azure {
379 container,
380 account_name,
381 account_key,
382 sas_token,
383 endpoint,
384 } => {
385 let mut builder = services::Azblob::default()
386 .container(container)
387 .account_name(account_name);
388
389 if let Some(key) = account_key {
390 builder = builder.account_key(key);
391 }
392 if let Some(token) = sas_token {
393 builder = builder.sas_token(token);
394 }
395 if let Some(endpoint) = endpoint {
396 builder = builder.endpoint(endpoint);
397 }
398
399 Operator::new(builder)
400 .map(|op| op.layer(Self::retry_layer()).finish())
401 .map_err(|e| DakeraError::Storage(e.to_string()))
402 }
403 ObjectStorageConfig::Gcs {
404 bucket,
405 credential_path,
406 endpoint,
407 } => {
408 let mut builder = services::Gcs::default().bucket(bucket);
409
410 if let Some(cred_path) = credential_path {
411 builder = builder.credential_path(cred_path);
412 }
413 if let Some(endpoint) = endpoint {
414 builder = builder.endpoint(endpoint);
415 }
416
417 Operator::new(builder)
418 .map(|op| op.layer(Self::retry_layer()).finish())
419 .map_err(|e| DakeraError::Storage(e.to_string()))
420 }
421 }
422 }
423
424 fn retry_layer() -> RetryLayer {
425 let max_times: usize = std::env::var("DAKERA_S3_MAX_RETRIES")
429 .ok()
430 .and_then(|v| v.parse().ok())
431 .unwrap_or(3);
432 let min_delay_ms: u64 = std::env::var("DAKERA_S3_RETRY_MIN_DELAY_MS")
433 .ok()
434 .and_then(|v| v.parse().ok())
435 .unwrap_or(500);
436 let max_delay_secs: u64 = std::env::var("DAKERA_S3_RETRY_MAX_DELAY_SECS")
437 .ok()
438 .and_then(|v| v.parse().ok())
439 .unwrap_or(60);
440
441 tracing::info!(
442 max_times,
443 min_delay_ms,
444 max_delay_secs,
445 "S3 retry layer configured"
446 );
447
448 RetryLayer::new()
449 .with_max_times(max_times)
450 .with_min_delay(Duration::from_millis(min_delay_ms))
451 .with_max_delay(Duration::from_secs(max_delay_secs))
452 .with_jitter()
453 .with_factor(2.0)
454 }
455
456 fn vector_path(namespace: &str, vector_id: &str) -> String {
458 format!("namespaces/{}/vectors/{}.json", namespace, vector_id)
459 }
460
461 fn namespace_meta_path(namespace: &str) -> String {
463 format!("namespaces/{}/meta.json", namespace)
464 }
465
466 fn namespace_vectors_prefix(namespace: &str) -> String {
468 format!("namespaces/{}/vectors/", namespace)
469 }
470
471 async fn read_namespace_meta(&self, namespace: &str) -> Result<Option<NamespaceMetadata>> {
473 let path = Self::namespace_meta_path(namespace);
474 match self.operator.read(&path).await {
475 Ok(data) => {
476 let bytes = data.to_vec();
477 if bytes.is_empty() {
478 tracing::warn!(
479 namespace = %namespace,
480 path = %path,
481 "Empty namespace metadata file detected, treating as missing"
482 );
483 return Ok(None);
484 }
485 match serde_json::from_slice(&bytes) {
486 Ok(meta) => Ok(Some(meta)),
487 Err(e) => {
488 tracing::warn!(
489 namespace = %namespace,
490 path = %path,
491 error = %e,
492 bytes_len = bytes.len(),
493 "Corrupted namespace metadata, treating as missing and will be recreated"
494 );
495 Ok(None)
496 }
497 }
498 }
499 Err(e) if e.kind() == opendal::ErrorKind::NotFound => Ok(None),
500 Err(e) => Err(DakeraError::Storage(e.to_string())),
501 }
502 }
503
504 async fn write_namespace_meta(&self, namespace: &str, meta: &NamespaceMetadata) -> Result<()> {
508 let path = Self::namespace_meta_path(namespace);
509 let data = serde_json::to_vec(meta).map_err(|e| DakeraError::Storage(e.to_string()))?;
510 self.write_atomic(&path, data).await
511 }
512
513 async fn write_atomic(&self, path: &str, data: Vec<u8>) -> Result<()> {
528 if !self.needs_tmp_rename {
529 return self
531 .operator
532 .write(path, data)
533 .await
534 .map(|_| ())
535 .map_err(|e| DakeraError::Storage(e.to_string()));
536 }
537 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
538 let tmp_path = format!("{}.tmp.{}.{}", path, Self::now(), seq);
539 let rename_result = async {
540 self.operator
541 .write(&tmp_path, data.clone())
542 .await
543 .map_err(|e| DakeraError::Storage(e.to_string()))?;
544 self.operator
545 .rename(&tmp_path, path)
546 .await
547 .map_err(|e| DakeraError::Storage(e.to_string()))?;
548 Ok::<(), DakeraError>(())
549 }
550 .await;
551 if let Err(e) = rename_result {
552 tracing::debug!(path = %path, error = %e, "atomic rename failed, falling back to direct write");
554 let _ = self.operator.delete(&tmp_path).await;
555 self.operator
556 .write(path, data)
557 .await
558 .map_err(|e| DakeraError::Storage(e.to_string()))?;
559 }
560 Ok(())
561 }
562
563 fn now() -> u64 {
565 std::time::SystemTime::now()
566 .duration_since(std::time::UNIX_EPOCH)
567 .unwrap_or_default()
568 .as_secs()
569 }
570
571 pub async fn put_object(&self, path: &str, data: Vec<u8>) -> Result<()> {
574 self.write_atomic(path, data).await
575 }
576
577 pub async fn get_object(&self, path: &str) -> Result<Option<Vec<u8>>> {
581 match self.operator.read(path).await {
582 Ok(data) => Ok(Some(data.to_vec())),
583 Err(e) if e.kind() == opendal::ErrorKind::NotFound => Ok(None),
584 Err(e) => Err(DakeraError::Storage(e.to_string())),
585 }
586 }
587}
588
589#[async_trait]
590impl VectorStorage for ObjectStorage {
591 async fn upsert(&self, namespace: &NamespaceId, vectors: Vec<Vector>) -> Result<usize> {
592 if vectors.is_empty() {
593 return Ok(0);
594 }
595
596 let _write_guard = self.namespace_lock(namespace).lock().await;
600
601 let mut meta = self
603 .read_namespace_meta(namespace)
604 .await?
605 .unwrap_or_else(|| NamespaceMetadata {
606 dimension: None,
607 vector_count: 0,
608 created_at: Self::now(),
609 updated_at: Self::now(),
610 });
611
612 let first_dim = vectors[0].values.len();
614 if let Some(dim) = meta.dimension {
615 for v in &vectors {
616 if v.values.len() != dim {
617 return Err(DakeraError::DimensionMismatch {
618 expected: dim,
619 actual: v.values.len(),
620 });
621 }
622 }
623 } else {
624 meta.dimension = Some(first_dim);
625 }
626
627 let total = vectors.len();
645
646 let fs_root = if Self::is_fs_safe_segment(namespace)
656 && vectors.iter().all(|v| Self::is_fs_safe_segment(&v.id))
657 {
658 self.fs_root.clone()
659 } else {
660 None
661 };
662
663 let new_inserts = if let Some(root) = fs_root {
664 self.write_vectors_fs(&root, namespace, vectors).await?
666 } else {
667 let op = self.operator.clone();
668 let ns = namespace.clone();
669 let needs_tmp_rename = self.needs_tmp_rename;
672 let results: Vec<Result<bool>> = stream::iter(vectors)
673 .map(|vector| {
674 let op = op.clone();
675 let ns = ns.clone();
676 async move {
677 let path = ObjectStorage::vector_path(&ns, &vector.id);
678 let stored: StoredVector = vector.into();
679 let data = serde_json::to_vec(&stored)
680 .map_err(|e| DakeraError::Storage(e.to_string()))?;
681
682 let exists = op
685 .exists(&path)
686 .await
687 .map_err(|e| DakeraError::Storage(e.to_string()))?;
688
689 if needs_tmp_rename {
690 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
694 let now_secs = std::time::SystemTime::now()
695 .duration_since(std::time::UNIX_EPOCH)
696 .unwrap_or_default()
697 .as_secs();
698 let tmp_path = format!("{}.tmp.{}.{}", path, now_secs, seq);
699 let rename_result = async {
700 op.write(&tmp_path, data.clone())
701 .await
702 .map_err(|e| DakeraError::Storage(e.to_string()))?;
703 op.rename(&tmp_path, &path)
704 .await
705 .map_err(|e| DakeraError::Storage(e.to_string()))?;
706 Ok::<(), DakeraError>(())
707 }
708 .await;
709 if let Err(e) = rename_result {
710 tracing::debug!(
711 path = %path,
712 error = %e,
713 "atomic rename failed, falling back to direct write"
714 );
715 let _ = op.delete(&tmp_path).await;
716 op.write(&path, data)
717 .await
718 .map_err(|e| DakeraError::Storage(e.to_string()))?;
719 }
720 } else {
721 op.write(&path, data)
728 .await
729 .map_err(|e| DakeraError::Storage(e.to_string()))?;
730 }
731
732 Ok::<bool, DakeraError>(!exists)
733 }
734 })
735 .buffer_unordered(s3_concurrent_ops())
736 .collect()
737 .await;
738
739 let mut count = 0usize;
740 for r in results {
741 if r? {
742 count += 1;
743 }
744 }
745 count
746 };
747
748 meta.vector_count += new_inserts;
749 meta.updated_at = Self::now();
750 self.write_namespace_meta(namespace, &meta).await?;
751
752 tracing::debug!(
753 namespace = namespace,
754 upserted = total,
755 "Upserted vectors to object storage"
756 );
757
758 Ok(total)
759 }
760
761 async fn get(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<Vec<Vector>> {
762 if ids.is_empty() {
763 return Ok(Vec::new());
764 }
765
766 let now = std::time::SystemTime::now()
767 .duration_since(std::time::UNIX_EPOCH)
768 .unwrap_or_default()
769 .as_secs();
770
771 let read_tasks: Vec<_> = ids
772 .iter()
773 .map(|id| {
774 let operator = self.operator.clone();
775 let corruption = self.corruption_count.clone();
776 let path = Self::vector_path(namespace, id);
777 let id = id.clone();
778 async move {
779 match operator.read(&path).await {
780 Ok(data) => {
781 let bytes = data.to_vec();
782 if bytes.is_empty() {
783 corruption.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
784 tracing::error!(
785 vector_id = %id,
786 "Empty vector file detected, skipping (silent data loss — see corruption_count)"
787 );
788 return Ok(None);
789 }
790 match serde_json::from_slice::<StoredVector>(&bytes) {
791 Ok(stored) => {
792 let vector: Vector = stored.into();
793 if !vector.is_expired_at(now) {
794 Ok(Some(vector))
795 } else {
796 Ok(None)
797 }
798 }
799 Err(e) => {
800 corruption.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
801 tracing::error!(
802 vector_id = %id,
803 error = %e,
804 bytes_len = bytes.len(),
805 "Corrupted vector file detected, skipping (silent data loss — see corruption_count)"
806 );
807 Ok(None)
808 }
809 }
810 }
811 Err(e) if e.kind() == opendal::ErrorKind::NotFound => Ok(None),
812 Err(e) => Err(DakeraError::Storage(e.to_string())),
813 }
814 }
815 })
816 .collect();
817
818 let results: Vec<Result<Option<Vector>>> = stream::iter(read_tasks)
819 .buffer_unordered(s3_concurrent_ops())
820 .collect()
821 .await;
822
823 let mut vectors = Vec::with_capacity(ids.len());
824 for result in results {
825 if let Some(v) = result? {
826 vectors.push(v);
827 }
828 }
829 Ok(vectors)
830 }
831
832 async fn get_all(&self, namespace: &NamespaceId) -> Result<Vec<Vector>> {
833 let prefix = Self::namespace_vectors_prefix(namespace);
834
835 let entries = self
836 .operator
837 .list(&prefix)
838 .await
839 .map_err(|e| DakeraError::Storage(e.to_string()))?;
840
841 let json_paths: Vec<String> = entries
842 .into_iter()
843 .filter(|e| e.path().ends_with(".json"))
844 .map(|e| e.path().to_string())
845 .collect();
846
847 if json_paths.is_empty() {
848 return Ok(Vec::new());
849 }
850
851 let now = std::time::SystemTime::now()
852 .duration_since(std::time::UNIX_EPOCH)
853 .unwrap_or_default()
854 .as_secs();
855
856 let results: Vec<Option<Vector>> = stream::iter(json_paths.into_iter().map(|path| {
857 let operator = self.operator.clone();
858 let corruption = self.corruption_count.clone();
859 async move {
860 match operator.read(&path).await {
861 Ok(data) => {
862 let bytes = data.to_vec();
863 match serde_json::from_slice::<StoredVector>(&bytes) {
864 Ok(stored) => {
865 let vector: Vector = stored.into();
866 if !vector.is_expired_at(now) {
867 return Some(vector);
868 }
869 None
870 }
871 Err(e) => {
872 corruption.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
875 tracing::error!(
876 path = %path,
877 error = %e,
878 bytes_len = bytes.len(),
879 "Corrupted vector file in namespace scan, skipping (silent data loss — see corruption_count)"
880 );
881 None
882 }
883 }
884 }
885 Err(e) => {
886 tracing::warn!(path = %path, error = %e, "Failed to read vector");
887 None
888 }
889 }
890 }
891 }))
892 .buffer_unordered(s3_concurrent_ops())
893 .collect()
894 .await;
895
896 Ok(results.into_iter().flatten().collect())
897 }
898
899 async fn delete(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<usize> {
900 if ids.is_empty() {
901 return Ok(0);
902 }
903
904 let _write_guard = self.namespace_lock(namespace).lock().await;
907
908 let delete_tasks: Vec<_> = ids
909 .iter()
910 .map(|id| {
911 let operator = self.operator.clone();
912 let path = Self::vector_path(namespace, id);
913 async move {
914 let exists = operator
915 .exists(&path)
916 .await
917 .map_err(|e| DakeraError::Storage(e.to_string()))?;
918 if exists {
919 match operator.delete(&path).await {
920 Ok(_) => Ok(true),
921 Err(e) if e.kind() == opendal::ErrorKind::NotFound => Ok(false),
922 Err(e) => Err(DakeraError::Storage(e.to_string())),
923 }
924 } else {
925 Ok(false)
926 }
927 }
928 })
929 .collect();
930
931 let results: Vec<Result<bool>> = stream::iter(delete_tasks)
932 .buffer_unordered(s3_concurrent_ops())
933 .collect()
934 .await;
935
936 let mut deleted = 0;
937 for result in results {
938 if result? {
939 deleted += 1;
940 }
941 }
942
943 if deleted > 0 {
945 if let Some(mut meta) = self.read_namespace_meta(namespace).await? {
946 meta.vector_count = meta.vector_count.saturating_sub(deleted);
947 meta.updated_at = Self::now();
948 self.write_namespace_meta(namespace, &meta).await?;
949 }
950 }
951
952 Ok(deleted)
953 }
954
955 async fn namespace_exists(&self, namespace: &NamespaceId) -> Result<bool> {
956 Ok(self.read_namespace_meta(namespace).await?.is_some())
957 }
958
959 async fn ensure_namespace(&self, namespace: &NamespaceId) -> Result<()> {
960 if self.read_namespace_meta(namespace).await?.is_none() {
961 let meta = NamespaceMetadata {
962 dimension: None,
963 vector_count: 0,
964 created_at: Self::now(),
965 updated_at: Self::now(),
966 };
967 self.write_namespace_meta(namespace, &meta).await?;
968 }
969 Ok(())
970 }
971
972 async fn dimension(&self, namespace: &NamespaceId) -> Result<Option<usize>> {
973 Ok(self
974 .read_namespace_meta(namespace)
975 .await?
976 .and_then(|m| m.dimension))
977 }
978
979 async fn count(&self, namespace: &NamespaceId) -> Result<usize> {
980 Ok(self
981 .read_namespace_meta(namespace)
982 .await?
983 .map(|m| m.vector_count)
984 .unwrap_or(0))
985 }
986
987 async fn list_namespaces(&self) -> Result<Vec<NamespaceId>> {
988 let entries = self
989 .operator
990 .list("namespaces/")
991 .await
992 .map_err(|e| DakeraError::Storage(e.to_string()))?;
993
994 let candidates: Vec<String> = entries
996 .iter()
997 .filter_map(|entry| {
998 let ns = entry
999 .path()
1000 .strip_prefix("namespaces/")?
1001 .trim_end_matches('/');
1002 if !ns.is_empty() && !ns.contains('/') {
1003 Some(ns.to_string())
1004 } else {
1005 None
1006 }
1007 })
1008 .collect();
1009
1010 let checked: Vec<Result<Option<String>>> = stream::iter(candidates)
1016 .map(|ns| async move { Ok(self.read_namespace_meta(&ns).await?.map(|_| ns)) })
1017 .buffer_unordered(s3_concurrent_ops())
1018 .collect()
1019 .await;
1020
1021 let mut namespaces = Vec::new();
1022 for r in checked {
1023 if let Some(ns) = r? {
1024 namespaces.push(ns);
1025 }
1026 }
1027
1028 Ok(namespaces)
1029 }
1030
1031 async fn delete_namespace(&self, namespace: &NamespaceId) -> Result<bool> {
1032 if !self.namespace_exists(namespace).await? {
1034 return Ok(false);
1035 }
1036
1037 let prefix = format!("namespaces/{}/", namespace);
1041 self.operator
1042 .delete_with(&prefix)
1043 .recursive(true)
1044 .await
1045 .map_err(|e| DakeraError::Storage(e.to_string()))?;
1046
1047 tracing::debug!(
1048 namespace = namespace,
1049 "Deleted namespace from object storage"
1050 );
1051
1052 Ok(true)
1053 }
1054
1055 async fn cleanup_expired(&self, _namespace: &NamespaceId) -> Result<usize> {
1056 Ok(0)
1059 }
1060
1061 async fn cleanup_all_expired(&self) -> Result<usize> {
1062 Ok(0)
1064 }
1065}
1066
1067#[async_trait]
1068impl IndexStorage for ObjectStorage {
1069 async fn save_index(
1070 &self,
1071 namespace: &NamespaceId,
1072 index_type: IndexType,
1073 data: Vec<u8>,
1074 ) -> Result<()> {
1075 let path = format!(
1076 "namespaces/{}/indexes/{}.bin",
1077 namespace,
1078 index_type.as_str()
1079 );
1080 self.operator
1081 .write(&path, data)
1082 .await
1083 .map_err(|e| DakeraError::Storage(e.to_string()))?;
1084
1085 tracing::debug!(
1086 namespace = namespace,
1087 index_type = index_type.as_str(),
1088 "Saved index to object storage"
1089 );
1090 Ok(())
1091 }
1092
1093 async fn load_index(
1094 &self,
1095 namespace: &NamespaceId,
1096 index_type: IndexType,
1097 ) -> Result<Option<Vec<u8>>> {
1098 let path = format!(
1099 "namespaces/{}/indexes/{}.bin",
1100 namespace,
1101 index_type.as_str()
1102 );
1103 match self.operator.read(&path).await {
1104 Ok(data) => {
1105 tracing::debug!(
1106 namespace = namespace,
1107 index_type = index_type.as_str(),
1108 size = data.len(),
1109 "Loaded index from object storage"
1110 );
1111 Ok(Some(data.to_vec()))
1112 }
1113 Err(e) if e.kind() == opendal::ErrorKind::NotFound => Ok(None),
1114 Err(e) => Err(DakeraError::Storage(e.to_string())),
1115 }
1116 }
1117
1118 async fn delete_index(&self, namespace: &NamespaceId, index_type: IndexType) -> Result<bool> {
1119 let path = format!(
1120 "namespaces/{}/indexes/{}.bin",
1121 namespace,
1122 index_type.as_str()
1123 );
1124 let exists = self
1125 .operator
1126 .exists(&path)
1127 .await
1128 .map_err(|e| DakeraError::Storage(e.to_string()))?;
1129
1130 if exists {
1131 self.operator
1132 .delete(&path)
1133 .await
1134 .map_err(|e| DakeraError::Storage(e.to_string()))?;
1135 tracing::debug!(
1136 namespace = namespace,
1137 index_type = index_type.as_str(),
1138 "Deleted index from object storage"
1139 );
1140 }
1141 Ok(exists)
1142 }
1143
1144 async fn index_exists(&self, namespace: &NamespaceId, index_type: IndexType) -> Result<bool> {
1145 let path = format!(
1146 "namespaces/{}/indexes/{}.bin",
1147 namespace,
1148 index_type.as_str()
1149 );
1150 self.operator
1151 .exists(&path)
1152 .await
1153 .map_err(|e| DakeraError::Storage(e.to_string()))
1154 }
1155
1156 async fn list_indexes(&self, namespace: &NamespaceId) -> Result<Vec<IndexType>> {
1157 let prefix = format!("namespaces/{}/indexes/", namespace);
1158 let entries = self
1159 .operator
1160 .list(&prefix)
1161 .await
1162 .map_err(|e| DakeraError::Storage(e.to_string()))?;
1163
1164 let mut indexes = Vec::new();
1165 for entry in entries {
1166 let path = entry.path();
1167 if path.ends_with(".bin") {
1168 if let Some(filename) = path.strip_prefix(&prefix) {
1170 let name = filename.trim_end_matches(".bin");
1171 match name {
1172 "hnsw" => indexes.push(IndexType::Hnsw),
1173 "pq" => indexes.push(IndexType::Pq),
1174 "ivf" => indexes.push(IndexType::Ivf),
1175 "spfresh" => indexes.push(IndexType::SpFresh),
1176 "fulltext" => indexes.push(IndexType::FullText),
1177 _ => {} }
1179 }
1180 }
1181 }
1182
1183 Ok(indexes)
1184 }
1185}
1186
1187pub fn create_operator(config: &ObjectStorageConfig) -> Result<Operator> {
1190 ObjectStorage::build_operator(config)
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[tokio::test]
1198 async fn test_object_storage_memory() {
1199 let storage = ObjectStorage::memory().unwrap();
1200 let namespace = "test".to_string();
1201
1202 storage.ensure_namespace(&namespace).await.unwrap();
1204 assert!(storage.namespace_exists(&namespace).await.unwrap());
1205
1206 let vectors = vec![
1208 Vector {
1209 id: "v1".to_string(),
1210 values: vec![1.0, 2.0, 3.0],
1211 metadata: None,
1212 ttl_seconds: None,
1213 expires_at: None,
1214 },
1215 Vector {
1216 id: "v2".to_string(),
1217 values: vec![4.0, 5.0, 6.0],
1218 metadata: Some(serde_json::json!({"key": "value"})),
1219 ttl_seconds: None,
1220 expires_at: None,
1221 },
1222 ];
1223
1224 let count = storage.upsert(&namespace, vectors).await.unwrap();
1225 assert_eq!(count, 2);
1226
1227 let results = storage.get(&namespace, &["v1".to_string()]).await.unwrap();
1229 assert_eq!(results.len(), 1);
1230 assert_eq!(results[0].id, "v1");
1231 assert_eq!(results[0].values, vec![1.0, 2.0, 3.0]);
1232
1233 let all = storage.get_all(&namespace).await.unwrap();
1235 assert_eq!(all.len(), 2);
1236
1237 assert_eq!(storage.count(&namespace).await.unwrap(), 2);
1239
1240 let deleted = storage
1242 .delete(&namespace, &["v1".to_string()])
1243 .await
1244 .unwrap();
1245 assert_eq!(deleted, 1);
1246 assert!(storage
1247 .get(&namespace, &["v1".to_string()])
1248 .await
1249 .unwrap()
1250 .is_empty());
1251 assert_eq!(storage.count(&namespace).await.unwrap(), 1);
1252 }
1253
1254 #[tokio::test]
1257 async fn corrupt_vector_file_is_counted_and_skipped() {
1258 let storage = ObjectStorage::memory().unwrap();
1259 let ns = "test".to_string();
1260 storage.ensure_namespace(&ns).await.unwrap();
1261 storage
1262 .upsert(
1263 &ns,
1264 vec![Vector {
1265 id: "good".to_string(),
1266 values: vec![1.0, 2.0],
1267 metadata: None,
1268 ttl_seconds: None,
1269 expires_at: None,
1270 }],
1271 )
1272 .await
1273 .unwrap();
1274
1275 let bad_path = ObjectStorage::vector_path(&ns, "bad");
1277 storage
1278 .operator
1279 .write(&bad_path, b"{ not valid json".to_vec())
1280 .await
1281 .unwrap();
1282
1283 assert_eq!(storage.corruption_count(), 0);
1284
1285 let got = storage.get(&ns, &["bad".to_string()]).await.unwrap();
1287 assert!(got.is_empty());
1288 assert_eq!(storage.corruption_count(), 1);
1289
1290 let all = storage.get_all(&ns).await.unwrap();
1292 assert_eq!(all.len(), 1);
1293 assert_eq!(all[0].id, "good");
1294 assert!(storage.corruption_count() >= 2);
1295 }
1296
1297 #[tokio::test]
1298 async fn test_object_storage_dimension_mismatch() {
1299 let storage = ObjectStorage::memory().unwrap();
1300 let namespace = "test".to_string();
1301 storage.ensure_namespace(&namespace).await.unwrap();
1302
1303 let v1 = vec![Vector {
1305 id: "v1".to_string(),
1306 values: vec![1.0, 2.0, 3.0],
1307 metadata: None,
1308 ttl_seconds: None,
1309 expires_at: None,
1310 }];
1311 storage.upsert(&namespace, v1).await.unwrap();
1312
1313 let v2 = vec![Vector {
1315 id: "v2".to_string(),
1316 values: vec![1.0, 2.0], metadata: None,
1318 ttl_seconds: None,
1319 expires_at: None,
1320 }];
1321 let result = storage.upsert(&namespace, v2).await;
1322 assert!(result.is_err());
1323 }
1324
1325 #[tokio::test]
1326 async fn test_object_storage_upsert() {
1327 let storage = ObjectStorage::memory().unwrap();
1328 let namespace = "test".to_string();
1329 storage.ensure_namespace(&namespace).await.unwrap();
1330
1331 let v1 = vec![Vector {
1333 id: "v1".to_string(),
1334 values: vec![1.0, 2.0],
1335 metadata: None,
1336 ttl_seconds: None,
1337 expires_at: None,
1338 }];
1339 storage.upsert(&namespace, v1).await.unwrap();
1340
1341 let v1_updated = vec![Vector {
1343 id: "v1".to_string(),
1344 values: vec![3.0, 4.0],
1345 metadata: None,
1346 ttl_seconds: None,
1347 expires_at: None,
1348 }];
1349 storage.upsert(&namespace, v1_updated).await.unwrap();
1350
1351 let results = storage.get(&namespace, &["v1".to_string()]).await.unwrap();
1353 assert_eq!(results.len(), 1);
1354 assert_eq!(results[0].values, vec![3.0, 4.0]);
1355
1356 assert_eq!(storage.count(&namespace).await.unwrap(), 1);
1358 }
1359
1360 #[tokio::test]
1361 async fn test_index_storage() {
1362 let storage = ObjectStorage::memory().unwrap();
1363 let namespace = "test_index".to_string();
1364
1365 assert!(!storage
1367 .index_exists(&namespace, IndexType::Hnsw)
1368 .await
1369 .unwrap());
1370
1371 let index_data = b"fake hnsw index data for testing".to_vec();
1373 storage
1374 .save_index(&namespace, IndexType::Hnsw, index_data.clone())
1375 .await
1376 .unwrap();
1377
1378 assert!(storage
1380 .index_exists(&namespace, IndexType::Hnsw)
1381 .await
1382 .unwrap());
1383 assert!(!storage
1384 .index_exists(&namespace, IndexType::Pq)
1385 .await
1386 .unwrap());
1387
1388 let loaded = storage
1390 .load_index(&namespace, IndexType::Hnsw)
1391 .await
1392 .unwrap();
1393 assert!(loaded.is_some());
1394 assert_eq!(loaded.unwrap(), index_data);
1395
1396 let pq_data = b"fake pq index data".to_vec();
1398 storage
1399 .save_index(&namespace, IndexType::Pq, pq_data)
1400 .await
1401 .unwrap();
1402
1403 let indexes = storage.list_indexes(&namespace).await.unwrap();
1405 assert_eq!(indexes.len(), 2);
1406 assert!(indexes.contains(&IndexType::Hnsw));
1407 assert!(indexes.contains(&IndexType::Pq));
1408
1409 let deleted = storage
1411 .delete_index(&namespace, IndexType::Hnsw)
1412 .await
1413 .unwrap();
1414 assert!(deleted);
1415 assert!(!storage
1416 .index_exists(&namespace, IndexType::Hnsw)
1417 .await
1418 .unwrap());
1419
1420 let deleted = storage
1422 .delete_index(&namespace, IndexType::Hnsw)
1423 .await
1424 .unwrap();
1425 assert!(!deleted);
1426
1427 let loaded = storage
1429 .load_index(&namespace, IndexType::Hnsw)
1430 .await
1431 .unwrap();
1432 assert!(loaded.is_none());
1433 }
1434
1435 fn make_vector(id: &str, dim: usize) -> Vector {
1438 Vector {
1439 id: id.to_string(),
1440 values: vec![0.1; dim],
1441 metadata: None,
1442 ttl_seconds: None,
1443 expires_at: None,
1444 }
1445 }
1446
1447 #[tokio::test]
1448 async fn test_upsert_batch_parallel_all_new() {
1449 let storage = ObjectStorage::memory().unwrap();
1450 let ns = "batch_all_new".to_string();
1451 storage.ensure_namespace(&ns).await.unwrap();
1452
1453 let vectors: Vec<Vector> = (0..50).map(|i| make_vector(&format!("v{i}"), 4)).collect();
1455 let count = storage.upsert(&ns, vectors).await.unwrap();
1456
1457 assert_eq!(count, 50);
1458 assert_eq!(storage.count(&ns).await.unwrap(), 50);
1459 }
1460
1461 #[tokio::test]
1462 async fn test_upsert_batch_parallel_idempotent_count() {
1463 let storage = ObjectStorage::memory().unwrap();
1464 let ns = "batch_idempotent".to_string();
1465 storage.ensure_namespace(&ns).await.unwrap();
1466
1467 let vectors: Vec<Vector> = (0..10).map(|i| make_vector(&format!("v{i}"), 4)).collect();
1468 storage.upsert(&ns, vectors.clone()).await.unwrap();
1469
1470 storage.upsert(&ns, vectors).await.unwrap();
1472 assert_eq!(storage.count(&ns).await.unwrap(), 10);
1473 }
1474
1475 #[tokio::test]
1476 async fn test_upsert_batch_parallel_empty() {
1477 let storage = ObjectStorage::memory().unwrap();
1478 let ns = "batch_empty".to_string();
1479 storage.ensure_namespace(&ns).await.unwrap();
1480
1481 let count = storage.upsert(&ns, vec![]).await.unwrap();
1482 assert_eq!(count, 0);
1483 assert_eq!(storage.count(&ns).await.unwrap(), 0);
1484 }
1485
1486 #[tokio::test]
1487 async fn test_upsert_batch_parallel_large_batch() {
1488 let storage = ObjectStorage::memory().unwrap();
1490 let ns = "batch_large".to_string();
1491 storage.ensure_namespace(&ns).await.unwrap();
1492
1493 let vectors: Vec<Vector> = (0..200).map(|i| make_vector(&format!("v{i}"), 8)).collect();
1494 let count = storage.upsert(&ns, vectors).await.unwrap();
1495
1496 assert_eq!(count, 200);
1497 assert_eq!(storage.count(&ns).await.unwrap(), 200);
1498 }
1499
1500 #[tokio::test]
1504 async fn test_upsert_count_mixed_insert_update_batch() {
1505 let storage = ObjectStorage::memory().unwrap();
1506 let ns = "mixed".to_string();
1507 storage.ensure_namespace(&ns).await.unwrap();
1508
1509 storage
1511 .upsert(&ns, vec![make_vector("v0", 4), make_vector("v1", 4)])
1512 .await
1513 .unwrap();
1514 assert_eq!(storage.count(&ns).await.unwrap(), 2);
1515
1516 storage
1518 .upsert(
1519 &ns,
1520 vec![
1521 make_vector("v1", 4),
1522 make_vector("v2", 4),
1523 make_vector("v3", 4),
1524 ],
1525 )
1526 .await
1527 .unwrap();
1528 assert_eq!(storage.count(&ns).await.unwrap(), 4);
1529
1530 storage
1532 .upsert(&ns, vec![make_vector("v0", 4), make_vector("v3", 4)])
1533 .await
1534 .unwrap();
1535 assert_eq!(storage.count(&ns).await.unwrap(), 4);
1536 }
1537
1538 #[tokio::test]
1542 async fn test_upsert_fs_fast_path_roundtrip() {
1543 let tmp = tempfile::tempdir().unwrap();
1544 let storage = ObjectStorage::filesystem(tmp.path().to_str().unwrap()).unwrap();
1545 let ns = "fsfast".to_string();
1546 storage.ensure_namespace(&ns).await.unwrap();
1547
1548 storage
1550 .upsert(
1551 &ns,
1552 vec![
1553 make_vector("a", 4),
1554 make_vector("b", 4),
1555 make_vector("c", 4),
1556 ],
1557 )
1558 .await
1559 .unwrap();
1560 assert_eq!(storage.count(&ns).await.unwrap(), 3);
1561
1562 let got = storage
1564 .get(&ns, &["a".to_string(), "b".to_string()])
1565 .await
1566 .unwrap();
1567 assert_eq!(got.len(), 2);
1568
1569 storage
1571 .upsert(&ns, vec![make_vector("b", 4), make_vector("d", 4)])
1572 .await
1573 .unwrap();
1574 assert_eq!(storage.count(&ns).await.unwrap(), 4);
1575
1576 storage
1579 .upsert(
1580 &ns,
1581 vec![make_vector("safe1", 4), make_vector("../escape", 4)],
1582 )
1583 .await
1584 .unwrap();
1585 let got = storage.get(&ns, &["safe1".to_string()]).await.unwrap();
1586 assert_eq!(got.len(), 1);
1587 assert!(!tmp.path().parent().unwrap().join("escape.json").exists());
1589 }
1590
1591 #[tokio::test]
1597 async fn test_upsert_incremental_count_growing_namespace_fs() {
1598 let tmp = tempfile::tempdir().unwrap();
1599 let storage = ObjectStorage::filesystem(tmp.path().to_str().unwrap()).unwrap();
1600 let ns = "grow".to_string();
1601 storage.ensure_namespace(&ns).await.unwrap();
1602
1603 const BATCHES: usize = 40;
1604 for b in 0..BATCHES {
1605 let new_id = format!("v{}", b + 1);
1608 let upd_id = format!("v{}", b);
1609 storage
1610 .upsert(&ns, vec![make_vector(&upd_id, 4), make_vector(&new_id, 4)])
1611 .await
1612 .unwrap();
1613 assert_eq!(storage.count(&ns).await.unwrap(), b + 2);
1615 }
1616 assert_eq!(storage.count(&ns).await.unwrap(), BATCHES + 1);
1617 }
1618
1619 #[tokio::test]
1622 async fn test_upsert_incremental_count_growing_namespace_opendal() {
1623 let storage = ObjectStorage::memory().unwrap();
1624 let ns = "grow-od".to_string();
1625 storage.ensure_namespace(&ns).await.unwrap();
1626
1627 const BATCHES: usize = 30;
1628 for b in 0..BATCHES {
1629 let new_id = format!("v{}", b + 1);
1630 let upd_id = format!("v{}", b);
1631 storage
1632 .upsert(&ns, vec![make_vector(&upd_id, 4), make_vector(&new_id, 4)])
1633 .await
1634 .unwrap();
1635 assert_eq!(storage.count(&ns).await.unwrap(), b + 2);
1636 }
1637 assert_eq!(storage.count(&ns).await.unwrap(), BATCHES + 1);
1638 }
1639
1640 #[test]
1646 fn test_write_strategy_selection() {
1647 assert!(!ObjectStorage::needs_tmp_rename_for(
1649 &ObjectStorageConfig::Memory
1650 ));
1651 assert!(!ObjectStorage::needs_tmp_rename_for(
1652 &ObjectStorageConfig::S3 {
1653 bucket: "dakera".to_string(),
1654 region: None,
1655 endpoint: None,
1656 access_key_id: None,
1657 secret_access_key: None,
1658 }
1659 ));
1660 assert!(!ObjectStorage::needs_tmp_rename_for(
1661 &ObjectStorageConfig::Azure {
1662 container: "c".to_string(),
1663 account_name: "acct".to_string(),
1664 account_key: None,
1665 sas_token: None,
1666 endpoint: None,
1667 }
1668 ));
1669 assert!(!ObjectStorage::needs_tmp_rename_for(
1670 &ObjectStorageConfig::Gcs {
1671 bucket: "dakera".to_string(),
1672 credential_path: None,
1673 endpoint: None,
1674 }
1675 ));
1676 assert!(ObjectStorage::needs_tmp_rename_for(
1678 &ObjectStorageConfig::Filesystem {
1679 root: "/tmp/x".to_string(),
1680 }
1681 ));
1682 assert!(!ObjectStorage::memory().unwrap().needs_tmp_rename);
1684 let tmp = tempfile::tempdir().unwrap();
1685 assert!(
1686 ObjectStorage::filesystem(tmp.path().to_str().unwrap())
1687 .unwrap()
1688 .needs_tmp_rename
1689 );
1690 }
1691
1692 #[tokio::test]
1697 async fn test_upsert_direct_write_path_roundtrip() {
1698 let storage = ObjectStorage::memory().unwrap();
1699 assert!(!storage.needs_tmp_rename, "memory must take direct path");
1700 let ns = "direct".to_string();
1701 storage.ensure_namespace(&ns).await.unwrap();
1702
1703 storage
1705 .upsert(&ns, vec![make_vector("a", 4), make_vector("b", 4)])
1706 .await
1707 .unwrap();
1708 assert_eq!(storage.count(&ns).await.unwrap(), 2);
1709
1710 let a_updated = Vector {
1712 id: "a".to_string(),
1713 values: vec![9.0, 9.0, 9.0, 9.0],
1714 metadata: None,
1715 ttl_seconds: None,
1716 expires_at: None,
1717 };
1718 storage.upsert(&ns, vec![a_updated]).await.unwrap();
1719 let got = storage.get(&ns, &["a".to_string()]).await.unwrap();
1720 assert_eq!(got.len(), 1);
1721 assert_eq!(got[0].values, vec![9.0, 9.0, 9.0, 9.0]);
1722 assert_eq!(storage.count(&ns).await.unwrap(), 2);
1724
1725 storage
1727 .upsert(&ns, vec![make_vector("b", 4), make_vector("c", 4)])
1728 .await
1729 .unwrap();
1730 assert_eq!(storage.count(&ns).await.unwrap(), 3);
1731 }
1732
1733 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1744 #[ignore]
1745 async fn profile_upsert_substages() {
1746 use std::time::Instant;
1747 const N: usize = 200;
1748 const DIM: usize = 1024; let conc = s3_concurrent_ops();
1750
1751 let make = |i: usize| -> Vector {
1753 Vector {
1754 id: format!("prof-{i}"),
1755 values: vec![0.0123_f32; DIM],
1756 metadata: Some(serde_json::json!({
1757 "_embedding_kind": "static",
1758 "tags": ["dia_1", "speaker_a", "session_3", "2026-06-03"],
1759 "importance": 0.7,
1760 "session_id": format!("sess-{}", i % 8),
1761 })),
1762 ttl_seconds: None,
1763 expires_at: None,
1764 }
1765 };
1766 let vectors: Vec<Vector> = (0..N).map(make).collect();
1767 let payload_bytes = serde_json::to_vec(&StoredVector::from(vectors[0].clone()))
1768 .unwrap()
1769 .len();
1770
1771 let tmp0 = tempfile::tempdir().unwrap();
1773 let s0 = ObjectStorage::filesystem(tmp0.path().to_str().unwrap()).unwrap();
1774 let ns = "prof".to_string();
1775 s0.ensure_namespace(&ns).await.unwrap();
1776 let t = Instant::now();
1777 let n = s0.upsert(&ns, vectors.clone()).await.unwrap();
1778 let v0_ms = t.elapsed().as_secs_f64() * 1000.0;
1779 assert_eq!(n, N);
1780
1781 async fn timed<F, Fut>(label: &str, n: usize, conc: usize, f: F) -> f64
1783 where
1784 F: Fn(usize, Operator) -> Fut,
1785 Fut: std::future::Future<Output = ()>,
1786 {
1787 let tmp = tempfile::tempdir().unwrap();
1788 let op = ObjectStorage::filesystem(tmp.path().to_str().unwrap())
1789 .unwrap()
1790 .operator;
1791 let t = Instant::now();
1792 stream::iter(0..n)
1793 .map(|i| {
1794 let op = op.clone();
1795 f(i, op)
1796 })
1797 .buffer_unordered(conc)
1798 .collect::<Vec<()>>()
1799 .await;
1800 let ms = t.elapsed().as_secs_f64() * 1000.0;
1801 drop(tmp);
1803 let _ = label;
1804 ms
1805 }
1806
1807 let data: Vec<Vec<u8>> = vectors
1808 .iter()
1809 .map(|v| serde_json::to_vec(&StoredVector::from(v.clone())).unwrap())
1810 .collect();
1811 let path_of = |i: usize| ObjectStorage::vector_path(&ns, &format!("prof-{i}"));
1812
1813 let d = data.clone();
1815 let p_exists = timed("exists", N, conc, move |i, op| {
1816 let path = path_of(i);
1817 let _ = &d;
1818 async move {
1819 let _ = op.exists(&path).await;
1820 }
1821 })
1822 .await;
1823
1824 let d = data.clone();
1826 let p_write_direct = timed("write_direct", N, conc, move |i, op| {
1827 let path = path_of(i);
1828 let bytes = d[i].clone();
1829 async move {
1830 op.write(&path, bytes).await.unwrap();
1831 }
1832 })
1833 .await;
1834
1835 let d = data.clone();
1837 let p_tmp_rename = timed("write_tmp_rename", N, conc, move |i, op| {
1838 let path = path_of(i);
1839 let bytes = d[i].clone();
1840 async move {
1841 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1842 let tmp_path = format!("{path}.tmp.{seq}");
1843 op.write(&tmp_path, bytes).await.unwrap();
1844 op.rename(&tmp_path, &path).await.unwrap();
1845 }
1846 })
1847 .await;
1848
1849 let d = data.clone();
1851 let p_full = timed("exists+tmp+rename", N, conc, move |i, op| {
1852 let path = path_of(i);
1853 let bytes = d[i].clone();
1854 async move {
1855 let _ = op.exists(&path).await;
1856 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1857 let tmp_path = format!("{path}.tmp.{seq}");
1858 op.write(&tmp_path, bytes).await.unwrap();
1859 op.rename(&tmp_path, &path).await.unwrap();
1860 }
1861 })
1862 .await;
1863
1864 let mut sweep = Vec::new();
1867 for &c in &[8usize, 16, 32, 64, 128] {
1868 let d = data.clone();
1869 let ms = timed("sweep", N, c, move |i, op| {
1870 let path = path_of(i);
1871 let bytes = d[i].clone();
1872 async move {
1873 let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1874 let tmp_path = format!("{path}.tmp.{seq}");
1875 op.write(&tmp_path, bytes).await.unwrap();
1876 op.rename(&tmp_path, &path).await.unwrap();
1877 }
1878 })
1879 .await;
1880 sweep.push((c, ms));
1881 }
1882
1883 async fn raw_fs(n: usize, conc: usize, bytes: usize, do_sync: bool) -> f64 {
1887 let dir = tempfile::tempdir().unwrap();
1888 let root = dir.path().to_path_buf();
1889 let payload = vec![0u8; bytes];
1890 let t = Instant::now();
1891 stream::iter(0..n)
1892 .map(|i| {
1893 let root = root.clone();
1894 let payload = payload.clone();
1895 async move {
1896 tokio::task::spawn_blocking(move || {
1897 use std::io::Write;
1898 let p = root.join(format!("f{i}.bin"));
1899 let mut f = std::fs::File::create(&p).unwrap();
1900 f.write_all(&payload).unwrap();
1901 if do_sync {
1902 f.sync_all().unwrap();
1903 }
1904 })
1905 .await
1906 .unwrap();
1907 }
1908 })
1909 .buffer_unordered(conc)
1910 .collect::<Vec<()>>()
1911 .await;
1912 let ms = t.elapsed().as_secs_f64() * 1000.0;
1913 drop(dir);
1914 ms
1915 }
1916 let raw_nosync = raw_fs(N, 16, payload_bytes, false).await;
1917 let raw_sync = raw_fs(N, 16, payload_bytes, true).await;
1918
1919 let rate = |ms: f64| N as f64 / (ms / 1000.0);
1920 eprintln!("\n=== DAK-6287 storage_upsert sub-stage profile (fs backend) ===");
1921 eprintln!("N={N} dim={DIM} payload={payload_bytes}B concurrency={conc}");
1922 eprintln!(
1923 "V0 upsert() REAL path : {v0_ms:8.2} ms {:8.1} mem/s",
1924 rate(v0_ms)
1925 );
1926 eprintln!(
1927 "P_full exists+tmp+rename : {p_full:8.2} ms {:8.1} mem/s",
1928 rate(p_full)
1929 );
1930 eprintln!(
1931 "P_exists (stat only) : {p_exists:8.2} ms {:8.1} mem/s",
1932 rate(p_exists)
1933 );
1934 eprintln!(
1935 "P_write_tmp_rename : {p_tmp_rename:8.2} ms {:8.1} mem/s",
1936 rate(p_tmp_rename)
1937 );
1938 eprintln!(
1939 "P_write_direct : {p_write_direct:8.2} ms {:8.1} mem/s",
1940 rate(p_write_direct)
1941 );
1942 eprintln!("--- attribution (of P_full) ---");
1943 eprintln!("exists share : {:5.1}%", 100.0 * p_exists / p_full);
1944 eprintln!("rename overhead: {:5.1}% (tmp+rename {p_tmp_rename:.1} vs direct {p_write_direct:.1})",
1945 100.0 * (p_tmp_rename - p_write_direct).max(0.0) / p_full);
1946 eprintln!("--- concurrency sweep (write tmp+rename) ---");
1947 for (c, ms) in &sweep {
1948 eprintln!("conc={c:4} : {ms:8.2} ms {:8.1} mem/s", rate(*ms));
1949 }
1950 eprintln!("--- fsync isolation (raw std::fs, conc=16, {payload_bytes}B) ---");
1951 eprintln!(
1952 "write no-sync : {raw_nosync:8.2} ms {:8.1} mem/s",
1953 rate(raw_nosync)
1954 );
1955 eprintln!(
1956 "write+sync_all: {raw_sync:8.2} ms {:8.1} mem/s",
1957 rate(raw_sync)
1958 );
1959 eprintln!(
1960 "=> fsync cost factor: {:.1}x",
1961 raw_sync / raw_nosync.max(0.001)
1962 );
1963 }
1964
1965 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1993 #[ignore]
1994 async fn profile_upsert_substages_s3() {
1995 use std::time::Instant;
1996
1997 let endpoint = match std::env::var("DAKERA_PROFILE_S3_ENDPOINT") {
1998 Ok(e) => e,
1999 Err(_) => {
2000 eprintln!(
2001 "SKIP profile_upsert_substages_s3: set DAKERA_PROFILE_S3_ENDPOINT + \
2002 DAKERA_PROFILE_S3_BUCKET (+ AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) \
2003 to a live MinIO/S3 endpoint to run this profile."
2004 );
2005 return;
2006 }
2007 };
2008 let bucket = std::env::var("DAKERA_PROFILE_S3_BUCKET")
2009 .expect("DAKERA_PROFILE_S3_BUCKET required for S3 profile");
2010 let region = std::env::var("DAKERA_PROFILE_S3_REGION").ok();
2011 let access_key_id = std::env::var("AWS_ACCESS_KEY_ID").ok();
2012 let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
2013 let n: usize = std::env::var("DAKERA_PROFILE_N")
2014 .ok()
2015 .and_then(|v| v.parse().ok())
2016 .unwrap_or(100);
2017 const DIM: usize = 1024; let conc = s3_concurrent_ops();
2019
2020 let s3_cfg = ObjectStorageConfig::S3 {
2021 bucket: bucket.clone(),
2022 region: region.clone(),
2023 endpoint: Some(endpoint.clone()),
2024 access_key_id: access_key_id.clone(),
2025 secret_access_key: secret_access_key.clone(),
2026 };
2027
2028 let make = |i: usize| -> Vector {
2030 Vector {
2031 id: format!("prof-{i}"),
2032 values: vec![0.0123_f32; DIM],
2033 metadata: Some(serde_json::json!({
2034 "_embedding_kind": "static",
2035 "tags": ["dia_1", "speaker_a", "session_3", "2026-06-03"],
2036 "importance": 0.7,
2037 "session_id": format!("sess-{}", i % 8),
2038 })),
2039 ttl_seconds: None,
2040 expires_at: None,
2041 }
2042 };
2043 let vectors: Vec<Vector> = (0..n).map(make).collect();
2044 let data: Vec<Vec<u8>> = vectors
2045 .iter()
2046 .map(|v| serde_json::to_vec(&StoredVector::from(v.clone())).unwrap())
2047 .collect();
2048 let payload_bytes = data[0].len();
2049
2050 let s0 = ObjectStorage::new(s3_cfg.clone()).unwrap();
2052 assert!(!s0.needs_tmp_rename, "S3 must take the direct-PUT path");
2053 let ns = "locomo-bench-prof-v0".to_string();
2054 let _ = s0.delete_namespace(&ns).await; s0.ensure_namespace(&ns).await.unwrap();
2056 let t = Instant::now();
2057 let written = s0.upsert(&ns, vectors.clone()).await.unwrap();
2058 let v0_ms = t.elapsed().as_secs_f64() * 1000.0;
2059 assert_eq!(written, n);
2060
2061 let op = ObjectStorage::build_operator(&s3_cfg).unwrap();
2063 async fn timed<F, Fut>(op: &Operator, n: usize, conc: usize, f: F) -> f64
2064 where
2065 F: Fn(usize, Operator) -> Fut,
2066 Fut: std::future::Future<Output = ()>,
2067 {
2068 let t = Instant::now();
2069 stream::iter(0..n)
2070 .map(|i| f(i, op.clone()))
2071 .buffer_unordered(conc)
2072 .collect::<Vec<()>>()
2073 .await;
2074 t.elapsed().as_secs_f64() * 1000.0
2075 }
2076 fn key(phase: &str, i: usize) -> String {
2079 format!("namespaces/locomo-bench-prof-{phase}/vectors/prof-{i}.json")
2080 }
2081
2082 let p_exists = timed(&op, n, conc, |i, op| {
2084 let path = key("exists", i);
2085 async move {
2086 let _ = op.exists(&path).await;
2087 }
2088 })
2089 .await;
2090
2091 let d = data.clone();
2093 let p_put = timed(&op, n, conc, move |i, op| {
2094 let path = key("put", i);
2095 let bytes = d[i].clone();
2096 async move {
2097 op.write(&path, bytes).await.unwrap();
2098 }
2099 })
2100 .await;
2101
2102 let d = data.clone();
2108 let p_waste = timed(&op, n, conc, move |i, op| {
2109 let tmp = format!("{}.tmp", key("waste", i));
2110 let bytes = d[i].clone();
2111 async move {
2112 op.write(&tmp, bytes).await.unwrap();
2113 op.delete(&tmp).await.unwrap();
2114 }
2115 })
2116 .await;
2117
2118 let d = data.clone();
2122 let p_full_old = timed(&op, n, conc, move |i, op| {
2123 let path = key("old", i);
2124 let tmp = format!("{path}.tmp");
2125 let bytes = d[i].clone();
2126 async move {
2127 let _ = op.exists(&path).await;
2128 let rename_ok = async {
2129 op.write(&tmp, bytes.clone()).await?;
2130 op.rename(&tmp, &path).await?;
2131 Ok::<(), opendal::Error>(())
2132 }
2133 .await
2134 .is_ok();
2135 if !rename_ok {
2136 let _ = op.delete(&tmp).await;
2137 op.write(&path, bytes).await.unwrap();
2138 }
2139 }
2140 })
2141 .await;
2142
2143 let d = data.clone();
2145 let p_direct_new = timed(&op, n, conc, move |i, op| {
2146 let path = key("new", i);
2147 let bytes = d[i].clone();
2148 async move {
2149 let _ = op.exists(&path).await;
2150 op.write(&path, bytes).await.unwrap();
2151 }
2152 })
2153 .await;
2154
2155 let mut sweep = Vec::new();
2157 for &c in &[8usize, 16, 32, 64] {
2158 let d = data.clone();
2159 let ms = timed(&op, n, c, move |i, op| {
2160 let path = format!("namespaces/locomo-bench-prof-sweep{c}/vectors/prof-{i}.json");
2161 let bytes = d[i].clone();
2162 async move {
2163 op.write(&path, bytes).await.unwrap();
2164 }
2165 })
2166 .await;
2167 sweep.push((c, ms));
2168 }
2169
2170 let rate = |ms: f64| n as f64 / (ms / 1000.0);
2171 eprintln!("\n=== DAK-6289 storage_upsert sub-stage profile (S3/MinIO backend) ===");
2172 eprintln!("endpoint={endpoint} bucket={bucket} N={n} dim={DIM} payload={payload_bytes}B concurrency={conc}");
2173 eprintln!(
2174 "V0 upsert() REAL (direct PUT): {v0_ms:8.2} ms {:8.1} mem/s",
2175 rate(v0_ms)
2176 );
2177 eprintln!(
2178 "P_full_old exists+PUT+rename : {p_full_old:8.2} ms {:8.1} mem/s <- BEFORE",
2179 rate(p_full_old)
2180 );
2181 eprintln!(
2182 "P_direct_new exists+PUT : {p_direct_new:8.2} ms {:8.1} mem/s <- AFTER",
2183 rate(p_direct_new)
2184 );
2185 eprintln!(
2186 "P_exists (HEAD only) : {p_exists:8.2} ms {:8.1} mem/s",
2187 rate(p_exists)
2188 );
2189 eprintln!(
2190 "P_put (direct PUT only) : {p_put:8.2} ms {:8.1} mem/s",
2191 rate(p_put)
2192 );
2193 eprintln!(
2194 "P_waste (PUT tmp + DELETE) : {p_waste:8.2} ms {:8.1} mem/s (the ops the fix drops)",
2195 rate(p_waste)
2196 );
2197 eprintln!("--- attribution (S3 rename is Unsupported → old path = PUT(tmp)+DELETE(tmp)+PUT(final)) ---");
2198 eprintln!(
2199 "wasted-op share of old body : {:5.1}% ({p_waste:.1}ms of {p_full_old:.1}ms)",
2200 100.0 * p_waste / p_full_old.max(0.001)
2201 );
2202 eprintln!(
2203 "=> direct-PUT speedup (old/new): {:.2}x (end-to-end V0 vs P_full_old: {:.2}x)",
2204 p_full_old / p_direct_new.max(0.001),
2205 p_full_old / v0_ms.max(0.001)
2206 );
2207 eprintln!("--- concurrency sweep (direct PUT) ---");
2208 for (c, ms) in &sweep {
2209 eprintln!("conc={c:4} : {ms:8.2} ms {:8.1} mem/s", rate(*ms));
2210 }
2211
2212 let mut prefixes: Vec<String> = ["v0", "exists", "put", "waste", "old", "new"]
2216 .iter()
2217 .map(|p| format!("namespaces/locomo-bench-prof-{p}/"))
2218 .collect();
2219 for &c in &[8usize, 16, 32, 64] {
2220 prefixes.push(format!("namespaces/locomo-bench-prof-sweep{c}/"));
2221 }
2222 for prefix in prefixes {
2223 let _ = op.delete_with(&prefix).recursive(true).await;
2224 }
2225 }
2226}