Skip to main content

storage/
tiered.rs

1//! Tiered Storage for Buffer
2//!
3//! Automatic data tiering based on access patterns:
4//! - Hot tier: In-memory for frequently accessed data
5//! - Warm tier: Local disk cache for recent data
6//! - Cold tier: Object storage for infrequently accessed data
7
8use async_trait::async_trait;
9use common::{DakeraError, NamespaceId, Result, Vector, VectorId};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, Instant};
15
16use crate::traits::VectorStorage;
17
18/// Tiered storage configuration
19#[derive(Debug, Clone)]
20pub struct TieredStorageConfig {
21    /// Hot tier capacity (number of vectors)
22    pub hot_tier_capacity: usize,
23    /// Hot tier byte budget (approximate, from per-vector size estimates).
24    /// When set, the hot tier is bounded by BOTH limits — whichever is
25    /// exceeded first triggers LRU demotion to warm.
26    pub hot_tier_max_bytes: Option<u64>,
27    /// Time before demoting from hot to warm
28    pub hot_to_warm_threshold: Duration,
29    /// Time before demoting from warm to cold
30    pub warm_to_cold_threshold: Duration,
31    /// Enable automatic tiering
32    pub auto_tier_enabled: bool,
33    /// Tier check interval
34    pub tier_check_interval: Duration,
35}
36
37impl Default for TieredStorageConfig {
38    fn default() -> Self {
39        Self {
40            hot_tier_capacity: 100_000,
41            hot_tier_max_bytes: None,
42            hot_to_warm_threshold: Duration::from_secs(3600), // 1 hour
43            warm_to_cold_threshold: Duration::from_secs(86400), // 24 hours
44            auto_tier_enabled: true,
45            tier_check_interval: Duration::from_secs(300), // 5 minutes
46        }
47    }
48}
49
50/// Approximate in-memory footprint of a JSON metadata value.
51fn approx_json_bytes(value: &serde_json::Value) -> usize {
52    match value {
53        serde_json::Value::Null => 4,
54        serde_json::Value::Bool(_) => 5,
55        serde_json::Value::Number(_) => 8,
56        serde_json::Value::String(s) => s.len() + 2,
57        serde_json::Value::Array(a) => a.iter().map(approx_json_bytes).sum::<usize>() + 2 + a.len(),
58        serde_json::Value::Object(o) => {
59            o.iter()
60                .map(|(k, v)| k.len() + 3 + approx_json_bytes(v))
61                .sum::<usize>()
62                + 2
63        }
64    }
65}
66
67/// Fixed per-vector overhead: struct fields, map entry, allocator slack.
68const VECTOR_FIXED_OVERHEAD: usize = 96;
69
70/// Approximate in-memory footprint of one vector in the hot tier.
71fn approx_vector_bytes(v: &Vector) -> usize {
72    v.values.len() * std::mem::size_of::<f32>()
73        + v.id.len()
74        + v.metadata.as_ref().map(approx_json_bytes).unwrap_or(0)
75        + VECTOR_FIXED_OVERHEAD
76}
77
78/// Saturating subtraction on an atomic counter (avoids wrap-around on
79/// accounting drift).
80fn sub_saturating(counter: &AtomicU64, value: u64) {
81    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
82        Some(cur.saturating_sub(value))
83    });
84}
85
86/// Storage tier for a piece of data
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum StorageTier {
89    /// In-memory, fastest access
90    Hot,
91    /// Local disk, fast access
92    Warm,
93    /// Object storage, slowest access
94    Cold,
95}
96
97impl StorageTier {
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            StorageTier::Hot => "hot",
101            StorageTier::Warm => "warm",
102            StorageTier::Cold => "cold",
103        }
104    }
105}
106
107/// Access tracking for tiering decisions
108#[derive(Debug, Clone)]
109struct AccessInfo {
110    /// Last access timestamp
111    last_access: Instant,
112    /// Total access count
113    access_count: u64,
114    /// Current tier
115    tier: StorageTier,
116    /// Approximate byte footprint (counted toward the hot budget while
117    /// tier == Hot)
118    approx_bytes: usize,
119}
120
121impl Default for AccessInfo {
122    fn default() -> Self {
123        Self {
124            last_access: Instant::now(),
125            access_count: 0,
126            tier: StorageTier::Hot,
127            approx_bytes: 0,
128        }
129    }
130}
131
132/// Statistics for tiered storage
133#[derive(Debug, Clone, Default)]
134pub struct TieredStorageStats {
135    /// Vectors in hot tier
136    pub hot_count: u64,
137    /// Approximate bytes held by the hot tier
138    pub hot_bytes: u64,
139    /// Vectors in warm tier
140    pub warm_count: u64,
141    /// Vectors in cold tier
142    pub cold_count: u64,
143    /// Hot tier hits
144    pub hot_hits: u64,
145    /// Warm tier hits
146    pub warm_hits: u64,
147    /// Cold tier hits
148    pub cold_hits: u64,
149    /// Promotions to hot tier
150    pub promotions_to_hot: u64,
151    /// Demotions to warm tier
152    pub demotions_to_warm: u64,
153    /// Demotions to cold tier
154    pub demotions_to_cold: u64,
155}
156
157/// Tiered storage manager
158pub struct TieredStorage<H, W, C> {
159    /// Configuration
160    config: TieredStorageConfig,
161    /// Hot tier storage (in-memory)
162    hot_storage: H,
163    /// Warm tier storage (disk cache)
164    warm_storage: W,
165    /// Cold tier storage (object storage)
166    cold_storage: C,
167    /// Access tracking per vector
168    access_info: RwLock<HashMap<(NamespaceId, VectorId), AccessInfo>>,
169    /// Statistics
170    stats: TieredStorageStatsInner,
171}
172
173struct TieredStorageStatsInner {
174    hot_count: AtomicU64,
175    hot_bytes: AtomicU64,
176    warm_count: AtomicU64,
177    cold_count: AtomicU64,
178    hot_hits: AtomicU64,
179    warm_hits: AtomicU64,
180    cold_hits: AtomicU64,
181    promotions_to_hot: AtomicU64,
182    demotions_to_warm: AtomicU64,
183    demotions_to_cold: AtomicU64,
184}
185
186impl Default for TieredStorageStatsInner {
187    fn default() -> Self {
188        Self {
189            hot_count: AtomicU64::new(0),
190            hot_bytes: AtomicU64::new(0),
191            warm_count: AtomicU64::new(0),
192            cold_count: AtomicU64::new(0),
193            hot_hits: AtomicU64::new(0),
194            warm_hits: AtomicU64::new(0),
195            cold_hits: AtomicU64::new(0),
196            promotions_to_hot: AtomicU64::new(0),
197            demotions_to_warm: AtomicU64::new(0),
198            demotions_to_cold: AtomicU64::new(0),
199        }
200    }
201}
202
203impl<H, W, C> TieredStorage<H, W, C>
204where
205    H: VectorStorage,
206    W: VectorStorage,
207    C: VectorStorage,
208{
209    /// Create a new tiered storage
210    pub fn new(
211        config: TieredStorageConfig,
212        hot_storage: H,
213        warm_storage: W,
214        cold_storage: C,
215    ) -> Self {
216        Self {
217            config,
218            hot_storage,
219            warm_storage,
220            cold_storage,
221            access_info: RwLock::new(HashMap::new()),
222            stats: TieredStorageStatsInner::default(),
223        }
224    }
225
226    /// Get the tiered storage configuration
227    pub fn config(&self) -> &TieredStorageConfig {
228        &self.config
229    }
230
231    /// Record access to a vector
232    fn record_access(&self, namespace: &NamespaceId, id: &VectorId, tier: StorageTier) {
233        let key = (namespace.clone(), id.clone());
234        let mut access_map = self.access_info.write();
235
236        let info = access_map.entry(key).or_default();
237        info.last_access = Instant::now();
238        info.access_count += 1;
239        // Keep the hot byte budget consistent if the observed tier disagrees
240        // with the tracked one (e.g. served from warm while still marked hot).
241        if info.tier == StorageTier::Hot && tier != StorageTier::Hot {
242            sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
243        } else if info.tier != StorageTier::Hot && tier == StorageTier::Hot {
244            self.stats
245                .hot_bytes
246                .fetch_add(info.approx_bytes as u64, Ordering::Relaxed);
247        }
248        info.tier = tier;
249
250        // Update hit counters
251        match tier {
252            StorageTier::Hot => self.stats.hot_hits.fetch_add(1, Ordering::Relaxed),
253            StorageTier::Warm => self.stats.warm_hits.fetch_add(1, Ordering::Relaxed),
254            StorageTier::Cold => self.stats.cold_hits.fetch_add(1, Ordering::Relaxed),
255        };
256    }
257
258    /// Get the current tier for a vector
259    fn get_tier(&self, namespace: &NamespaceId, id: &VectorId) -> Option<StorageTier> {
260        let access_map = self.access_info.read();
261        access_map
262            .get(&(namespace.clone(), id.clone()))
263            .map(|info| info.tier)
264    }
265
266    /// Promote a vector to a higher tier
267    pub async fn promote(&self, namespace: &NamespaceId, id: &VectorId) -> Result<bool> {
268        let current_tier = self.get_tier(namespace, id);
269
270        match current_tier {
271            Some(StorageTier::Warm) => {
272                // Promote warm -> hot
273                let vectors = self
274                    .warm_storage
275                    .get(namespace, std::slice::from_ref(id))
276                    .await?;
277                if !vectors.is_empty() {
278                    let est = vectors.first().map(approx_vector_bytes).unwrap_or(0);
279                    self.hot_storage.upsert(namespace, vectors).await?;
280                    self.warm_storage
281                        .delete(namespace, std::slice::from_ref(id))
282                        .await?;
283
284                    self.set_approx_bytes(namespace, id, est);
285                    self.update_tier(namespace, id, StorageTier::Hot);
286                    self.stats.promotions_to_hot.fetch_add(1, Ordering::Relaxed);
287                    self.stats.hot_count.fetch_add(1, Ordering::Relaxed);
288                    self.stats.warm_count.fetch_sub(1, Ordering::Relaxed);
289
290                    return Ok(true);
291                }
292            }
293            Some(StorageTier::Cold) => {
294                // Promote cold -> warm (or directly to hot if frequently accessed)
295                let vectors = self
296                    .cold_storage
297                    .get(namespace, std::slice::from_ref(id))
298                    .await?;
299                if !vectors.is_empty() {
300                    // Check if should go directly to hot based on access frequency
301                    let should_be_hot = {
302                        let access_map = self.access_info.read();
303                        access_map
304                            .get(&(namespace.clone(), id.clone()))
305                            .map(|info| info.access_count > 10)
306                            .unwrap_or(false)
307                    };
308
309                    if should_be_hot {
310                        let est = vectors.first().map(approx_vector_bytes).unwrap_or(0);
311                        self.hot_storage.upsert(namespace, vectors).await?;
312                        self.set_approx_bytes(namespace, id, est);
313                        self.update_tier(namespace, id, StorageTier::Hot);
314                        self.stats.promotions_to_hot.fetch_add(1, Ordering::Relaxed);
315                        self.stats.hot_count.fetch_add(1, Ordering::Relaxed);
316                    } else {
317                        self.warm_storage.upsert(namespace, vectors).await?;
318                        self.update_tier(namespace, id, StorageTier::Warm);
319                        self.stats.warm_count.fetch_add(1, Ordering::Relaxed);
320                    }
321                    // Cold tier is the durable source of truth — never delete on promotion.
322                    // The tier map tracks which tier is "active" for reads; cold remains
323                    // as the persistent backup in case warm/hot are lost on restart.
324
325                    return Ok(true);
326                }
327            }
328            _ => {}
329        }
330
331        Ok(false)
332    }
333
334    /// Demote a vector to a lower tier
335    pub async fn demote(&self, namespace: &NamespaceId, id: &VectorId) -> Result<bool> {
336        let current_tier = self.get_tier(namespace, id);
337
338        match current_tier {
339            Some(StorageTier::Hot) => {
340                // Demote hot -> warm
341                let vectors = self
342                    .hot_storage
343                    .get(namespace, std::slice::from_ref(id))
344                    .await?;
345                if !vectors.is_empty() {
346                    self.warm_storage.upsert(namespace, vectors).await?;
347                    self.hot_storage
348                        .delete(namespace, std::slice::from_ref(id))
349                        .await?;
350
351                    self.update_tier(namespace, id, StorageTier::Warm);
352                    self.stats.demotions_to_warm.fetch_add(1, Ordering::Relaxed);
353                    self.stats.hot_count.fetch_sub(1, Ordering::Relaxed);
354                    self.stats.warm_count.fetch_add(1, Ordering::Relaxed);
355
356                    return Ok(true);
357                }
358            }
359            Some(StorageTier::Warm) => {
360                // Demote warm -> cold
361                let vectors = self
362                    .warm_storage
363                    .get(namespace, std::slice::from_ref(id))
364                    .await?;
365                if !vectors.is_empty() {
366                    self.cold_storage.upsert(namespace, vectors).await?;
367                    self.warm_storage
368                        .delete(namespace, std::slice::from_ref(id))
369                        .await?;
370
371                    self.update_tier(namespace, id, StorageTier::Cold);
372                    self.stats.demotions_to_cold.fetch_add(1, Ordering::Relaxed);
373                    self.stats.warm_count.fetch_sub(1, Ordering::Relaxed);
374                    self.stats.cold_count.fetch_add(1, Ordering::Relaxed);
375
376                    return Ok(true);
377                }
378            }
379            _ => {}
380        }
381
382        Ok(false)
383    }
384
385    /// Update tier tracking, keeping the hot-tier byte budget in sync with
386    /// Hot ↔ non-Hot transitions.
387    fn update_tier(&self, namespace: &NamespaceId, id: &VectorId, tier: StorageTier) {
388        let mut access_map = self.access_info.write();
389        let key = (namespace.clone(), id.clone());
390        let info = access_map.entry(key).or_default();
391        let was_hot = info.tier == StorageTier::Hot;
392        let now_hot = tier == StorageTier::Hot;
393        if was_hot && !now_hot {
394            sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
395        } else if !was_hot && now_hot {
396            self.stats
397                .hot_bytes
398                .fetch_add(info.approx_bytes as u64, Ordering::Relaxed);
399        }
400        info.tier = tier;
401    }
402
403    /// Refresh the byte estimate for a tracked vector (e.g. on promotion,
404    /// when the vector payload is available again).
405    fn set_approx_bytes(&self, namespace: &NamespaceId, id: &VectorId, bytes: usize) {
406        let mut access_map = self.access_info.write();
407        if let Some(info) = access_map.get_mut(&(namespace.clone(), id.clone())) {
408            if info.tier == StorageTier::Hot {
409                sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
410                self.stats
411                    .hot_bytes
412                    .fetch_add(bytes as u64, Ordering::Relaxed);
413            }
414            info.approx_bytes = bytes;
415        }
416    }
417
418    fn is_permanent_storage_error(error: &DakeraError) -> bool {
419        match error {
420            DakeraError::NamespaceNotFound(_) => true,
421            DakeraError::Storage(msg) => {
422                msg.contains("PermissionDenied") || msg.contains("permission denied")
423            }
424            _ => false,
425        }
426    }
427
428    /// Run automatic tiering based on access patterns
429    pub async fn run_auto_tiering(&self) -> Result<TieringResult> {
430        if !self.config.auto_tier_enabled {
431            // Time-based tiering is opt-out, but the hot capacity bound is a
432            // hard limit — enforce it regardless.
433            let demoted_to_warm = self.enforce_hot_capacity().await;
434            return Ok(TieringResult {
435                demoted_to_warm,
436                ..Default::default()
437            });
438        }
439
440        let now = Instant::now();
441        let mut to_demote_to_warm = Vec::new();
442        let mut to_demote_to_cold = Vec::new();
443
444        // Collect vectors to demote
445        {
446            let access_map = self.access_info.read();
447            for ((namespace, id), info) in access_map.iter() {
448                let elapsed = now.duration_since(info.last_access);
449
450                match info.tier {
451                    StorageTier::Hot if elapsed > self.config.hot_to_warm_threshold => {
452                        to_demote_to_warm.push((namespace.clone(), id.clone()));
453                    }
454                    StorageTier::Warm if elapsed > self.config.warm_to_cold_threshold => {
455                        to_demote_to_cold.push((namespace.clone(), id.clone()));
456                    }
457                    _ => {}
458                }
459            }
460        }
461
462        // Execute demotions — errors on individual vectors are handled gracefully
463        // to prevent a single deleted namespace from blocking all tiering.
464        let mut demoted_to_warm = 0;
465        let mut demoted_to_cold = 0;
466        let mut stale_entries = Vec::new();
467
468        for (namespace, id) in to_demote_to_warm {
469            match self.demote(&namespace, &id).await {
470                Ok(true) => demoted_to_warm += 1,
471                Ok(false) => {}
472                Err(e) => {
473                    if Self::is_permanent_storage_error(&e) {
474                        stale_entries.push((namespace, id));
475                    } else {
476                        tracing::debug!(error = %e, "Transient demotion error, will retry next cycle");
477                    }
478                }
479            }
480        }
481
482        for (namespace, id) in to_demote_to_cold {
483            match self.demote(&namespace, &id).await {
484                Ok(true) => demoted_to_cold += 1,
485                Ok(false) => {}
486                Err(e) => {
487                    if Self::is_permanent_storage_error(&e) {
488                        stale_entries.push((namespace, id));
489                    } else {
490                        tracing::debug!(error = %e, "Transient demotion error, will retry next cycle");
491                    }
492                }
493            }
494        }
495
496        if !stale_entries.is_empty() {
497            let removed = stale_entries.len();
498            let mut access_map = self.access_info.write();
499            for (ns, id) in &stale_entries {
500                if let Some(info) = access_map.remove(&(ns.clone(), id.clone())) {
501                    match info.tier {
502                        StorageTier::Hot => {
503                            sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
504                            sub_saturating(&self.stats.hot_count, 1);
505                        }
506                        StorageTier::Warm => sub_saturating(&self.stats.warm_count, 1),
507                        StorageTier::Cold => sub_saturating(&self.stats.cold_count, 1),
508                    }
509                }
510            }
511            tracing::warn!(
512                removed = removed,
513                "Purged stale entries from tiering queue (deleted namespace or permanent error)"
514            );
515        }
516
517        // Capacity enforcement after time-based demotion — the hot tier must
518        // respect both the vector-count cap and the byte budget.
519        let demoted_by_capacity = self.enforce_hot_capacity().await;
520
521        Ok(TieringResult {
522            demoted_to_warm: demoted_to_warm + demoted_by_capacity,
523            demoted_to_cold,
524            promoted_to_hot: 0,
525            promoted_to_warm: 0,
526        })
527    }
528
529    /// Demote least-recently-accessed hot vectors to warm until the hot tier
530    /// is within both `hot_tier_capacity` (count) and `hot_tier_max_bytes`
531    /// (approximate bytes). Returns the number of vectors demoted.
532    ///
533    /// This is the enforcement half of the L1 budget: without it,
534    /// `hot_tier_capacity` is advisory only and the hot tier grows unbounded
535    /// until the memory-pressure guard trips (DAK-7227 / DAK-7337).
536    pub async fn enforce_hot_capacity(&self) -> u64 {
537        let cap_count = self.config.hot_tier_capacity;
538        let cap_bytes = self.config.hot_tier_max_bytes;
539
540        let mut hot_entries: Vec<((NamespaceId, VectorId), Instant, u64)> = {
541            let access_map = self.access_info.read();
542            access_map
543                .iter()
544                .filter(|(_, info)| info.tier == StorageTier::Hot)
545                .map(|(key, info)| (key.clone(), info.last_access, info.approx_bytes as u64))
546                .collect()
547        };
548
549        let mut cur_count = hot_entries.len();
550        let mut cur_bytes: u64 = hot_entries.iter().map(|(_, _, b)| *b).sum();
551        let over = |count: usize, bytes: u64| {
552            count > cap_count || cap_bytes.map(|cap| bytes > cap).unwrap_or(false)
553        };
554
555        if !over(cur_count, cur_bytes) {
556            return 0;
557        }
558
559        // Oldest access first — LRU demotion order.
560        hot_entries.sort_by_key(|(_, last_access, _)| *last_access);
561
562        let mut demoted = 0u64;
563        for ((ns, id), _, bytes) in hot_entries {
564            if !over(cur_count, cur_bytes) {
565                break;
566            }
567            match self.demote(&ns, &id).await {
568                Ok(true) => {
569                    demoted += 1;
570                    cur_count -= 1;
571                    cur_bytes = cur_bytes.saturating_sub(bytes);
572                }
573                Ok(false) => {
574                    // Vector no longer present in hot storage — tracking is
575                    // stale for this entry; skip it in the local accounting so
576                    // the loop still terminates.
577                    cur_count = cur_count.saturating_sub(1);
578                    cur_bytes = cur_bytes.saturating_sub(bytes);
579                }
580                Err(e) => {
581                    tracing::debug!(
582                        error = %e,
583                        namespace = %ns,
584                        "Capacity demotion error, will retry next cycle"
585                    );
586                    cur_count = cur_count.saturating_sub(1);
587                    cur_bytes = cur_bytes.saturating_sub(bytes);
588                }
589            }
590        }
591
592        if demoted > 0 {
593            tracing::info!(
594                demoted,
595                cap_count,
596                cap_bytes = cap_bytes.unwrap_or(0),
597                "Hot tier capacity enforcement demoted LRU vectors to warm"
598            );
599        }
600        demoted
601    }
602
603    /// Get storage statistics
604    pub fn stats(&self) -> TieredStorageStats {
605        TieredStorageStats {
606            hot_count: self.stats.hot_count.load(Ordering::Relaxed),
607            hot_bytes: self.stats.hot_bytes.load(Ordering::Relaxed),
608            warm_count: self.stats.warm_count.load(Ordering::Relaxed),
609            cold_count: self.stats.cold_count.load(Ordering::Relaxed),
610            hot_hits: self.stats.hot_hits.load(Ordering::Relaxed),
611            warm_hits: self.stats.warm_hits.load(Ordering::Relaxed),
612            cold_hits: self.stats.cold_hits.load(Ordering::Relaxed),
613            promotions_to_hot: self.stats.promotions_to_hot.load(Ordering::Relaxed),
614            demotions_to_warm: self.stats.demotions_to_warm.load(Ordering::Relaxed),
615            demotions_to_cold: self.stats.demotions_to_cold.load(Ordering::Relaxed),
616        }
617    }
618
619    /// Get tier distribution by namespace
620    pub fn tier_distribution(&self, namespace: &NamespaceId) -> TierDistribution {
621        let access_map = self.access_info.read();
622        let mut hot = 0u64;
623        let mut warm = 0u64;
624        let mut cold = 0u64;
625
626        for ((ns, _), info) in access_map.iter() {
627            if ns == namespace {
628                match info.tier {
629                    StorageTier::Hot => hot += 1,
630                    StorageTier::Warm => warm += 1,
631                    StorageTier::Cold => cold += 1,
632                }
633            }
634        }
635
636        TierDistribution { hot, warm, cold }
637    }
638}
639
640/// Result of automatic tiering
641#[derive(Debug, Clone, Default)]
642pub struct TieringResult {
643    /// Vectors demoted to warm tier
644    pub demoted_to_warm: u64,
645    /// Vectors demoted to cold tier
646    pub demoted_to_cold: u64,
647    /// Vectors promoted to hot tier
648    pub promoted_to_hot: u64,
649    /// Vectors promoted to warm tier
650    pub promoted_to_warm: u64,
651}
652
653/// Tier distribution for a namespace
654#[derive(Debug, Clone)]
655pub struct TierDistribution {
656    pub hot: u64,
657    pub warm: u64,
658    pub cold: u64,
659}
660
661#[async_trait]
662impl<H, W, C> VectorStorage for TieredStorage<H, W, C>
663where
664    H: VectorStorage,
665    W: VectorStorage,
666    C: VectorStorage + Clone + Send + Sync + 'static,
667{
668    async fn upsert(&self, namespace: &NamespaceId, vectors: Vec<Vector>) -> Result<usize> {
669        // Clone vectors for the background cold-tier flush before consuming them.
670        let cold_vectors = vectors.clone();
671
672        // Write hot tier first — lowest latency path, immediately available for reads.
673        let count = self.hot_storage.upsert(namespace, vectors).await?;
674
675        // Track access info + hot byte budget in a single pass (before the cold
676        // flush consumes cold_vectors). Overwrites of vectors already in the hot
677        // tier replace their byte estimate instead of double-counting (both
678        // bytes and hot_count).
679        {
680            let mut access_map = self.access_info.write();
681            let mut newly_hot = 0u64;
682            for v in &cold_vectors {
683                let est = approx_vector_bytes(v);
684                let key = (namespace.clone(), v.id.clone());
685                match access_map.get_mut(&key) {
686                    Some(info) => {
687                        if info.tier == StorageTier::Hot {
688                            sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
689                        } else {
690                            newly_hot += 1;
691                        }
692                        info.tier = StorageTier::Hot;
693                        info.last_access = Instant::now();
694                        info.access_count += 1;
695                        info.approx_bytes = est;
696                    }
697                    None => {
698                        newly_hot += 1;
699                        access_map.insert(
700                            key,
701                            AccessInfo {
702                                last_access: Instant::now(),
703                                access_count: 1,
704                                tier: StorageTier::Hot,
705                                approx_bytes: est,
706                            },
707                        );
708                    }
709                }
710                self.stats
711                    .hot_bytes
712                    .fetch_add(est as u64, Ordering::Relaxed);
713                self.stats.hot_hits.fetch_add(1, Ordering::Relaxed);
714            }
715            self.stats.hot_count.fetch_add(newly_hot, Ordering::Relaxed);
716        }
717
718        // Flush to cold tier (S3) in the background — failure is logged but not fatal.
719        // The hot tier (RocksDB/in-memory) is the primary durable read path.
720        let cold = self.cold_storage.clone();
721        let cold_ns = namespace.clone();
722        tokio::spawn(async move {
723            if let Err(e) = cold.ensure_namespace(&cold_ns).await {
724                tracing::error!(
725                    error = %e,
726                    namespace = %cold_ns,
727                    "Cold tier namespace ensure failed (S3 flush aborted)"
728                );
729                return;
730            }
731            let retry_vectors = cold_vectors.clone();
732            match cold.upsert(&cold_ns, cold_vectors).await {
733                Ok(_) => {}
734                Err(DakeraError::DimensionMismatch { expected, actual }) => {
735                    tracing::warn!(
736                        namespace = %cold_ns,
737                        cold_dim = expected,
738                        hot_dim = actual,
739                        "Cold tier dimension mismatch — resetting stale cold namespace"
740                    );
741                    if let Err(e) = cold.delete_namespace(&cold_ns).await {
742                        tracing::error!(error = %e, namespace = %cold_ns,
743                            "Failed to delete stale cold namespace");
744                        return;
745                    }
746                    if let Err(e) = cold.ensure_namespace(&cold_ns).await {
747                        tracing::error!(error = %e, namespace = %cold_ns,
748                            "Failed to recreate cold namespace after dimension fix");
749                        return;
750                    }
751                    if let Err(e) = cold.upsert(&cold_ns, retry_vectors).await {
752                        tracing::error!(
753                            error = %e,
754                            namespace = %cold_ns,
755                            "Cold tier S3 flush failed after dimension fix — data is durable in hot tier"
756                        );
757                    }
758                }
759                Err(e) => {
760                    tracing::error!(
761                        error = %e,
762                        namespace = %cold_ns,
763                        "Cold tier S3 flush failed — data is durable in hot tier"
764                    );
765                }
766            }
767        });
768
769        Ok(count)
770    }
771
772    async fn get(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<Vec<Vector>> {
773        let mut results = Vec::with_capacity(ids.len());
774        let mut remaining_ids: Vec<VectorId> = ids.to_vec();
775
776        // Try hot tier first (NamespaceNotFound is normal after restart when hot cache is empty)
777        let hot_results = match self.hot_storage.get(namespace, &remaining_ids).await {
778            Ok(v) => v,
779            Err(DakeraError::NamespaceNotFound(_)) => vec![],
780            Err(e) => return Err(e),
781        };
782        for v in &hot_results {
783            self.record_access(namespace, &v.id, StorageTier::Hot);
784        }
785
786        // Remove found IDs
787        let found_ids: std::collections::HashSet<_> = hot_results.iter().map(|v| &v.id).collect();
788        remaining_ids.retain(|id| !found_ids.contains(id));
789        results.extend(hot_results);
790
791        if remaining_ids.is_empty() {
792            return Ok(results);
793        }
794
795        // Try warm tier (NamespaceNotFound is normal after restart when warm cache is empty)
796        let warm_results = match self.warm_storage.get(namespace, &remaining_ids).await {
797            Ok(v) => v,
798            Err(common::DakeraError::NamespaceNotFound(_)) => vec![],
799            Err(e) => return Err(e),
800        };
801        for v in &warm_results {
802            self.record_access(namespace, &v.id, StorageTier::Warm);
803        }
804
805        let found_ids: std::collections::HashSet<_> = warm_results.iter().map(|v| &v.id).collect();
806        remaining_ids.retain(|id| !found_ids.contains(id));
807        results.extend(warm_results);
808
809        if remaining_ids.is_empty() {
810            return Ok(results);
811        }
812
813        // Try cold tier
814        let cold_results = match self.cold_storage.get(namespace, &remaining_ids).await {
815            Ok(v) => v,
816            Err(DakeraError::NamespaceNotFound(_)) => vec![],
817            Err(e) => return Err(e),
818        };
819        for v in &cold_results {
820            self.record_access(namespace, &v.id, StorageTier::Cold);
821        }
822        results.extend(cold_results);
823
824        Ok(results)
825    }
826
827    async fn get_all(&self, namespace: &NamespaceId) -> Result<Vec<Vector>> {
828        let mut seen = std::collections::HashSet::new();
829        let mut results = Vec::new();
830
831        // Helper: treat NamespaceNotFound as empty — normal for hot/warm after restart.
832        let tier_get_all = |res: common::Result<Vec<Vector>>| -> common::Result<Vec<Vector>> {
833            match res {
834                Ok(v) => Ok(v),
835                Err(common::DakeraError::NamespaceNotFound(_)) => Ok(vec![]),
836                Err(e) => Err(e),
837            }
838        };
839
840        // Gather from all tiers, preferring hot over warm over cold.
841        // Deduplicate by vector ID since write-through means a vector
842        // can exist in both hot and cold simultaneously.
843        for v in tier_get_all(self.hot_storage.get_all(namespace).await)? {
844            if seen.insert(v.id.clone()) {
845                results.push(v);
846            }
847        }
848        for v in tier_get_all(self.warm_storage.get_all(namespace).await)? {
849            if seen.insert(v.id.clone()) {
850                results.push(v);
851            }
852        }
853        for v in tier_get_all(self.cold_storage.get_all(namespace).await)? {
854            if seen.insert(v.id.clone()) {
855                results.push(v);
856            }
857        }
858
859        Ok(results)
860    }
861
862    async fn delete(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<usize> {
863        let mut deleted = 0;
864
865        // Delete from all tiers. Tolerate NamespaceNotFound from individual tiers
866        // since data may only reside in a subset of tiers (e.g. cold but not hot).
867        match self.hot_storage.delete(namespace, ids).await {
868            Ok(n) => deleted += n,
869            Err(DakeraError::NamespaceNotFound(_)) => {}
870            Err(e) => return Err(e),
871        }
872        match self.warm_storage.delete(namespace, ids).await {
873            Ok(n) => deleted += n,
874            Err(DakeraError::NamespaceNotFound(_)) => {}
875            Err(e) => return Err(e),
876        }
877        match self.cold_storage.delete(namespace, ids).await {
878            Ok(n) => deleted += n,
879            Err(DakeraError::NamespaceNotFound(_)) => {}
880            Err(e) => return Err(e),
881        }
882
883        // Remove from tracking, releasing hot budget and tier counts.
884        {
885            let mut access_map = self.access_info.write();
886            for id in ids {
887                if let Some(info) = access_map.remove(&(namespace.clone(), id.clone())) {
888                    match info.tier {
889                        StorageTier::Hot => {
890                            sub_saturating(&self.stats.hot_bytes, info.approx_bytes as u64);
891                            sub_saturating(&self.stats.hot_count, 1);
892                        }
893                        StorageTier::Warm => sub_saturating(&self.stats.warm_count, 1),
894                        StorageTier::Cold => sub_saturating(&self.stats.cold_count, 1),
895                    }
896                }
897            }
898        }
899
900        Ok(deleted)
901    }
902
903    async fn namespace_exists(&self, namespace: &NamespaceId) -> Result<bool> {
904        // Check any tier
905        Ok(self.hot_storage.namespace_exists(namespace).await?
906            || self.warm_storage.namespace_exists(namespace).await?
907            || self.cold_storage.namespace_exists(namespace).await?)
908    }
909
910    async fn ensure_namespace(&self, namespace: &NamespaceId) -> Result<()> {
911        // Ensure in all tiers
912        self.hot_storage.ensure_namespace(namespace).await?;
913        self.warm_storage.ensure_namespace(namespace).await?;
914        self.cold_storage.ensure_namespace(namespace).await?;
915        Ok(())
916    }
917
918    async fn count(&self, namespace: &NamespaceId) -> Result<usize> {
919        // With write-through, cold tier is the source of truth for total count.
920        // Hot/warm are caches that hold subsets of the same data.
921        // Use cold count as the baseline, then add any vectors that are ONLY
922        // in hot or warm (shouldn't happen with write-through, but safe).
923        let cold = self.cold_storage.count(namespace).await?;
924        if cold > 0 {
925            return Ok(cold);
926        }
927        // Fallback: if cold is empty, count from hot + warm (non-tiered data)
928        let hot = self.hot_storage.count(namespace).await?;
929        let warm = self.warm_storage.count(namespace).await?;
930        Ok(hot + warm)
931    }
932
933    async fn dimension(&self, namespace: &NamespaceId) -> Result<Option<usize>> {
934        // Check hot first, then warm, then cold
935        if let Some(dim) = self.hot_storage.dimension(namespace).await? {
936            return Ok(Some(dim));
937        }
938        if let Some(dim) = self.warm_storage.dimension(namespace).await? {
939            return Ok(Some(dim));
940        }
941        self.cold_storage.dimension(namespace).await
942    }
943
944    async fn list_namespaces(&self) -> Result<Vec<NamespaceId>> {
945        let mut namespaces = std::collections::HashSet::new();
946
947        namespaces.extend(self.hot_storage.list_namespaces().await?);
948        namespaces.extend(self.warm_storage.list_namespaces().await?);
949        namespaces.extend(self.cold_storage.list_namespaces().await?);
950
951        Ok(namespaces.into_iter().collect())
952    }
953
954    async fn delete_namespace(&self, namespace: &NamespaceId) -> Result<bool> {
955        // Delete from all tiers
956        let hot_deleted = self.hot_storage.delete_namespace(namespace).await?;
957        let warm_deleted = self.warm_storage.delete_namespace(namespace).await?;
958        let cold_deleted = self.cold_storage.delete_namespace(namespace).await?;
959
960        // Remove from access tracking
961        {
962            let mut access_map = self.access_info.write();
963            access_map.retain(|(ns, _), _| ns != namespace);
964        }
965
966        Ok(hot_deleted || warm_deleted || cold_deleted)
967    }
968
969    async fn cleanup_expired(&self, namespace: &NamespaceId) -> Result<usize> {
970        // Cleanup from all tiers
971        let mut total = 0;
972        total += self.hot_storage.cleanup_expired(namespace).await?;
973        total += self.warm_storage.cleanup_expired(namespace).await?;
974        total += self.cold_storage.cleanup_expired(namespace).await?;
975        Ok(total)
976    }
977
978    async fn cleanup_all_expired(&self) -> Result<usize> {
979        // Cleanup from all tiers
980        let mut total = 0;
981        total += self.hot_storage.cleanup_all_expired().await?;
982        total += self.warm_storage.cleanup_all_expired().await?;
983        total += self.cold_storage.cleanup_all_expired().await?;
984        Ok(total)
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::memory::InMemoryStorage;
992
993    fn create_test_vector(id: &str, dim: usize) -> Vector {
994        Vector {
995            id: id.to_string(),
996            values: vec![1.0; dim],
997            metadata: None,
998            ttl_seconds: None,
999            expires_at: None,
1000        }
1001    }
1002
1003    #[tokio::test]
1004    async fn test_tiered_storage_basic() {
1005        let config = TieredStorageConfig::default();
1006        let storage = TieredStorage::new(
1007            config,
1008            InMemoryStorage::new(),
1009            InMemoryStorage::new(),
1010            InMemoryStorage::new(),
1011        );
1012
1013        let namespace = "test".to_string();
1014        storage.ensure_namespace(&namespace).await.unwrap();
1015
1016        // Upsert goes to hot tier
1017        let vectors = vec![create_test_vector("v1", 4)];
1018        let count = storage.upsert(&namespace, vectors).await.unwrap();
1019        assert_eq!(count, 1);
1020
1021        // Get should find in hot tier
1022        let results = storage.get(&namespace, &["v1".to_string()]).await.unwrap();
1023        assert_eq!(results.len(), 1);
1024
1025        let stats = storage.stats();
1026        assert_eq!(stats.hot_hits, 2); // One from upsert record_access, one from get
1027    }
1028
1029    #[tokio::test]
1030    async fn test_tiered_storage_promotion_demotion() {
1031        let config = TieredStorageConfig::default();
1032        let storage = TieredStorage::new(
1033            config,
1034            InMemoryStorage::new(),
1035            InMemoryStorage::new(),
1036            InMemoryStorage::new(),
1037        );
1038
1039        let namespace = "test".to_string();
1040        storage.ensure_namespace(&namespace).await.unwrap();
1041
1042        // Add to hot tier
1043        storage
1044            .upsert(&namespace, vec![create_test_vector("v1", 4)])
1045            .await
1046            .unwrap();
1047
1048        // Verify in hot
1049        assert_eq!(
1050            storage.get_tier(&namespace, &"v1".to_string()),
1051            Some(StorageTier::Hot)
1052        );
1053
1054        // Demote to warm
1055        let demoted = storage.demote(&namespace, &"v1".to_string()).await.unwrap();
1056        assert!(demoted);
1057        assert_eq!(
1058            storage.get_tier(&namespace, &"v1".to_string()),
1059            Some(StorageTier::Warm)
1060        );
1061
1062        // Demote to cold
1063        let demoted = storage.demote(&namespace, &"v1".to_string()).await.unwrap();
1064        assert!(demoted);
1065        assert_eq!(
1066            storage.get_tier(&namespace, &"v1".to_string()),
1067            Some(StorageTier::Cold)
1068        );
1069
1070        // Still accessible
1071        let results = storage.get(&namespace, &["v1".to_string()]).await.unwrap();
1072        assert_eq!(results.len(), 1);
1073
1074        let stats = storage.stats();
1075        assert_eq!(stats.demotions_to_warm, 1);
1076        assert_eq!(stats.demotions_to_cold, 1);
1077    }
1078
1079    #[tokio::test]
1080    async fn test_tiered_storage_multi_tier_get() {
1081        let config = TieredStorageConfig::default();
1082        let storage = TieredStorage::new(
1083            config,
1084            InMemoryStorage::new(),
1085            InMemoryStorage::new(),
1086            InMemoryStorage::new(),
1087        );
1088
1089        let namespace = "test".to_string();
1090        storage.ensure_namespace(&namespace).await.unwrap();
1091
1092        // Add vectors and demote some
1093        for i in 0..3 {
1094            storage
1095                .upsert(&namespace, vec![create_test_vector(&format!("v{}", i), 4)])
1096                .await
1097                .unwrap();
1098        }
1099
1100        // v0 stays hot, v1 goes warm, v2 goes cold
1101        storage.demote(&namespace, &"v1".to_string()).await.unwrap();
1102        storage.demote(&namespace, &"v2".to_string()).await.unwrap();
1103        storage.demote(&namespace, &"v2".to_string()).await.unwrap();
1104
1105        // Get all at once
1106        let ids: Vec<_> = (0..3).map(|i| format!("v{}", i)).collect();
1107        let results = storage.get(&namespace, &ids).await.unwrap();
1108        assert_eq!(results.len(), 3);
1109    }
1110
1111    #[tokio::test]
1112    async fn test_tier_distribution() {
1113        let config = TieredStorageConfig::default();
1114        let storage = TieredStorage::new(
1115            config,
1116            InMemoryStorage::new(),
1117            InMemoryStorage::new(),
1118            InMemoryStorage::new(),
1119        );
1120
1121        let namespace = "test".to_string();
1122        storage.ensure_namespace(&namespace).await.unwrap();
1123
1124        // Add 5 vectors
1125        for i in 0..5 {
1126            storage
1127                .upsert(&namespace, vec![create_test_vector(&format!("v{}", i), 4)])
1128                .await
1129                .unwrap();
1130        }
1131
1132        // Demote 2 to warm, 1 to cold
1133        storage.demote(&namespace, &"v3".to_string()).await.unwrap();
1134        storage.demote(&namespace, &"v4".to_string()).await.unwrap();
1135        storage.demote(&namespace, &"v4".to_string()).await.unwrap();
1136
1137        let dist = storage.tier_distribution(&namespace);
1138        assert_eq!(dist.hot, 3);
1139        assert_eq!(dist.warm, 1);
1140        assert_eq!(dist.cold, 1);
1141    }
1142
1143    #[tokio::test]
1144    async fn test_auto_tiering_purges_stale_namespace_entries() {
1145        let config = TieredStorageConfig {
1146            auto_tier_enabled: true,
1147            hot_to_warm_threshold: Duration::from_millis(0),
1148            warm_to_cold_threshold: Duration::from_millis(0),
1149            ..Default::default()
1150        };
1151        let storage = TieredStorage::new(
1152            config,
1153            InMemoryStorage::new(),
1154            InMemoryStorage::new(),
1155            InMemoryStorage::new(),
1156        );
1157
1158        let namespace = "deleted_bench_ns".to_string();
1159        storage.ensure_namespace(&namespace).await.unwrap();
1160        storage
1161            .upsert(&namespace, vec![create_test_vector("v1", 4)])
1162            .await
1163            .unwrap();
1164
1165        // Delete the namespace (simulates bench cleanup)
1166        storage.delete_namespace(&namespace).await.unwrap();
1167
1168        // Re-insert stale entries into access_info (simulates race / leftover)
1169        {
1170            let mut access_map = storage.access_info.write();
1171            access_map.insert(
1172                (namespace.clone(), "v1".to_string()),
1173                AccessInfo {
1174                    last_access: Instant::now() - Duration::from_secs(7200),
1175                    access_count: 1,
1176                    tier: StorageTier::Hot,
1177                    approx_bytes: 0,
1178                },
1179            );
1180        }
1181
1182        // Auto-tiering should succeed (not bail) and purge the stale entry
1183        let result = storage.run_auto_tiering().await.unwrap();
1184        assert_eq!(result.demoted_to_warm, 0);
1185
1186        let access_map = storage.access_info.read();
1187        assert!(
1188            access_map.is_empty(),
1189            "Stale entries should have been purged from tiering queue"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_is_permanent_storage_error() {
1195        assert!(TieredStorage::<
1196            InMemoryStorage,
1197            InMemoryStorage,
1198            InMemoryStorage,
1199        >::is_permanent_storage_error(
1200            &DakeraError::NamespaceNotFound("test".into())
1201        ));
1202        assert!(TieredStorage::<
1203            InMemoryStorage,
1204            InMemoryStorage,
1205            InMemoryStorage,
1206        >::is_permanent_storage_error(&DakeraError::Storage(
1207            "PermissionDenied (permanent) at write".into()
1208        )));
1209        assert!(!TieredStorage::<
1210            InMemoryStorage,
1211            InMemoryStorage,
1212            InMemoryStorage,
1213        >::is_permanent_storage_error(
1214            &DakeraError::Storage("connection timeout".into())
1215        ));
1216        assert!(!TieredStorage::<
1217            InMemoryStorage,
1218            InMemoryStorage,
1219            InMemoryStorage,
1220        >::is_permanent_storage_error(
1221            &DakeraError::DimensionMismatch {
1222                expected: 384,
1223                actual: 1024,
1224            }
1225        ));
1226    }
1227
1228    #[test]
1229    fn test_approx_vector_bytes_scales_with_payload() {
1230        let small = approx_vector_bytes(&create_test_vector("v", 4));
1231        let big = approx_vector_bytes(&create_test_vector("v", 1024));
1232        // Embedding values dominate: (1024 - 4) * 4 bytes difference.
1233        assert_eq!(big - small, (1024 - 4) * std::mem::size_of::<f32>());
1234
1235        let mut with_meta = create_test_vector("v", 4);
1236        with_meta.metadata = Some(serde_json::json!({ "content": "x".repeat(100) }));
1237        assert!(approx_vector_bytes(&with_meta) > small + 100);
1238    }
1239
1240    #[tokio::test]
1241    async fn test_hot_bytes_accounting() {
1242        let storage = TieredStorage::new(
1243            TieredStorageConfig::default(),
1244            InMemoryStorage::new(),
1245            InMemoryStorage::new(),
1246            InMemoryStorage::new(),
1247        );
1248        let namespace = "test".to_string();
1249        storage.ensure_namespace(&namespace).await.unwrap();
1250
1251        storage
1252            .upsert(
1253                &namespace,
1254                vec![create_test_vector("v1", 4), create_test_vector("v2", 4)],
1255            )
1256            .await
1257            .unwrap();
1258        let after_insert = storage.stats().hot_bytes;
1259        assert!(after_insert > 0);
1260
1261        // Overwriting an existing hot vector must not double-count bytes or count.
1262        storage
1263            .upsert(&namespace, vec![create_test_vector("v1", 4)])
1264            .await
1265            .unwrap();
1266        assert_eq!(storage.stats().hot_bytes, after_insert);
1267        assert_eq!(storage.stats().hot_count, 2);
1268
1269        // Delete releases the budget.
1270        storage
1271            .delete(&namespace, &["v1".to_string()])
1272            .await
1273            .unwrap();
1274        assert!(storage.stats().hot_bytes < after_insert);
1275        assert_eq!(storage.stats().hot_count, 1);
1276
1277        storage
1278            .delete(&namespace, &["v2".to_string()])
1279            .await
1280            .unwrap();
1281        assert_eq!(storage.stats().hot_bytes, 0);
1282        assert_eq!(storage.stats().hot_count, 0);
1283    }
1284
1285    #[tokio::test]
1286    async fn test_capacity_enforcement_by_count() {
1287        let config = TieredStorageConfig {
1288            hot_tier_capacity: 2,
1289            // Capacity is a hard bound — enforced even with time-based tiering off.
1290            auto_tier_enabled: false,
1291            ..Default::default()
1292        };
1293        let storage = TieredStorage::new(
1294            config,
1295            InMemoryStorage::new(),
1296            InMemoryStorage::new(),
1297            InMemoryStorage::new(),
1298        );
1299        let namespace = "test".to_string();
1300        storage.ensure_namespace(&namespace).await.unwrap();
1301
1302        for (i, id) in ["v1", "v2", "v3", "v4"].iter().enumerate() {
1303            storage
1304                .upsert(&namespace, vec![create_test_vector(id, 4)])
1305                .await
1306                .unwrap();
1307            // Stagger last_access so LRU order is deterministic.
1308            if i < 3 {
1309                tokio::time::sleep(Duration::from_millis(5)).await;
1310            }
1311        }
1312
1313        let result = storage.run_auto_tiering().await.unwrap();
1314        assert_eq!(result.demoted_to_warm, 2);
1315        let stats = storage.stats();
1316        assert_eq!(stats.hot_count, 2);
1317        assert_eq!(stats.warm_count, 2);
1318
1319        // Oldest two demoted, newest two stayed hot; all four still readable.
1320        assert_eq!(
1321            storage.get_tier(&namespace, &"v1".to_string()),
1322            Some(StorageTier::Warm)
1323        );
1324        assert_eq!(
1325            storage.get_tier(&namespace, &"v2".to_string()),
1326            Some(StorageTier::Warm)
1327        );
1328        assert_eq!(
1329            storage.get_tier(&namespace, &"v4".to_string()),
1330            Some(StorageTier::Hot)
1331        );
1332        let all = storage
1333            .get(
1334                &namespace,
1335                &[
1336                    "v1".to_string(),
1337                    "v2".to_string(),
1338                    "v3".to_string(),
1339                    "v4".to_string(),
1340                ],
1341            )
1342            .await
1343            .unwrap();
1344        assert_eq!(all.len(), 4);
1345    }
1346
1347    #[tokio::test]
1348    async fn test_capacity_enforcement_by_bytes() {
1349        let per_vec = approx_vector_bytes(&create_test_vector("v1", 128)) as u64;
1350        let budget = per_vec * 2 + 8; // room for ~2 vectors
1351        let config = TieredStorageConfig {
1352            hot_tier_capacity: usize::MAX,
1353            hot_tier_max_bytes: Some(budget),
1354            auto_tier_enabled: false,
1355            ..Default::default()
1356        };
1357        let storage = TieredStorage::new(
1358            config,
1359            InMemoryStorage::new(),
1360            InMemoryStorage::new(),
1361            InMemoryStorage::new(),
1362        );
1363        let namespace = "test".to_string();
1364        storage.ensure_namespace(&namespace).await.unwrap();
1365
1366        for (i, id) in ["v1", "v2", "v3", "v4"].iter().enumerate() {
1367            storage
1368                .upsert(&namespace, vec![create_test_vector(id, 128)])
1369                .await
1370                .unwrap();
1371            if i < 3 {
1372                tokio::time::sleep(Duration::from_millis(5)).await;
1373            }
1374        }
1375        assert!(storage.stats().hot_bytes > budget);
1376
1377        let result = storage.run_auto_tiering().await.unwrap();
1378        assert_eq!(result.demoted_to_warm, 2);
1379        let stats = storage.stats();
1380        assert!(stats.hot_bytes <= budget);
1381        assert_eq!(stats.hot_count, 2);
1382
1383        // Nothing lost — demoted vectors are served from warm.
1384        let all = storage
1385            .get(
1386                &namespace,
1387                &[
1388                    "v1".to_string(),
1389                    "v2".to_string(),
1390                    "v3".to_string(),
1391                    "v4".to_string(),
1392                ],
1393            )
1394            .await
1395            .unwrap();
1396        assert_eq!(all.len(), 4);
1397    }
1398}