Skip to main content

chronix_engine/cache/
metadata.rs

1//! Segment metadata index — one entry per live segment.
2//!
3//! Holds each segment's header, column statistics and per-tag bloom filters
4//! so the query planner can prune without opening a file. Populated at open
5//! from the catalog and maintained by flush, compaction, retention, delete
6//! and GC.
7//!
8//! # This is an index, not a cache
9//!
10//! There is no eviction and no hit rate: the entry set *is* the live segment
11//! set, bounded by the segments on disk exactly as the catalog is. It used to
12//! advertise a `max_entries` cap that only logged — inserts succeeded
13//! regardless — beside a comment putting an entry at "~1 KB", which no
14//! per-tag bloom filter was going to honour.
15//!
16//! [`MetadataCache::memory_bytes`] replaces both: maintained as entries come
17//! and go, summed into the resident-memory total, exported as
18//! `chronix_metadata_cache_bytes`.
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use parking_lot::RwLock;
24
25use crate::segment::{ColumnMeta, SegmentHeader};
26use chronix_core::SegmentId;
27
28/// Cached metadata for a single segment.
29#[derive(Debug, Clone)]
30pub struct CachedSegmentMeta {
31    /// Segment ID.
32    pub segment_id: SegmentId,
33    /// Parsed segment header.
34    pub header: SegmentHeader,
35    /// Per-column metadata (name, type, role, stats).
36    pub columns: Vec<ColumnMeta>,
37}
38
39/// In-memory metadata cache for all segments.
40///
41/// Populated on startup from the segment catalog and updated as
42/// segments are created, compacted, or deleted.
43///
44/// # Thread Safety
45///
46/// Uses `RwLock<HashMap>` for concurrent read access.
47///
48/// # Size
49///
50/// One entry per live segment, and [`memory_bytes`](Self::memory_bytes)
51/// reports what they cost. The figure is maintained as entries are inserted
52/// and removed rather than recomputed, because `statistics()` runs on every
53/// metrics scrape and walking the map there would make the exporter the most
54/// expensive thing in the process.
55pub struct MetadataCache {
56    /// Map from segment ID → cached metadata, `Arc`-wrapped to avoid a deep clone on read.
57    entries: Arc<RwLock<HashMap<SegmentId, Arc<CachedSegmentMeta>>>>,
58    /// Running total of [`entry_bytes`] over `entries`, maintained under the
59    /// same lock so it cannot drift from the map it describes.
60    bytes: Arc<RwLock<usize>>,
61}
62
63/// What one entry costs: the struct, its header, and every column's name,
64/// statistics and bloom filter.
65///
66/// The bloom filter is the term that matters and the one the old "~1 KB per
67/// entry" estimate omitted — it is a `Vec<u8>` sized by the segment's tag
68/// cardinality, so a wide segment's entry is orders of magnitude larger than
69/// a narrow one's.
70fn entry_bytes(meta: &CachedSegmentMeta) -> usize {
71    std::mem::size_of::<CachedSegmentMeta>()
72        + meta.columns.capacity() * std::mem::size_of::<ColumnMeta>()
73        + meta
74            .columns
75            .iter()
76            .map(|c| c.name.capacity() + c.bloom_filter.as_ref().map_or(0, Vec::capacity))
77            .sum::<usize>()
78}
79
80impl MetadataCache {
81    /// Create an empty metadata index.
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            entries: Arc::new(RwLock::new(HashMap::new())),
86            bytes: Arc::new(RwLock::new(0)),
87        }
88    }
89
90    /// Insert or update metadata for a segment.
91    pub fn insert(&self, meta: CachedSegmentMeta) {
92        let added = entry_bytes(&meta);
93        let mut map = self.entries.write();
94        let mut bytes = self.bytes.write();
95        let replaced = map.insert(meta.segment_id, Arc::new(meta));
96        *bytes = bytes.saturating_sub(replaced.as_deref().map_or(0, entry_bytes)) + added;
97    }
98
99    /// Look up metadata for a segment.
100    ///
101    /// Returns an `Arc` for cheap cloning.
102    #[must_use]
103    pub fn get(&self, segment_id: SegmentId) -> Option<Arc<CachedSegmentMeta>> {
104        self.entries.read().get(&segment_id).cloned()
105    }
106
107    /// Remove metadata for a segment.
108    pub fn remove(&self, segment_id: SegmentId) {
109        let mut map = self.entries.write();
110        let mut bytes = self.bytes.write();
111        if let Some(removed) = map.remove(&segment_id) {
112            *bytes = bytes.saturating_sub(entry_bytes(&removed));
113        }
114    }
115
116    /// Bytes the index holds, counted as entries come and go.
117    ///
118    /// Summed into the database's resident-memory total and exported as
119    /// `chronix_metadata_cache_bytes`.
120    #[must_use]
121    pub fn memory_bytes(&self) -> usize {
122        *self.bytes.read()
123    }
124
125    /// Returns the number of cached segments.
126    #[must_use]
127    pub fn len(&self) -> usize {
128        self.entries.read().len()
129    }
130
131    /// Returns `true` if the cache is empty.
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.entries.read().is_empty()
135    }
136
137    /// Clear all cached metadata.
138    pub fn clear(&self) {
139        let mut map = self.entries.write();
140        let mut bytes = self.bytes.write();
141        map.clear();
142        *bytes = 0;
143    }
144
145    /// Returns column metadata for a segment, if cached.
146    #[must_use]
147    pub fn columns_for(&self, segment_id: SegmentId) -> Option<Vec<ColumnMeta>> {
148        self.entries
149            .read()
150            .get(&segment_id)
151            .map(|m| m.columns.clone())
152    }
153}
154
155impl Default for MetadataCache {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl std::fmt::Debug for MetadataCache {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("MetadataCache")
164            .field("entries", &self.entries.read().len())
165            .field("bytes", &*self.bytes.read())
166            .finish()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::segment::ColumnStats;
174
175    fn make_header() -> SegmentHeader {
176        SegmentHeader {
177            version: 1,
178            flags: 0,
179            created_at: 0,
180            min_timestamp: 100,
181            max_timestamp: 200,
182            row_count: 1000,
183            column_count: 3,
184            series_count: 5,
185            compression: 0,
186            sort_order: 1,
187        }
188    }
189
190    fn make_column_meta(name: &str) -> ColumnMeta {
191        ColumnMeta {
192            name: name.to_string(),
193            data_type: 2,
194            role: 2,
195            default_encoding: 1,
196            stats: ColumnStats::empty(),
197            bloom_filter: None,
198            row_group_blooms: None,
199            encrypted: false,
200            key_id: None,
201            decimal_scale: None,
202        }
203    }
204
205    #[test]
206    fn insert_and_get() {
207        let cache = MetadataCache::new();
208        cache.insert(CachedSegmentMeta {
209            segment_id: SegmentId(1),
210            header: make_header(),
211            columns: vec![make_column_meta("cpu")],
212        });
213
214        let result = cache.get(SegmentId(1));
215        assert!(result.is_some());
216        assert_eq!(result.unwrap().columns.len(), 1);
217    }
218
219    #[test]
220    fn get_nonexistent_returns_none() {
221        let cache = MetadataCache::new();
222        assert!(cache.get(SegmentId(99)).is_none());
223    }
224
225    #[test]
226    fn remove_entry() {
227        let cache = MetadataCache::new();
228        cache.insert(CachedSegmentMeta {
229            segment_id: SegmentId(1),
230            header: make_header(),
231            columns: Vec::new(),
232        });
233        assert_eq!(cache.len(), 1);
234
235        cache.remove(SegmentId(1));
236        assert_eq!(cache.len(), 0);
237    }
238
239    #[test]
240    fn columns_for_segment() {
241        let cache = MetadataCache::new();
242        cache.insert(CachedSegmentMeta {
243            segment_id: SegmentId(1),
244            header: make_header(),
245            columns: vec![make_column_meta("cpu"), make_column_meta("mem")],
246        });
247
248        let cols = cache.columns_for(SegmentId(1)).unwrap();
249        assert_eq!(cols.len(), 2);
250        assert_eq!(cols[0].name, "cpu");
251    }
252}