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 Value::Boolean(_) => 1,
93 Value::Integer(_) => 4,
94 Value::BigInt(_) => 8,
95 Value::Counter(_) => 8,
96 Value::Float(_) => 8,
97 Value::Text(s) => s.len(),
98 Value::Blob(b) => b.len(),
99 Value::Timestamp(_) => 8,
100 Value::Date(_) => 4,
101 Value::Time(_) => 8,
102 Value::Uuid(_) => 16,
103 Value::Inet(bytes) => bytes.len(),
104 Value::Json(json) => json.to_string().len(),
105 Value::List(items) => items.iter().map(estimate_value_size).sum(),
106 Value::Map(map) => map
107 .iter()
108 .map(|(k, v)| estimate_value_size(k) + estimate_value_size(v))
109 .sum(),
110 Value::TinyInt(_) => 1,
111 Value::SmallInt(_) => 2,
112 Value::Float32(_) => 4,
113 Value::Set(items) => items.iter().map(estimate_value_size).sum(),
114 Value::Tuple(items) => items.iter().map(estimate_value_size).sum(),
115 Value::Udt(udt) => udt
116 .fields
117 .iter()
118 .map(|f| f.value.as_ref().map_or(0, estimate_value_size))
119 .sum(),
120 Value::Frozen(boxed_value) => estimate_value_size(boxed_value),
121 Value::Varint(data) => data.len(),
122 Value::Decimal { unscaled, .. } => 4 + unscaled.len(), // scale + unscaled data
123 Value::Duration { .. } => 12, // 3 * 4 bytes
124 Value::Tombstone(_) => 16, // timestamp + type + optional TTL
125 }
126}
127
128/// Memory statistics.
129///
130/// Public-surface-wired (semver) through `Database::stats().memory_stats`. The
131/// field names and types are preserved verbatim (issue #1568, AK6); only the
132/// *source* of the block-cache numbers changed — they now reflect the real B1
133/// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache).
134///
135/// # Independent sampling (advisory observability)
136///
137/// The `block_cache_*` fields and the `key_cache_*` fields are sampled
138/// **independently and at different instants**: the block-cache figures come from
139/// the [`MemoryManager`] (its shared B1 chunk cache), while the key-cache figures
140/// are aggregated from the storage engine's per-reader B4 caches
141/// (`SSTableManager::aggregate_key_cache_stats`) in a separate step. Under
142/// concurrent read load the two groups can therefore reflect slightly different
143/// moments in time, so within a single `memory_stats` snapshot the block-cache vs
144/// key-cache figures are **not guaranteed to be mutually coherent**. Treat this as
145/// advisory observability (trends, rough ratios), not a transactionally-consistent
146/// point-in-time view of both caches.
147#[derive(Debug, Clone, Default)]
148pub struct MemoryStats {
149 /// Block cache hits (real B1 cache hit count when wired).
150 pub block_cache_hits: u64,
151
152 /// Block cache misses (real B1 cache miss count when wired).
153 pub block_cache_misses: u64,
154
155 /// Block cache evictions: entries the B1 decompressed-chunk cache evicted to
156 /// stay within its byte budget (issue #1571, B5). Real `eviction_count()`
157 /// when wired; `0` (honest — no cache, no evictions) otherwise. High churn
158 /// relative to hits signals an undersized `block_cache_capacity_bytes`.
159 pub block_cache_evictions: u64,
160
161 /// Block cache configured byte budget (issue #1571, B5): the B1 cache's
162 /// `budget_bytes()` when wired, so a hit rate can be read against the budget
163 /// it was measured under; `0` when no cache is wired / block caching disabled.
164 pub block_cache_capacity_bytes: usize,
165
166 /// Key cache hits: aggregate across the per-reader B4 key→partition-offset
167 /// caches (issue #1571, B5). A hit lets a repeated point read skip the
168 /// `Index.db`/trie descent. Real summed counter; `0` when no reader is open.
169 pub key_cache_hits: u64,
170
171 /// Key cache misses: aggregate across the per-reader B4 caches (issue #1571).
172 pub key_cache_misses: u64,
173
174 /// Key cache evictions: aggregate across the per-reader B4 caches (issue
175 /// #1571) — entries evicted to stay within each reader's byte budget.
176 pub key_cache_evictions: u64,
177
178 /// Key cache resident bytes: aggregate approximate resident footprint of the
179 /// per-reader B4 caches (issue #1571).
180 pub key_cache_resident_bytes: usize,
181
182 /// Key cache capacity bytes: summed configured byte budget across the
183 /// per-reader B4 caches (issue #1571).
184 pub key_cache_capacity_bytes: usize,
185
186 /// Row cache hits. Retained for shape compatibility; the row cache was
187 /// deleted (issue #1568), so this reports `0` (full surface is Epic B / B5).
188 pub row_cache_hits: u64,
189
190 /// Row cache misses. Retained for shape compatibility; reports `0`.
191 pub row_cache_misses: u64,
192
193 /// Total memory used. Now the B1 cache's resident decompressed bytes
194 /// (`resident_bytes()`) when wired (issue #1568).
195 pub total_memory_used: usize,
196
197 /// Buffer pool allocations. Retained for shape compatibility; the buffer
198 /// pool was deleted (issue #1568), so this reports `0`.
199 pub buffer_allocations: u64,
200
201 /// Buffer pool deallocations. Retained for shape compatibility; reports `0`.
202 pub buffer_deallocations: u64,
203}
204
205impl MemoryStats {
206 /// Calculate block cache hit rate.
207 pub fn block_cache_hit_rate(&self) -> f64 {
208 let total = self.block_cache_hits + self.block_cache_misses;
209 if total > 0 {
210 self.block_cache_hits as f64 / total as f64
211 } else {
212 0.0
213 }
214 }
215
216 /// Calculate the aggregate key-cache hit rate (issue #1571, B5) from the real
217 /// summed hit and miss counts. Returns `0.0` when there has been no key-cache
218 /// activity (honest — not a structural pin).
219 pub fn key_cache_hit_rate(&self) -> f64 {
220 let total = self.key_cache_hits + self.key_cache_misses;
221 if total > 0 {
222 self.key_cache_hits as f64 / total as f64
223 } else {
224 0.0
225 }
226 }
227
228 /// Calculate row cache hit rate.
229 pub fn row_cache_hit_rate(&self) -> f64 {
230 let total = self.row_cache_hits + self.row_cache_misses;
231 if total > 0 {
232 self.row_cache_hits as f64 / total as f64
233 } else {
234 0.0
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::storage::cache::{ChunkKey, DecompressedChunkCache};
243
244 #[test]
245 fn stats_report_zero_without_a_wired_cache() {
246 let config = Config::default();
247 let manager = MemoryManager::new(&config).expect("construct manager");
248
249 let stats = manager.stats().expect("stats");
250 assert_eq!(stats.block_cache_hits, 0);
251 assert_eq!(stats.block_cache_misses, 0);
252 assert_eq!(stats.total_memory_used, 0);
253 assert_eq!(stats.block_cache_hit_rate(), 0.0);
254 // Issue #1571 (B5): every new cache-observability field reports an honest
255 // zero when no cache is wired — never a fabricated placeholder.
256 assert_eq!(stats.block_cache_evictions, 0);
257 assert_eq!(stats.block_cache_capacity_bytes, 0);
258 assert_eq!(stats.key_cache_hits, 0);
259 assert_eq!(stats.key_cache_misses, 0);
260 assert_eq!(stats.key_cache_evictions, 0);
261 assert_eq!(stats.key_cache_resident_bytes, 0);
262 assert_eq!(stats.key_cache_capacity_bytes, 0);
263 assert_eq!(stats.key_cache_hit_rate(), 0.0);
264 }
265
266 #[test]
267 fn stats_bridge_reports_real_b1_cache_numbers() {
268 // A wired manager reports the live B1 cache's hits/misses/occupancy.
269 let cache = Arc::new(DecompressedChunkCache::with_budget_bytes(1 << 20));
270 let manager = MemoryManager::with_chunk_cache(Arc::clone(&cache));
271
272 let key = ChunkKey::new(1, 0);
273 cache.insert(key, vec![0xAB; 4096]); // resident bytes now non-zero
274 assert!(cache.get(&key).is_some()); // one hit
275 assert!(cache.get(&ChunkKey::new(1, 1)).is_none()); // one miss
276
277 let stats = manager.stats().expect("stats");
278 assert_eq!(stats.block_cache_hits, 1);
279 assert_eq!(stats.block_cache_misses, 1);
280 assert_eq!(stats.total_memory_used, cache.resident_bytes());
281 assert!(
282 stats.total_memory_used > 0,
283 "occupancy tracks resident bytes"
284 );
285 assert!(
286 stats.block_cache_hit_rate() > 0.0,
287 "a hit makes the reported rate non-zero (not structural 0.0)"
288 );
289 // Issue #1571 (B5): capacity is the real budget; no eviction yet.
290 assert_eq!(stats.block_cache_capacity_bytes, cache.budget_bytes());
291 assert_eq!(stats.block_cache_evictions, 0);
292 }
293
294 #[test]
295 fn stats_bridge_reports_real_b1_eviction_count() {
296 // Issue #1571 (B5): a wired manager surfaces the chunk cache's real
297 // eviction count. Tiny budget on a single shard forces deterministic
298 // evictions.
299 let cache = Arc::new(DecompressedChunkCache::with_budget_and_shards(200, 1));
300 let manager = MemoryManager::with_chunk_cache(Arc::clone(&cache));
301 for i in 0..5u64 {
302 cache.insert(ChunkKey::new(1, i), vec![i as u8; 100]);
303 }
304 let stats = manager.stats().expect("stats");
305 assert_eq!(stats.block_cache_evictions, cache.eviction_count());
306 assert!(
307 stats.block_cache_evictions > 0,
308 "over-budget inserts must have evicted"
309 );
310 assert_eq!(stats.block_cache_capacity_bytes, cache.budget_bytes());
311 }
312}