Skip to main content

storage/
object.rs

1//! Object Storage Backend using OpenDAL
2//!
3//! Provides storage abstraction for multiple cloud storage backends:
4//! - S3-compatible (AWS S3, MinIO, DigitalOcean Spaces, etc.)
5//! - Azure Blob Storage
6//! - Google Cloud Storage (GCS)
7//! - Local filesystem
8//! - In-memory (for testing)
9
10use 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/// Serializable namespace metadata
30#[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/// Serializable vector data for storage
39#[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/// Object storage backend configuration
69#[derive(Debug, Clone, Default)]
70pub enum ObjectStorageConfig {
71    /// In-memory storage (for testing)
72    #[default]
73    Memory,
74    /// Local filesystem storage
75    Filesystem { root: String },
76    /// S3-compatible storage (S3, MinIO, etc.)
77    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 Blob Storage
85    Azure {
86        container: String,
87        account_name: String,
88        account_key: Option<String>,
89        sas_token: Option<String>,
90        endpoint: Option<String>,
91    },
92    /// Google Cloud Storage
93    Gcs {
94        bucket: String,
95        credential_path: Option<String>,
96        endpoint: Option<String>,
97    },
98}
99
100/// Object storage backend using OpenDAL
101#[derive(Clone)]
102pub struct ObjectStorage {
103    operator: Operator,
104    /// DAK-6287: local filesystem root, retained ONLY when the backend is a real
105    /// local filesystem. Lets `upsert` take a raw-`std::fs` fast write path that
106    /// bypasses OpenDAL's per-op overhead (~9× faster on the same disk; profiled).
107    /// `None` for memory/S3/Azure/GCS — those keep the OpenDAL path.
108    fs_root: Option<std::path::PathBuf>,
109    /// DAK-6289: whether write-path operations must use the tmp-file + rename atomic
110    /// publish dance. TRUE only for the local `Filesystem` backend, where a direct
111    /// overwrite is non-atomic and a concurrent reader can observe a truncated file
112    /// during the write window (O_TRUNC race, DAK-4545). FALSE for `Memory` and every
113    /// object store (`S3`/`Azure`/`Gcs`): their `PUT` is atomic and read-after-write
114    /// consistent — a reader sees either the prior object or the fully-written new one,
115    /// never a torn read. Object stores have no POSIX rename: OpenDAL's S3 service returns
116    /// `Unsupported` for `rename` (verified against live MinIO, DAK-6289), so the old
117    /// tmp+rename path ALWAYS fell through to its fallback (delete(tmp) + direct write) =
118    /// a wasted PUT(tmp) + DELETE(tmp) per object for zero durability benefit. We write
119    /// direct to the final key instead.
120    needs_tmp_rename: bool,
121    /// DAK-7428: sharded per-namespace async write locks. `upsert`/`delete` do a
122    /// metadata read-modify-write (`vector_count`/`dimension`); without serialization
123    /// two concurrent writers to the same namespace lost-update the count. A fixed
124    /// shard array (bounded memory — no per-namespace map growth — and no lock
125    /// poisoning) serializes same-namespace writers while letting different namespaces
126    /// proceed in parallel. `ObjectStorage` is the fs/S3 flat backend and the tiered
127    /// warm/cold tiers, not the default in-memory hot path, so this serialization is a
128    /// correctness win at negligible throughput cost. `Arc`-shared so cloned
129    /// `ObjectStorage` handles serialize against the *same* shards (the struct
130    /// derives `Clone`).
131    namespace_locks: std::sync::Arc<Vec<tokio::sync::Mutex<()>>>,
132    /// DAK-7428: count of vector files dropped on read because they were empty or
133    /// failed to deserialize. A read that silently skips corrupt files returns
134    /// fewer vectors than were stored with no signal to the caller — silent data
135    /// loss. This counter (shared across cloned handles) makes corruption
136    /// observable via [`ObjectStorage::corruption_count`]; the skip is logged at
137    /// error level rather than warn so it trips alerting.
138    corruption_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
139}
140
141/// Number of write-lock shards. Two namespaces may occasionally share a shard
142/// (harmless false serialization); collisions are rare relative to distinct
143/// namespaces and the array stays tiny and bounded (DAK-7428).
144const NAMESPACE_LOCK_SHARDS: usize = 256;
145
146impl ObjectStorage {
147    /// Create a new object storage with the given configuration
148    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    /// DAK-7428: total vector files dropped on read due to emptiness/corruption
169    /// since process start (shared across cloned handles). Non-zero means reads
170    /// have silently returned fewer vectors than were stored — surface it via
171    /// metrics/health so the data loss is not invisible.
172    pub fn corruption_count(&self) -> u64 {
173        self.corruption_count
174            .load(std::sync::atomic::Ordering::Relaxed)
175    }
176
177    /// DAK-7428: pick the write-lock shard for `namespace`. Stable within the
178    /// process (same namespace → same shard), which is all the serialization needs.
179    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    /// DAK-6289: whether `config`'s backend needs the tmp-file + rename atomic publish.
188    /// TRUE only for the local `Filesystem` (a direct overwrite there is non-atomic and
189    /// a concurrent reader can observe a truncated file — the O_TRUNC race, DAK-4545).
190    /// FALSE for `Memory` and every object store (`S3`/`Azure`/`Gcs`), whose `PUT` is
191    /// atomic and read-after-write consistent, so we write direct to the final key and
192    /// avoid OpenDAL's COPY+DELETE rename on S3.
193    fn needs_tmp_rename_for(config: &ObjectStorageConfig) -> bool {
194        matches!(config, ObjectStorageConfig::Filesystem { .. })
195    }
196
197    /// DAK-6287: true when `s` is safe as a single filesystem path segment (non-empty,
198    /// no separators, no parent refs, no NUL). Gates the raw-fs fast write path so a
199    /// crafted namespace or vector id cannot escape the storage root via path traversal.
200    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    /// DAK-6287: raw-`std::fs` batch write for the local Filesystem backend. Each vector is
210    /// written to `<root>/namespaces/<ns>/vectors/<id>.json` via tmp+fsync+rename, matching
211    /// the OpenDAL on-disk layout byte-for-byte so the read/recall path is unchanged. The
212    /// directory is created once per batch; each file is fsync'd before its atomic rename, so
213    /// crash-consistency is >= the OpenDAL path. Bypasses OpenDAL's per-op overhead, which the
214    /// storage_upsert profile showed dominates (~9× faster on the same disk). Callers MUST
215    /// ensure `namespace` and every vector id pass `is_fs_safe_segment`.
216    ///
217    /// Returns the number of NEW inserts (ids not already present on disk before the batch),
218    /// so the caller can maintain `meta.vector_count` without a separate O(namespace) listing
219    /// (DAK-6299). Classification is a single cheap raw-`std::fs` stat per file (O(batch),
220    /// ~1% of upsert cost per the storage_upsert profile), done inside the per-file write
221    /// task so it adds no extra round-trip pass over the namespace.
222    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                    // Leading dot keeps the temp file out of the `*.json` id listing.
244                    let tmp_path = dir.join(format!(".{}.tmp.{}", id, seq));
245                    tokio::task::spawn_blocking(move || -> Result<bool> {
246                        use std::io::Write;
247                        // Cheap per-file stat classifies insert-vs-update (matches the prior
248                        // !exists semantics) without scanning the whole namespace.
249                        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                        // Flush contents before the atomic publish so a crash can never
255                        // expose a half-written vector file (>= OpenDAL durability).
256                        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        // DAK-7428: fsync the namespace vectors directory so the atomic renames
281        // above are durable across power loss. Each file's contents are synced
282        // before its rename, but the directory entry created by the rename can
283        // still be lost on a crash unless the directory itself is fsynced.
284        // Best-effort: a directory-fsync failure must not fail an otherwise
285        // successful batch write.
286        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    /// Create in-memory storage (for testing)
298    pub fn memory() -> Result<Self> {
299        Self::new(ObjectStorageConfig::Memory)
300    }
301
302    /// Create filesystem storage
303    pub fn filesystem(root: impl Into<String>) -> Result<Self> {
304        Self::new(ObjectStorageConfig::Filesystem { root: root.into() })
305    }
306
307    /// Create S3 storage
308    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    /// Create Azure Blob storage
319    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    /// Create Google Cloud Storage
330    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        // DAK-3430: default capped from 10 → 3 so a MinIO 429 storm fails fast
426        // (≤3×60s ≈ 3min) rather than blocking production for 10min. Override
427        // with DAKERA_S3_MAX_RETRIES=10 for high-resilience deployments.
428        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    /// Get the path for a vector
457    fn vector_path(namespace: &str, vector_id: &str) -> String {
458        format!("namespaces/{}/vectors/{}.json", namespace, vector_id)
459    }
460
461    /// Get the path for namespace metadata
462    fn namespace_meta_path(namespace: &str) -> String {
463        format!("namespaces/{}/meta.json", namespace)
464    }
465
466    /// Get the path prefix for all vectors in a namespace
467    fn namespace_vectors_prefix(namespace: &str) -> String {
468        format!("namespaces/{}/vectors/", namespace)
469    }
470
471    /// Read namespace metadata
472    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    /// Write namespace metadata atomically.
505    ///
506    /// Writes to a `.tmp` file first, then renames to the final path. On POSIX filesystems
507    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    /// Write data to `path` atomically.
514    ///
515    /// On the local `Filesystem` backend (`needs_tmp_rename == true`) this writes to a
516    /// `.tmp` file first, then renames to the final path. On POSIX filesystems rename(2)
517    /// is atomic within the same device, preventing concurrent readers from seeing a
518    /// truncated/empty file during the write window (O_TRUNC race, DAK-4545).
519    ///
520    /// On object stores and memory (`needs_tmp_rename == false`) the backend's `PUT` is
521    /// already atomic and read-after-write consistent, so we write direct to the final
522    /// key. This is the DAK-6289 fix: OpenDAL's S3 service has no `rename` (returns
523    /// `Unsupported`), so the old tmp+rename always fell through to its fallback —
524    /// delete(tmp) + direct write — turning each metadata write into PUT(tmp) +
525    /// DELETE(tmp) + PUT(final) for zero durability gain. A direct `PUT` still leaves
526    /// readers on the previous version until it completes — no torn-read window exists.
527    async fn write_atomic(&self, path: &str, data: Vec<u8>) -> Result<()> {
528        if !self.needs_tmp_rename {
529            // Atomic-PUT backend (S3/Azure/GCS/Memory): write straight to the final key.
530            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            // Backend doesn't support rename: fall back to direct write.
553            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    /// Get current timestamp
564    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    /// Write raw bytes to `path`, using the same atomic-PUT semantics as metadata
572    /// writes. Exposed for backup/restore remote DR (`crate::backup`).
573    pub async fn put_object(&self, path: &str, data: Vec<u8>) -> Result<()> {
574        self.write_atomic(path, data).await
575    }
576
577    /// Read raw bytes at `path`. Returns `Ok(None)` when the object does not exist,
578    /// mirroring the `NotFound` handling in `read_namespace_meta`. Exposed for
579    /// backup/restore remote DR (`crate::backup`).
580    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        // DAK-7428: serialize writers to this namespace across the metadata
597        // read-modify-write below so two concurrent upserts can't lost-update
598        // `vector_count`/`dimension`. Different namespaces are unaffected.
599        let _write_guard = self.namespace_lock(namespace).lock().await;
600
601        // Get or create namespace metadata
602        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        // Validate dimensions
613        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        // DAK-5553: parallelize per-vector write to eliminate sequential I/O bottleneck.
628        // Buffer up to s3_concurrent_ops() (default 16) concurrent writes at a time.
629        //
630        // DAK-6299: classify insert-vs-update (to maintain meta.vector_count) with O(batch)
631        // per-vector existence checks, NOT a per-batch full-namespace list(). PR#612 replaced
632        // N per-vector exists() stats with ONE operator.list(vectors_prefix), but that list
633        // returns the ENTIRE namespace = O(namespace_size) per batch => O(N^2/batch) over a
634        // full ingest, and it ran for ALL backends (incl S3, where it is a paginated LIST of
635        // every key). Full-scale measurement (DAK-6297, fs, 2675 mem, one namespace) showed
636        // the list inverted the fast path: 1.43x at 220 mem -> 0.55x at 2675 mem. The
637        // profile that motivated PR#612 (DAK-6287) showed the per-vector stat was only ~1%
638        // of upsert cost, so removing it bought ~nothing while the list it introduced is the
639        // regression. Here: on the fs fast path a cheap raw std::fs stat per file (O(batch))
640        // classifies new-vs-existing; on the OpenDAL path a per-vector exists() (O(batch)
641        // HEAD/stat, the pre-PR#612 DAK-5553 semantics) does the same. No full-namespace
642        // scan. Write-path only, recall-neutral: vector file format, the read/recall path,
643        // and the tmp+rename atomic-publish durability guarantee are all unchanged.
644        let total = vectors.len();
645
646        // DAK-6287: on the local Filesystem backend, write via raw std::fs instead of
647        // OpenDAL. Profiled on an x64 runner (storage_upsert decomposition): the per-vector
648        // cost is dominated by OpenDAL's fs-writer overhead, NOT fsync/stat/rename — raw
649        // write+fsync reaches ~5800 mem/s vs OpenDAL ~650 mem/s on the same disk. The fast
650        // path keeps the EXACT on-disk layout (namespaces/<ns>/vectors/<id>.json), atomic
651        // tmp+rename publish, and adds an explicit per-file fsync, so the read/recall path is
652        // byte-identical and durability is >= the OpenDAL path. Guarded: only taken when the
653        // namespace and every id are filesystem-safe segments (no `/`, `\`, `..`, NUL) to
654        // prevent path traversal; otherwise it falls back to the OpenDAL path below.
655        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            // Fast path classifies inserts via a per-file stat inside the write loop.
665            self.write_vectors_fs(&root, namespace, vectors).await?
666        } else {
667            let op = self.operator.clone();
668            let ns = namespace.clone();
669            // DAK-6289: capture the write strategy once (Copy bool) so each per-vector task
670            // chooses direct-PUT vs tmp+rename without touching `self`.
671            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                        // O(1) per vector: a single exists() (HEAD on S3) classifies
683                        // insert-vs-update without scanning the namespace.
684                        let exists = op
685                            .exists(&path)
686                            .await
687                            .map_err(|e| DakeraError::Storage(e.to_string()))?;
688
689                        if needs_tmp_rename {
690                            // Local filesystem fallback (only reached here for traversal-unsafe
691                            // ids; the safe-id common case takes write_vectors_fs above):
692                            // tmp+rename avoids the O_TRUNC torn-read race (DAK-4545).
693                            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                            // DAK-6289 hot path: S3/Azure/GCS/Memory `PUT` is atomic and
722                            // read-after-write consistent. Write direct to the final key.
723                            // The old tmp+rename always degraded to PUT(tmp)+DELETE(tmp)+
724                            // PUT(final) on S3 (OpenDAL rename => Unsupported → fallback),
725                            // i.e. the payload was PUT twice. Direct write is one PUT with
726                            // identical atomicity. Recall-neutral: same key, same bytes.
727                            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                                // Previously this deserialize failure was dropped with
873                                // no log at all — fully invisible data loss (DAK-7428).
874                                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        // DAK-7428: serialize with concurrent upsert/delete on this namespace so the
905        // `vector_count` read-modify-write below can't lost-update.
906        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        // Update metadata
944        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        // Extract candidate namespace names from paths like "namespaces/myns/".
995        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        // Only include namespaces that actually have metadata (meta.json) — this
1011        // filters ghost directory entries left after deletion on backends where empty
1012        // directories persist (local fs, some S3-compatible stores). DAK-7428: probe
1013        // the candidates CONCURRENTLY; this was a sequential await per namespace, i.e.
1014        // N serial round trips on S3.
1015        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        // Check if namespace exists
1033        if !self.namespace_exists(namespace).await? {
1034            return Ok(false);
1035        }
1036
1037        // Recursively remove everything under the namespace prefix.
1038        // This deletes vectors, metadata, indexes, AND directory entries,
1039        // preventing ghost namespaces from appearing in list_namespaces().
1040        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        // Object storage doesn't track TTL internally - cleanup handled by application layer
1057        // StoredVector doesn't persist TTL fields, so nothing to clean up here
1058        Ok(0)
1059    }
1060
1061    async fn cleanup_all_expired(&self) -> Result<usize> {
1062        // Object storage doesn't track TTL internally - cleanup handled by application layer
1063        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                // Extract index type from filename
1169                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                        _ => {} // Ignore unknown index types
1178                    }
1179                }
1180            }
1181        }
1182
1183        Ok(indexes)
1184    }
1185}
1186
1187/// Create an OpenDAL operator from configuration without constructing a full ObjectStorage.
1188/// Useful for lightweight S3 access (e.g., BackupManager metadata persistence).
1189pub 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        // Ensure namespace
1203        storage.ensure_namespace(&namespace).await.unwrap();
1204        assert!(storage.namespace_exists(&namespace).await.unwrap());
1205
1206        // Insert vectors
1207        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        // Get single vector
1228        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        // Get all vectors
1234        let all = storage.get_all(&namespace).await.unwrap();
1235        assert_eq!(all.len(), 2);
1236
1237        // Count
1238        assert_eq!(storage.count(&namespace).await.unwrap(), 2);
1239
1240        // Delete
1241        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    // DAK-7428: a corrupt vector file must be counted (not silently dropped) so
1255    // the read-path data loss is observable.
1256    #[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        // Inject an undeserializable file directly at a vector path.
1276        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        // Direct get of the corrupt id: skipped and counted.
1286        let got = storage.get(&ns, &["bad".to_string()]).await.unwrap();
1287        assert!(got.is_empty());
1288        assert_eq!(storage.corruption_count(), 1);
1289
1290        // Namespace scan: corrupt file skipped, valid vector survives, counter grows.
1291        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        // Insert first vector
1304        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        // Try to insert vector with different dimension
1314        let v2 = vec![Vector {
1315            id: "v2".to_string(),
1316            values: vec![1.0, 2.0], // Wrong dimension
1317            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        // Insert
1332        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        // Upsert (update)
1342        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        // Verify update
1352        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        // Count should still be 1
1357        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        // Initially no indexes
1366        assert!(!storage
1367            .index_exists(&namespace, IndexType::Hnsw)
1368            .await
1369            .unwrap());
1370
1371        // Save an index
1372        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        // Check it exists
1379        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        // Load it back
1389        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        // Save another index type
1397        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        // List indexes
1404        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        // Delete index
1410        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        // Delete non-existent
1421        let deleted = storage
1422            .delete_index(&namespace, IndexType::Hnsw)
1423            .await
1424            .unwrap();
1425        assert!(!deleted);
1426
1427        // Load non-existent
1428        let loaded = storage
1429            .load_index(&namespace, IndexType::Hnsw)
1430            .await
1431            .unwrap();
1432        assert!(loaded.is_none());
1433    }
1434
1435    // ── DAK-5553: parallel upsert correctness ─────────────────────────────────
1436
1437    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        // Insert 50 unique vectors in one batch call
1454        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        // Upsert the same IDs again — vector_count must not double
1471        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        // Verify that a batch exceeding the default concurrency window (16) processes all items
1489        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    // DAK-6287: the per-vector exists() stat was replaced by a single directory list()
1501    // to classify insert-vs-update for vector_count. Prove a batch mixing updates of
1502    // existing ids with new inserts produces the exact count (only new ids increment).
1503    #[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        // Seed two vectors.
1510        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        // Batch updates v1 (existing) and inserts v2, v3 (new) => count must be 4, not 5.
1517        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        // Re-upserting only existing ids must not change the count.
1531        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    // DAK-6287: exercise the raw-fs fast write path (real Filesystem backend) end to end —
1539    // it must produce the same on-disk layout the OpenDAL read path expects, keep exact
1540    // count semantics, and safely fall back for traversal-unsafe ids without escaping root.
1541    #[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        // Insert via the fast path.
1549        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        // Read back through OpenDAL: proves the fast-path files are where the read path looks.
1563        let got = storage
1564            .get(&ns, &["a".to_string(), "b".to_string()])
1565            .await
1566            .unwrap();
1567        assert_eq!(got.len(), 2);
1568
1569        // Update b (existing) + insert d (new) in one batch => count 4, not 5.
1570        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        // Guard: a batch containing a traversal-unsafe id must not panic or escape the
1577        // storage root; it falls back to the OpenDAL path and the safe sibling persists.
1578        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        // Nothing was written above the storage root.
1588        assert!(!tmp.path().parent().unwrap().join("escape.json").exists());
1589    }
1590
1591    // DAK-6299: ingest pattern — many small batches into ONE growing namespace. This is the
1592    // O(N^2) scenario the removed per-batch full-namespace list() created. vector_count must
1593    // stay exact when classified incrementally per batch (fs fast path: per-file stat).
1594    // Each batch overlaps the previous one by one id (an update), so only the genuinely-new
1595    // id may increment the count.
1596    #[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            // Batch b writes ids {b, b+1}: id `b` is an update of the prior batch's new id,
1606            // id `b+1` is the only genuinely new vector => count grows by exactly 1 per batch.
1607            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            // After batch b we have ids v0..=v(b+1) => b+2 distinct vectors.
1614            assert_eq!(storage.count(&ns).await.unwrap(), b + 2);
1615        }
1616        assert_eq!(storage.count(&ns).await.unwrap(), BATCHES + 1);
1617    }
1618
1619    // DAK-6299: same incremental-count contract on the OpenDAL path (Memory backend), which
1620    // classifies inserts via a per-vector exists() rather than the fs stat.
1621    #[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    // ── DAK-6289: S3/object-store direct-write path ───────────────────────────
1641
1642    // The write strategy is selected once at construction. Only the local filesystem
1643    // needs the tmp+rename O_TRUNC guard; memory and every object store get the
1644    // direct-PUT fast path (no COPY+DELETE on S3).
1645    #[test]
1646    fn test_write_strategy_selection() {
1647        // Object stores + memory → direct write (atomic PUT, no COPY+DELETE).
1648        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        // Local filesystem → tmp+rename (O_TRUNC torn-read guard).
1677        assert!(ObjectStorage::needs_tmp_rename_for(
1678            &ObjectStorageConfig::Filesystem {
1679                root: "/tmp/x".to_string(),
1680            }
1681        ));
1682        // Constructed instances agree with the classifier.
1683        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    // End-to-end exercise of the direct-write branch (needs_tmp_rename == false) on a
1693    // non-filesystem backend: insert, overwrite-in-place, and a mixed insert/update
1694    // batch must all round-trip with exact count semantics — proving the removal of
1695    // tmp+rename keeps the read/recall path and vector_count classification intact.
1696    #[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        // Insert two new vectors via the direct path.
1704        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        // Overwrite `a` in place with new values (direct PUT to the final key, no rename).
1711        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        // Overwrite must NOT increment the count.
1723        assert_eq!(storage.count(&ns).await.unwrap(), 2);
1724
1725        // Mixed batch: update `b`, insert `c` → count 3, not 4.
1726        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    // DAK-6287: per-sub-stage profile of ObjectStorage::upsert on the fs backend.
1734    //
1735    // Post-tier (DAKERA_TIERED=1, embedding deferred) the per-stage ingest profile
1736    // (PR#609) names storage_upsert as the dominant stage (83.6% of the tiered
1737    // pipeline). This test decomposes that stage into its three per-vector fs ops
1738    // (exists stat / write-tmp / rename) plus candidate fixes, all at the production
1739    // concurrency window, so the dominant SUB-stage is provable rather than assumed.
1740    //
1741    // Ignored by default (writes thousands of files); run explicitly:
1742    //   cargo test -p dakera-storage --release profile_upsert_substages -- --ignored --nocapture
1743    #[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; // bge-large-en-v1.5
1749        let conc = s3_concurrent_ops();
1750
1751        // Realistic payload: 1024-d vector + LoCoMo-shaped metadata.
1752        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        // --- V0: authoritative current upsert() (real code path) ---
1772        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        // Helper: run a per-vector closure over a fresh fs operator at `conc` and time it.
1782        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            // keep tmp alive until after timing
1802            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        // P_exists: 200 stats only (the exists() round-trip upsert does per vector).
1814        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        // P_write_direct: 200 direct writes (no tmp, no rename, no exists).
1825        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        // P_write_tmp_rename: 200 × (write tmp + rename) — the atomic write, no exists.
1836        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        // P_full: exists + write tmp + rename (mirror of current upsert per-vector body).
1850        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        // --- Concurrency sweep on the real write pattern: does throughput scale with
1865        // parallelism (I/O-latency-bound) or plateau (CPU/serialized)? ---
1866        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        // --- fsync isolation: raw std::fs write vs write+sync_all at conc 16, to test
1884        // whether the per-file durability flush is the dominant write cost (the issue's
1885        // "disable per-write sync" lever). ---
1886        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    // DAK-6289: per-sub-stage profile of ObjectStorage::upsert against a REAL S3/MinIO
1966    // endpoint. Prod runs DAKERA_STORAGE=s3 (dakera-deploy docker-compose + k8s), whose
1967    // write path is network-bound and a different shape from the fs 83.6% profiled above.
1968    //
1969    // MEASURED (this harness, live MinIO): OpenDAL's S3 service has NO rename capability —
1970    // `rename` returns Unsupported (a fast CLIENT-side error, not a network COPY). So the OLD
1971    // upsert always hit its fallback: write(tmp) → rename fails → delete(tmp) + write(final).
1972    // Net OLD per vector = exists(HEAD) + PUT(tmp) + DELETE(tmp) + PUT(final) — payload PUT
1973    // TWICE. The fix writes direct = exists(HEAD) + PUT(final). This decomposes the path so
1974    // the wasted PUT(tmp)+DELETE(tmp) is PROVABLE, and reports BEFORE vs AFTER on one bucket:
1975    //   - V0            : the REAL upsert() (now the direct-PUT path) end to end
1976    //   - P_full_old    : exists + write(tmp) + rename-with-fallback (the real BEFORE path)
1977    //   - P_direct_new  : exists + write(final key)                                  ← AFTER
1978    //   - P_exists      : HEAD only
1979    //   - P_put         : PUT only
1980    //   - P_waste       : write(tmp) + delete(tmp) — the wasted ops the fix removes
1981    //   - concurrency sweep on the direct-PUT pattern
1982    //
1983    // Ignored by default (needs a live endpoint + writes objects). Stand up MinIO and run:
1984    //   docker run -d -p 9000:9000 -e MINIO_ROOT_USER=minioadmin \
1985    //     -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data
1986    //   # create bucket `dakera-prof` (mc mb), then:
1987    //   DAKERA_PROFILE_S3_ENDPOINT=http://127.0.0.1:9000 \
1988    //   DAKERA_PROFILE_S3_BUCKET=dakera-prof \
1989    //   AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \
1990    //   DAKERA_PROFILE_S3_REGION=us-east-1 \
1991    //   cargo test -p dakera-storage --release profile_upsert_substages_s3 -- --ignored --nocapture
1992    #[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; // bge-large-en-v1.5
2018        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        // Realistic payload: 1024-d vector + LoCoMo-shaped metadata.
2029        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        // --- V0: authoritative current upsert() (the shipped direct-PUT path) ---
2051        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; // clean slate
2055        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        // Helper: run a per-vector closure over the S3 operator at `conc`, time it.
2062        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        // Distinct key prefix per phase so phases don't interfere. A nested `fn` (not a
2077        // closure) so the `move` timing closures below can call it without capturing it.
2078        fn key(phase: &str, i: usize) -> String {
2079            format!("namespaces/locomo-bench-prof-{phase}/vectors/prof-{i}.json")
2080        }
2081
2082        // P_exists: n HEAD requests.
2083        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        // P_put: n direct PUTs (no exists, no tmp, no rename).
2092        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        // P_waste: the wasted ops the fix removes — write(tmp) + delete(tmp). On S3,
2103        // OpenDAL has NO rename capability (`rename` => Unsupported, a fast CLIENT-side
2104        // error, NO network COPY), so the OLD upsert always fell through to its fallback:
2105        // delete(tmp) + write(final). Net OLD per vector = PUT(tmp) + DELETE(tmp) + PUT(final)
2106        // — the payload is PUT twice. This phase isolates the PUT(tmp)+DELETE(tmp) the fix drops.
2107        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        // P_full_old: the EXACT old per-vector body — exists + write(tmp) + rename-with-fallback.
2119        // On S3 the rename errors (Unsupported), so this exercises the real fallback path
2120        // (delete(tmp) + write(final)) that prod actually ran. This is the true BEFORE cost.
2121        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        // P_direct_new: exists + write(final key)  (the AFTER per-vector body).
2144        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        // Concurrency sweep on the shipped direct-PUT pattern.
2156        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        // Best-effort cleanup of every profile object written this run (DAK-2407 bench
2213        // hygiene). The decomposition phases write raw vector objects with no meta.json,
2214        // so delete_namespace() would skip them — recursively delete the prefixes directly.
2215        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}