Skip to main content

cqlite_core/memory/
mod.rs

1//! Memory management for CQLite.
2//!
3//! Issue #1568 (Epic B / B2) deleted the dead `MemoryManager` cache core — the
4//! LRU block cache, the row cache, and the buffer pool that no read path used —
5//! now that B1 (#1567) ships the real
6//! [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
7//! What remains is the **semver-frozen** public stats shell:
8//! [`MemoryManager::stats`] and [`MemoryStats`], reachable through
9//! `Database::stats().memory_stats`. Its block-cache numbers are now sourced
10//! from the live B1 cache (real hits/misses/occupancy) instead of the deleted
11//! always-zero counters, so a repeated cached read yields a non-zero
12//! [`MemoryStats::block_cache_hit_rate`] rather than a structural `0.0`.
13
14use std::sync::Arc;
15
16use crate::storage::cache::DecompressedChunkCache;
17#[cfg(feature = "state_machine")]
18use crate::Value;
19use crate::{Config, Result};
20
21/// Memory manager: the retained public stats shell over the real B1 cache.
22///
23/// It holds an optional handle to the shared
24/// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
25/// When present (the production path — [`MemoryManager::with_chunk_cache`]),
26/// [`stats`](Self::stats) reports that cache's real activity. When absent
27/// ([`new`](Self::new), used where no cache is wired), the block-cache numbers
28/// report zero.
29#[derive(Debug)]
30pub struct MemoryManager {
31    /// The live shared decompressed-chunk cache backing the stats surface, if
32    /// this manager was wired to one.
33    chunk_cache: Option<Arc<DecompressedChunkCache>>,
34}
35
36impl MemoryManager {
37    /// Create a memory manager not wired to a cache.
38    ///
39    /// Retained for callers (and tests) that construct a manager without a live
40    /// storage engine; its [`stats`](Self::stats) reports zero block-cache
41    /// activity. Production opens use [`with_chunk_cache`](Self::with_chunk_cache).
42    pub fn new(_config: &Config) -> Result<Self> {
43        Ok(Self { chunk_cache: None })
44    }
45
46    /// Create a memory manager whose stats surface reports the real activity of
47    /// the shared B1 decompressed-chunk cache (issue #1568).
48    pub fn with_chunk_cache(chunk_cache: Arc<DecompressedChunkCache>) -> Self {
49        Self {
50            chunk_cache: Some(chunk_cache),
51        }
52    }
53
54    /// Get memory statistics.
55    ///
56    /// The block-cache hit/miss counts and occupancy (`total_memory_used`) are
57    /// sourced from the live B1 [`DecompressedChunkCache`] when wired (issue
58    /// #1568): `hit_count()` / `miss_count()` / `resident_bytes()`. The retained
59    /// row-cache and buffer-pool sub-fields report a fixed `0` — the richer
60    /// honest surface is Epic B / B5. The `-> Result<MemoryStats>` signature is
61    /// preserved for semver compatibility with `Database::stats()`.
62    ///
63    /// [`DecompressedChunkCache`]: crate::storage::cache::DecompressedChunkCache
64    pub fn stats(&self) -> Result<MemoryStats> {
65        let mut stats = MemoryStats::default();
66        if let Some(cache) = &self.chunk_cache {
67            stats.block_cache_hits = cache.hit_count();
68            stats.block_cache_misses = cache.miss_count();
69            stats.block_cache_evictions = cache.eviction_count();
70            stats.block_cache_capacity_bytes = cache.budget_bytes();
71            stats.total_memory_used = cache.resident_bytes();
72        }
73        Ok(stats)
74    }
75}
76
77/// Estimate the logical size, in bytes, of a single CQL value.
78///
79/// The single estimator reused by the SELECT executor's byte-bounded result
80/// budget (issue #1582). It is a *logical* content estimate and deliberately
81/// does not model container overhead (HashMap slots, `Arc`/`String` capacity),
82/// which is why the executor's byte budget sits well below the process memory
83/// target to leave headroom for that overhead.
84///
85/// `state_machine`-gated: its only consumer is the (feature-gated) query engine
86/// (`query::result_budget`), so under a minimal `--no-default-features` build it
87/// would otherwise be dead code.
88#[cfg(feature = "state_machine")]
89pub(crate) fn estimate_value_size(value: &Value) -> usize {
90    match value {
91        Value::Null => 1,
92        // Zero content bytes: the sentinel's payload IS the empty buffer
93        // (issue #3805).
94        Value::Empty(_) => 0,
95        Value::Boolean(_) => 1,
96        Value::Integer(_) => 4,
97        Value::BigInt(_) => 8,
98        Value::Counter(_) => 8,
99        Value::Float(_) => 8,
100        Value::Text(s) => s.len(),
101        Value::Blob(b) => b.len(),
102        Value::Timestamp(_) => 8,
103        Value::Date(_) => 4,
104        Value::Time(_) => 8,
105        Value::Uuid(_) => 16,
106        Value::Inet(bytes) => bytes.len(),
107        Value::Json(json) => json.to_string().len(),
108        Value::List(items) => items.iter().map(estimate_value_size).sum(),
109        Value::Map(map) => map
110            .iter()
111            .map(|(k, v)| estimate_value_size(k) + estimate_value_size(v))
112            .sum(),
113        Value::TinyInt(_) => 1,
114        Value::SmallInt(_) => 2,
115        Value::Float32(_) => 4,
116        Value::Set(items) => items.iter().map(estimate_value_size).sum(),
117        Value::Tuple(items) => items.iter().map(estimate_value_size).sum(),
118        Value::Udt(udt) => udt
119            .fields
120            .iter()
121            .map(|f| f.value.as_ref().map_or(0, estimate_value_size))
122            .sum(),
123        Value::Frozen(boxed_value) => estimate_value_size(boxed_value),
124        Value::Varint(data) => data.len(),
125        Value::Decimal { unscaled, .. } => 4 + unscaled.len(), // scale + unscaled data
126        Value::Duration { .. } => 12,                          // 3 * 4 bytes
127        Value::Tombstone(_) => 16,                             // timestamp + type + optional TTL
128    }
129}
130
131/// Memory statistics.
132///
133/// Public-surface-wired (semver) through `Database::stats().memory_stats`. The
134/// field names and types are preserved verbatim (issue #1568, AK6); only the
135/// *source* of the block-cache numbers changed — they now reflect the real B1
136/// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
137///
138/// # Independent sampling (advisory observability)
139///
140/// The `block_cache_*` fields and the `key_cache_*` fields are sampled
141/// **independently and at different instants**: the block-cache figures come from
142/// the [`MemoryManager`] (its shared B1 chunk cache), while the key-cache figures
143/// are aggregated from the storage engine's per-reader B4 caches
144/// (`SSTableManager::aggregate_key_cache_stats`) in a separate step. Under
145/// concurrent read load the two groups can therefore reflect slightly different
146/// moments in time, so within a single `memory_stats` snapshot the block-cache vs
147/// key-cache figures are **not guaranteed to be mutually coherent**. Treat this as
148/// advisory observability (trends, rough ratios), not a transactionally-consistent
149/// point-in-time view of both caches.
150#[derive(Debug, Clone, Default)]
151pub struct MemoryStats {
152    /// Block cache hits (real B1 cache hit count when wired).
153    pub block_cache_hits: u64,
154
155    /// Block cache misses (real B1 cache miss count when wired).
156    pub block_cache_misses: u64,
157
158    /// Block cache evictions: entries the B1 decompressed-chunk cache evicted to
159    /// stay within its byte budget (issue #1571, B5). Real `eviction_count()`
160    /// when wired; `0` (honest — no cache, no evictions) otherwise. High churn
161    /// relative to hits signals an undersized `block_cache_capacity_bytes`.
162    pub block_cache_evictions: u64,
163
164    /// Block cache configured byte budget (issue #1571, B5): the B1 cache's
165    /// `budget_bytes()` when wired, so a hit rate can be read against the budget
166    /// it was measured under; `0` when no cache is wired / block caching disabled.
167    pub block_cache_capacity_bytes: usize,
168
169    /// Key cache hits (issue #1571/#2059). A hit lets a repeated point read skip the
170    /// `Index.db` interval parse. Since #2059 the key cache is ONE process-global
171    /// instance shared by every open reader, so these counters are PROCESS-GLOBAL: they
172    /// aggregate activity across ALL `Database` instances in the process, not one
173    /// reader's slice (a semantic change from the retired per-reader counters). Real
174    /// counter; `0` before any reader touches the cache.
175    pub key_cache_hits: u64,
176
177    /// Key cache misses (issue #1571/#2059). Process-global, like
178    /// [`key_cache_hits`](Self::key_cache_hits) — summed across all `Database`
179    /// instances in the process.
180    pub key_cache_misses: u64,
181
182    /// Key cache evictions: entries evicted from the process-global key cache to
183    /// stay within its byte budget (issue #1571/#2059) — DISTINCT from
184    /// [`key_cache_invalidations`](Self::key_cache_invalidations).
185    pub key_cache_evictions: u64,
186
187    /// Key cache invalidations: entries dropped from the process-global key cache on
188    /// generation removal / compaction / warm-registry evict (issue #2059) — a
189    /// distinct counter from budget-driven [`key_cache_evictions`](Self::key_cache_evictions).
190    pub key_cache_invalidations: u64,
191
192    /// Key cache resident bytes: approximate resident footprint of the process-global
193    /// key cache (issue #1571/#2059).
194    pub key_cache_resident_bytes: usize,
195
196    /// Key cache capacity bytes: the process-global key cache's fixed configured byte
197    /// budget (issue #1571/#2059), or `0` when block caching is disabled.
198    pub key_cache_capacity_bytes: usize,
199
200    /// Row cache hits. Retained for shape compatibility; the row cache was
201    /// deleted (issue #1568), so this reports `0` (full surface is Epic B / B5).
202    pub row_cache_hits: u64,
203
204    /// Row cache misses. Retained for shape compatibility; reports `0`.
205    pub row_cache_misses: u64,
206
207    /// Total memory used. Now the B1 cache's resident decompressed bytes
208    /// (`resident_bytes()`) when wired (issue #1568).
209    pub total_memory_used: usize,
210
211    /// Buffer pool allocations. Retained for shape compatibility; the buffer
212    /// pool was deleted (issue #1568), so this reports `0`.
213    pub buffer_allocations: u64,
214
215    /// Buffer pool deallocations. Retained for shape compatibility; reports `0`.
216    pub buffer_deallocations: u64,
217}
218
219impl MemoryStats {
220    /// Calculate block cache hit rate.
221    pub fn block_cache_hit_rate(&self) -> f64 {
222        let total = self.block_cache_hits + self.block_cache_misses;
223        if total > 0 {
224            self.block_cache_hits as f64 / total as f64
225        } else {
226            0.0
227        }
228    }
229
230    /// Calculate the aggregate key-cache hit rate (issue #1571, B5) from the real
231    /// summed hit and miss counts. Returns `0.0` when there has been no key-cache
232    /// activity (honest — not a structural pin).
233    pub fn key_cache_hit_rate(&self) -> f64 {
234        let total = self.key_cache_hits + self.key_cache_misses;
235        if total > 0 {
236            self.key_cache_hits as f64 / total as f64
237        } else {
238            0.0
239        }
240    }
241
242    /// Calculate row cache hit rate.
243    pub fn row_cache_hit_rate(&self) -> f64 {
244        let total = self.row_cache_hits + self.row_cache_misses;
245        if total > 0 {
246            self.row_cache_hits as f64 / total as f64
247        } else {
248            0.0
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::storage::cache::{ChunkKey, DecompressedChunkCache};
257
258    #[test]
259    fn stats_report_zero_without_a_wired_cache() {
260        let config = Config::default();
261        let manager = MemoryManager::new(&config).expect("construct manager");
262
263        let stats = manager.stats().expect("stats");
264        assert_eq!(stats.block_cache_hits, 0);
265        assert_eq!(stats.block_cache_misses, 0);
266        assert_eq!(stats.total_memory_used, 0);
267        assert_eq!(stats.block_cache_hit_rate(), 0.0);
268        // Issue #1571 (B5): every new cache-observability field reports an honest
269        // zero when no cache is wired — never a fabricated placeholder.
270        assert_eq!(stats.block_cache_evictions, 0);
271        assert_eq!(stats.block_cache_capacity_bytes, 0);
272        assert_eq!(stats.key_cache_hits, 0);
273        assert_eq!(stats.key_cache_misses, 0);
274        assert_eq!(stats.key_cache_evictions, 0);
275        // Issue #2059: the invalidations counter is distinct from evictions.
276        assert_eq!(stats.key_cache_invalidations, 0);
277        assert_eq!(stats.key_cache_resident_bytes, 0);
278        assert_eq!(stats.key_cache_capacity_bytes, 0);
279        assert_eq!(stats.key_cache_hit_rate(), 0.0);
280    }
281
282    #[test]
283    fn stats_bridge_reports_real_b1_cache_numbers() {
284        // A wired manager reports the live B1 cache's hits/misses/occupancy.
285        let cache = Arc::new(DecompressedChunkCache::with_budget_bytes(1 << 20));
286        let manager = MemoryManager::with_chunk_cache(Arc::clone(&cache));
287
288        let key = ChunkKey::new(1, 0);
289        cache.insert(key, vec![0xAB; 4096]); // resident bytes now non-zero
290        assert!(cache.get(&key).is_some()); // one hit
291        assert!(cache.get(&ChunkKey::new(1, 1)).is_none()); // one miss
292
293        let stats = manager.stats().expect("stats");
294        assert_eq!(stats.block_cache_hits, 1);
295        assert_eq!(stats.block_cache_misses, 1);
296        assert_eq!(stats.total_memory_used, cache.resident_bytes());
297        assert!(
298            stats.total_memory_used > 0,
299            "occupancy tracks resident bytes"
300        );
301        assert!(
302            stats.block_cache_hit_rate() > 0.0,
303            "a hit makes the reported rate non-zero (not structural 0.0)"
304        );
305        // Issue #1571 (B5): capacity is the real budget; no eviction yet.
306        assert_eq!(stats.block_cache_capacity_bytes, cache.budget_bytes());
307        assert_eq!(stats.block_cache_evictions, 0);
308    }
309
310    #[test]
311    fn stats_bridge_reports_real_b1_eviction_count() {
312        // Issue #1571 (B5): a wired manager surfaces the chunk cache's real
313        // eviction count. Tiny budget on a single shard forces deterministic
314        // evictions.
315        let cache = Arc::new(DecompressedChunkCache::with_budget_and_shards(200, 1));
316        let manager = MemoryManager::with_chunk_cache(Arc::clone(&cache));
317        for i in 0..5u64 {
318            cache.insert(ChunkKey::new(1, i), vec![i as u8; 100]);
319        }
320        let stats = manager.stats().expect("stats");
321        assert_eq!(stats.block_cache_evictions, cache.eviction_count());
322        assert!(
323            stats.block_cache_evictions > 0,
324            "over-budget inserts must have evicted"
325        );
326        assert_eq!(stats.block_cache_capacity_bytes, cache.budget_bytes());
327    }
328}