Skip to main content

chronix_engine/cache/
metadata.rs

1//! Metadata Cache — in-memory cache for segment metadata.
2//!
3//! Caches segment headers, bloom filters, and column statistics permanently
4//! in memory. Updated on segment creation, compaction, and deletion.
5//! Memory cost is approximately 1 KB per segment — no eviction needed.
6//!
7//! # Eviction policy
8//!
9//! This cache intentionally has **no LRU eviction**.  Each entry is ~1 KB
10//! (header + column stats), so even 100 000 segments consume only ~100 MB.
11//! Segments are explicitly removed from the cache when they are deleted or
12//! compacted away (see `MetadataCache::remove`).  If segment counts ever
13//! grow to the point where this becomes a concern, an LRU bound or
14//! generation-based eviction can be layered on top.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use parking_lot::RwLock;
20
21use crate::segment::{ColumnMeta, SegmentHeader};
22use chronix_core::SegmentId;
23
24/// Cached metadata for a single segment.
25#[derive(Debug, Clone)]
26pub struct CachedSegmentMeta {
27    /// Segment ID.
28    pub segment_id: SegmentId,
29    /// Parsed segment header.
30    pub header: SegmentHeader,
31    /// Per-column metadata (name, type, role, stats).
32    pub columns: Vec<ColumnMeta>,
33}
34
35/// In-memory metadata cache for all segments.
36///
37/// Populated on startup from the segment catalog and updated as
38/// segments are created, compacted, or deleted.
39///
40/// # Thread Safety
41///
42/// Uses `RwLock<HashMap>` for concurrent read access.
43///
44/// # Capacity bound
45///
46/// An optional `max_entries` limit caps the number of cached segments.
47/// When the limit is reached, new inserts are still accepted (to keep
48/// hot metadata available) but a warning is emitted so operators can
49/// investigate.  Each entry is ~1 KB, so even the default cap of
50/// 500 000 entries consumes at most ~500 MB — a safe upper bound for
51/// most deployments.  Set `max_entries = 0` to disable the cap.
52pub struct MetadataCache {
53    /// Map from segment ID → cached metadata, `Arc`-wrapped to avoid a deep clone on read.
54    entries: Arc<RwLock<HashMap<SegmentId, Arc<CachedSegmentMeta>>>>,
55    /// Maximum number of entries before warnings are emitted (0 = unlimited).
56    max_entries: usize,
57}
58
59/// Default maximum number of cached segment metadata entries.
60///
61/// At ~1 KB per entry this allows up to ~500 MB of metadata, which
62/// is a safe upper bound for the vast majority of deployments.
63const DEFAULT_MAX_ENTRIES: usize = 500_000;
64
65impl MetadataCache {
66    /// Create an empty metadata cache with the default capacity bound.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            entries: Arc::new(RwLock::new(HashMap::new())),
71            max_entries: DEFAULT_MAX_ENTRIES,
72        }
73    }
74
75    /// Create an empty metadata cache with a custom capacity bound.
76    ///
77    /// Pass `0` to disable the capacity warning.
78    #[must_use]
79    pub fn with_max_entries(max_entries: usize) -> Self {
80        Self {
81            entries: Arc::new(RwLock::new(HashMap::new())),
82            max_entries,
83        }
84    }
85
86    /// Insert or update metadata for a segment.
87    ///
88    /// If the cache exceeds [`max_entries`](Self::with_max_entries) a
89    /// warning is logged but the insert still succeeds so that hot
90    /// metadata is never silently dropped.
91    pub fn insert(&self, meta: CachedSegmentMeta) {
92        let mut map = self.entries.write();
93        map.insert(meta.segment_id, Arc::new(meta));
94        if self.max_entries > 0 && map.len() > self.max_entries {
95            tracing::warn!(
96                cache_len = map.len(),
97                max_entries = self.max_entries,
98                "MetadataCache exceeded max_entries — consider raising the limit or investigating segment count",
99            );
100        }
101    }
102
103    /// Look up metadata for a segment.
104    ///
105    /// Returns an `Arc` for cheap cloning.
106    #[must_use]
107    pub fn get(&self, segment_id: SegmentId) -> Option<Arc<CachedSegmentMeta>> {
108        self.entries.read().get(&segment_id).cloned()
109    }
110
111    /// Remove metadata for a segment.
112    pub fn remove(&self, segment_id: SegmentId) {
113        self.entries.write().remove(&segment_id);
114    }
115
116    /// Returns the number of cached segments.
117    #[must_use]
118    pub fn len(&self) -> usize {
119        self.entries.read().len()
120    }
121
122    /// Returns `true` if the cache is empty.
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.entries.read().is_empty()
126    }
127
128    /// Clear all cached metadata.
129    pub fn clear(&self) {
130        self.entries.write().clear();
131    }
132
133    /// Returns column metadata for a segment, if cached.
134    #[must_use]
135    pub fn columns_for(&self, segment_id: SegmentId) -> Option<Vec<ColumnMeta>> {
136        self.entries
137            .read()
138            .get(&segment_id)
139            .map(|m| m.columns.clone())
140    }
141}
142
143impl Default for MetadataCache {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl std::fmt::Debug for MetadataCache {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("MetadataCache")
152            .field("entries", &self.entries.read().len())
153            .finish()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::segment::ColumnStats;
161
162    fn make_header() -> SegmentHeader {
163        SegmentHeader {
164            version: 1,
165            flags: 0,
166            created_at: 0,
167            min_timestamp: 100,
168            max_timestamp: 200,
169            row_count: 1000,
170            column_count: 3,
171            series_count: 5,
172            compression: 0,
173            sort_order: 1,
174        }
175    }
176
177    fn make_column_meta(name: &str) -> ColumnMeta {
178        ColumnMeta {
179            name: name.to_string(),
180            data_type: 2,
181            role: 2,
182            default_encoding: 1,
183            stats: ColumnStats::empty(),
184            bloom_filter: None,
185            row_group_blooms: None,
186            encrypted: false,
187            key_id: None,
188        }
189    }
190
191    #[test]
192    fn insert_and_get() {
193        let cache = MetadataCache::new();
194        cache.insert(CachedSegmentMeta {
195            segment_id: SegmentId(1),
196            header: make_header(),
197            columns: vec![make_column_meta("cpu")],
198        });
199
200        let result = cache.get(SegmentId(1));
201        assert!(result.is_some());
202        assert_eq!(result.unwrap().columns.len(), 1);
203    }
204
205    #[test]
206    fn get_nonexistent_returns_none() {
207        let cache = MetadataCache::new();
208        assert!(cache.get(SegmentId(99)).is_none());
209    }
210
211    #[test]
212    fn remove_entry() {
213        let cache = MetadataCache::new();
214        cache.insert(CachedSegmentMeta {
215            segment_id: SegmentId(1),
216            header: make_header(),
217            columns: Vec::new(),
218        });
219        assert_eq!(cache.len(), 1);
220
221        cache.remove(SegmentId(1));
222        assert_eq!(cache.len(), 0);
223    }
224
225    #[test]
226    fn columns_for_segment() {
227        let cache = MetadataCache::new();
228        cache.insert(CachedSegmentMeta {
229            segment_id: SegmentId(1),
230            header: make_header(),
231            columns: vec![make_column_meta("cpu"), make_column_meta("mem")],
232        });
233
234        let cols = cache.columns_for(SegmentId(1)).unwrap();
235        assert_eq!(cols.len(), 2);
236        assert_eq!(cols[0].name, "cpu");
237    }
238}