Skip to main content

kevy_embedded/
info.rs

1//! Introspection on a live [`Store`] — the embedded-mode answer to Redis's
2//! `INFO` / `DBSIZE` / `TTL` / expire-set diagnostics. In-process mode has no
3//! TCP endpoint to point `redis-cli` at, so these expose the same signals as
4//! plain method calls on the `Store` handle.
5
6use std::time::Duration;
7
8use crate::store::Store;
9
10/// Snapshot of a store's runtime counters, returned by [`Store::info`]. A
11/// cheap aggregate (one mutex lock); fields mirror the individual accessors.
12///
13/// ```
14/// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
15/// s.set(b"k", b"v")?;
16/// let info = s.info();
17/// assert_eq!(info.keys, 1);
18/// assert!(info.tiering.is_none());
19/// # Ok::<(), kevy_embedded::KevyError>(())
20/// ```
21#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub struct KevyInfo {
24    /// Live key count (`DBSIZE`).
25    ///
26    /// ```
27    /// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
28    /// s.set(b"a", b"1")?;
29    /// s.set(b"b", b"2")?;
30    /// assert_eq!(s.info().keys, 2);
31    /// # Ok::<(), kevy_embedded::KevyError>(())
32    /// ```
33    pub keys: usize,
34    /// Estimated resident bytes (`INFO memory: used_memory`).
35    ///
36    /// ```
37    /// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
38    /// let before = s.info().used_memory;
39    /// s.set(b"blob", &[0u8; 64 * 1024])?;
40    /// assert!(s.info().used_memory >= before + 64 * 1024);
41    /// # Ok::<(), kevy_embedded::KevyError>(())
42    /// ```
43    pub used_memory: u64,
44    /// Current on-disk AOF size in bytes (0 when persistence is off).
45    ///
46    /// ```
47    /// use kevy_embedded::{AppendFsync, Config, Store};
48    ///
49    /// assert_eq!(Store::open(Config::default())?.info().aof_bytes, 0); // in-memory
50    /// let dir = kevy_tmpdir::TmpDir::new("info-aof-bytes");
51    /// let cfg = Config::default().with_persist(dir.path()).with_appendfsync(AppendFsync::Always);
52    /// let s = Store::open(cfg)?;
53    /// s.set(b"k", b"v")?;
54    /// assert!(s.info().aof_bytes > 0);
55    /// # Ok::<(), kevy_embedded::KevyError>(())
56    /// ```
57    pub aof_bytes: u64,
58    /// Live keys carrying a TTL — the expire-set size. A `0` here when you
59    /// expected TTLs is the tell that the TTL subsystem didn't register them.
60    ///
61    /// ```
62    /// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
63    /// s.set(b"session", b"x")?;
64    /// s.set(b"forever", b"y")?;
65    /// s.expire(b"session", std::time::Duration::from_secs(60))?;
66    /// assert_eq!(s.info().expire_pending, 1);
67    /// # Ok::<(), kevy_embedded::KevyError>(())
68    /// ```
69    pub expire_pending: usize,
70    /// Total keys evicted by `maxmemory` so far.
71    ///
72    /// ```
73    /// use kevy_embedded::{Config, EvictionPolicy, Store};
74    ///
75    /// let cfg = Config::default()
76    ///     .with_max_memory(256 << 10)
77    ///     .with_eviction(EvictionPolicy::AllKeysLru);
78    /// let s = Store::open(cfg)?;
79    /// assert_eq!(s.info().evictions, 0);
80    /// for i in 0..2000 {
81    ///     s.set(format!("k{i}").as_bytes(), &[0u8; 512])?;
82    /// }
83    /// assert!(s.info().evictions > 0);
84    /// # Ok::<(), kevy_embedded::KevyError>(())
85    /// ```
86    pub evictions: u64,
87    /// Total keys expired (lazy + active reaper) so far.
88    ///
89    /// ```
90    /// use std::time::Duration;
91    /// use kevy_embedded::{Config, Store};
92    ///
93    /// let s = Store::open(Config::default().with_ttl_reaper_manual())?;
94    /// s.set_with_ttl(b"k", b"v", Duration::from_millis(1))?;
95    /// std::thread::sleep(Duration::from_millis(20));
96    /// assert_eq!(s.get(b"k")?, None); // reads as gone, but a read reclaims nothing
97    /// assert_eq!(s.info().expired_keys, 0);
98    /// // the active reaper samples, so a few ticks catch the one expired key
99    /// for _ in 0..50 { s.tick(); }
100    /// assert_eq!(s.info().expired_keys, 1);
101    /// # Ok::<(), kevy_embedded::KevyError>(())
102    /// ```
103    pub expired_keys: u64,
104    /// Tiering gauges (the `# Tiering` INFO section). `None` when
105    /// tiering is off — the untiered snapshot is unchanged.
106    ///
107    /// ```
108    /// use kevy_embedded::{Config, Store};
109    ///
110    /// assert!(Store::open(Config::default())?.info().tiering.is_none());
111    /// let dir = kevy_tmpdir::TmpDir::new("info-tiering");
112    /// let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
113    /// assert_eq!(s.info().tiering.map(|t| t.tier_budget_bytes), Some(1 << 20));
114    /// # Ok::<(), kevy_embedded::KevyError>(())
115    /// ```
116    pub tiering: Option<KevyTierInfo>,
117}
118
119/// The `# Tiering` gauge set (B12) — summed across shards; field names
120/// mirror the server's `INFO # Tiering` section one-to-one.
121///
122/// ```
123/// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
124/// assert!(s.info().tiering.is_none()); // tiering off: no gauges
125/// # Ok::<(), kevy_embedded::KevyError>(())
126/// ```
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
128#[non_exhaustive]
129pub struct KevyTierInfo {
130    /// The resolved RAM budget (whole store — Σ per-shard slices).
131    ///
132    /// ```
133    /// # use kevy_embedded::{Config, Store};
134    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
135    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
136    /// # s.set(b"cold", &[b'x'; 4096])?;
137    /// # assert!(s.debug_force_demote(b"cold"));
138    /// let t = s.info().tiering.unwrap();
139    /// assert_eq!(t.tier_budget_bytes, 1 << 20); // the budget the config named
140    /// # Ok::<(), kevy_embedded::KevyError>(())
141    /// ```
142    pub tier_budget_bytes: u64,
143    /// The demote target `used_memory` is held to (`budget·19/20 − index
144    /// floor`, saturating). **0 = the index floor alone exceeds the budget.**
145    ///
146    /// ```
147    /// # use kevy_embedded::{Config, Store};
148    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
149    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
150    /// # s.set(b"cold", &[b'x'; 4096])?;
151    /// # assert!(s.debug_force_demote(b"cold"));
152    /// let t = s.info().tiering.unwrap();
153    /// // a cold key is charged once, inside used_memory: the target stays put
154    /// assert_eq!(t.tier_effective_target, (1 << 20) * 19 / 20);
155    /// # Ok::<(), kevy_embedded::KevyError>(())
156    /// ```
157    pub tier_effective_target: u64,
158    /// Currently-cold keys.
159    ///
160    /// ```
161    /// # use kevy_embedded::{Config, Store};
162    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
163    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
164    /// # s.set(b"cold", &[b'x'; 4096])?;
165    /// # assert!(s.debug_force_demote(b"cold"));
166    /// assert_eq!(s.info().tiering.unwrap().cold_keys, 1);
167    /// s.get(b"cold")?; // the first read serves from disk and leaves it cold
168    /// s.get(b"cold")?; // the second promotes it back to RAM
169    /// assert_eq!(s.info().tiering.unwrap().cold_keys, 0);
170    /// # Ok::<(), kevy_embedded::KevyError>(())
171    /// ```
172    pub cold_keys: u64,
173    /// Σ original weights of currently-cold values.
174    ///
175    /// ```
176    /// # use kevy_embedded::{Config, Store};
177    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
178    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
179    /// # s.set(b"cold", &[b'x'; 4096])?;
180    /// # assert!(s.debug_force_demote(b"cold"));
181    /// let t = s.info().tiering.unwrap();
182    /// assert!(t.cold_bytes >= 4096); // the value's original weight, not its disk size
183    /// # Ok::<(), kevy_embedded::KevyError>(())
184    /// ```
185    pub cold_bytes: u64,
186    /// RAM the cold stubs cost (Σ `ENTRY_OVERHEAD + key heap bytes`).
187    ///
188    /// ```
189    /// # use kevy_embedded::{Config, Store};
190    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
191    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
192    /// # s.set(b"cold", &[b'x'; 4096])?;
193    /// # assert!(s.debug_force_demote(b"cold"));
194    /// let t = s.info().tiering.unwrap();
195    /// assert!(t.stub_bytes > 0 && t.stub_bytes < 4096); // the stub left in RAM is small
196    /// # Ok::<(), kevy_embedded::KevyError>(())
197    /// ```
198    pub stub_bytes: u64,
199    /// The index/view memory floor fed on the reaper tick.
200    ///
201    /// ```
202    /// # use kevy_embedded::{Config, Store};
203    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
204    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
205    /// # s.set(b"cold", &[b'x'; 4096])?;
206    /// # assert!(s.debug_force_demote(b"cold"));
207    /// assert_eq!(s.info().tiering.unwrap().index_reserved_bytes, 0); // no indexes yet
208    /// s.hset(b"row:1", &[(b"n", b"1")])?;
209    /// use kevy_embedded::{IndexKind, IndexValType};
210    /// s.idx_create(b"by_n", b"row:", b"n", IndexValType::I64, IndexKind::Range)?;
211    /// s.tick(); // the tick feeds the index floor
212    /// assert!(s.info().tiering.unwrap().index_reserved_bytes > 0);
213    /// # Ok::<(), kevy_embedded::KevyError>(())
214    /// ```
215    pub index_reserved_bytes: u64,
216    /// Vlog bytes on disk.
217    ///
218    /// ```
219    /// # use kevy_embedded::{Config, Store};
220    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
221    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
222    /// # s.set(b"cold", &[b'x'; 4096])?;
223    /// # assert!(s.debug_force_demote(b"cold"));
224    /// let t = s.info().tiering.unwrap();
225    /// assert!(t.vlog_size_bytes > 0 && t.vlog_size_bytes < 4096); // a run of one byte compresses
226    /// # Ok::<(), kevy_embedded::KevyError>(())
227    /// ```
228    pub vlog_size_bytes: u64,
229    /// Vlog live (non-dead) bytes.
230    ///
231    /// ```
232    /// # use kevy_embedded::{Config, Store};
233    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
234    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
235    /// # s.set(b"cold", &[b'x'; 4096])?;
236    /// # assert!(s.debug_force_demote(b"cold"));
237    /// let t = s.info().tiering.unwrap();
238    /// assert_eq!(t.vlog_live_bytes, t.vlog_size_bytes); // nothing has died yet
239    /// # Ok::<(), kevy_embedded::KevyError>(())
240    /// ```
241    pub vlog_live_bytes: u64,
242    /// Vlog file count.
243    ///
244    /// ```
245    /// # use kevy_embedded::{Config, Store};
246    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
247    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
248    /// # s.set(b"cold", &[b'x'; 4096])?;
249    /// # assert!(s.debug_force_demote(b"cold"));
250    /// assert_eq!(s.info().tiering.unwrap().vlog_files, 1);
251    /// # Ok::<(), kevy_embedded::KevyError>(())
252    /// ```
253    pub vlog_files: u64,
254    /// Vlog compaction epoch (retired files).
255    ///
256    /// ```
257    /// # use kevy_embedded::{Config, Store};
258    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
259    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
260    /// # s.set(b"cold", &[b'x'; 4096])?;
261    /// # assert!(s.debug_force_demote(b"cold"));
262    /// assert_eq!(s.info().tiering.unwrap().vlog_epoch, 0); // no file retired yet
263    /// # Ok::<(), kevy_embedded::KevyError>(())
264    /// ```
265    pub vlog_epoch: u64,
266    /// Keys demoted since boot.
267    ///
268    /// ```
269    /// # use kevy_embedded::{Config, Store};
270    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
271    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
272    /// # s.set(b"cold", &[b'x'; 4096])?;
273    /// # assert!(s.debug_force_demote(b"cold"));
274    /// assert_eq!(s.info().tiering.unwrap().demotions_total, 1);
275    /// # Ok::<(), kevy_embedded::KevyError>(())
276    /// ```
277    pub demotions_total: u64,
278    /// Keys promoted back since boot.
279    ///
280    /// ```
281    /// # use kevy_embedded::{Config, Store};
282    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
283    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
284    /// # s.set(b"cold", &[b'x'; 4096])?;
285    /// # assert!(s.debug_force_demote(b"cold"));
286    /// assert_eq!(s.info().tiering.unwrap().promotions_total, 0);
287    /// assert_eq!(s.get(b"cold")?.map(|v| v.len()), Some(4096));
288    /// assert_eq!(s.info().tiering.unwrap().promotions_total, 0, "one read is not enough");
289    /// s.get(b"cold")?;
290    /// assert_eq!(s.info().tiering.unwrap().promotions_total, 1);
291    /// # Ok::<(), kevy_embedded::KevyError>(())
292    /// ```
293    pub promotions_total: u64,
294    /// No-promote peek record reads — one per cold row swept by
295    /// hydration / backfill / digest / export.
296    ///
297    /// ```
298    /// use kevy_embedded::{Config, IndexKind, IndexValType, Store};
299    ///
300    /// let dir = kevy_tmpdir::TmpDir::new("info-peek");
301    /// let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
302    /// let pad = [b'x'; 4096];
303    /// s.hset(b"row:1", &[(&b"n"[..], &b"7"[..]), (&b"pad"[..], &pad[..])])?;
304    /// assert!(s.debug_force_demote(b"row:1"));
305    /// // the backfill reads the cold row without promoting it
306    /// s.idx_create(b"by_n", b"row:", b"n", IndexValType::I64, IndexKind::Range)?;
307    /// let t = s.info().tiering.unwrap();
308    /// assert_eq!((t.peek_preads_total, t.cold_keys), (1, 1));
309    /// # Ok::<(), kevy_embedded::KevyError>(())
310    /// ```
311    pub peek_preads_total: u64,
312    /// Batched cold-read submissions — one per peeked page.
313    ///
314    /// ```
315    /// # use kevy_embedded::{Config, Store};
316    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
317    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
318    /// # s.set(b"cold", &[b'x'; 4096])?;
319    /// # assert!(s.debug_force_demote(b"cold"));
320    /// // the embedded store peeks cold rows one at a time; batched pages are
321    /// // the server's hydration path
322    /// assert_eq!(s.info().tiering.unwrap().batch_submissions_total, 0);
323    /// # Ok::<(), kevy_embedded::KevyError>(())
324    /// ```
325    pub batch_submissions_total: u64,
326}
327
328/// The value log's compression accounting, summed across shards; field
329/// names mirror the last four lines of the server's `INFO # Tiering`.
330/// Payload plus frame headers against raw bytes is the ratio; the
331/// dictionaries are memory, one per vlog file.
332/// # Examples
333///
334/// ```
335/// use kevy_embedded::{Config, Store};
336/// // untiered: the section does not exist, so neither do its terms
337/// let s = Store::open(Config::default()).unwrap();
338/// assert!(s.tier_compression().is_none());
339/// ```
340#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
341#[non_exhaustive]
342pub struct KevyTierCompression {
343    /// Value bytes before encoding, over the vlog files on disk.
344    ///
345    /// ```
346    /// # use kevy_embedded::{Config, Store};
347    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
348    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
349    /// # s.set(b"cold", &[b'x'; 4096])?;
350    /// # assert!(s.debug_force_demote(b"cold"));
351    /// assert_eq!(s.tier_compression().unwrap().vlog_raw_bytes, 4096);
352    /// # Ok::<(), kevy_embedded::KevyError>(())
353    /// ```
354    pub vlog_raw_bytes: u64,
355    /// Encoded payload bytes on disk, frame headers excluded.
356    ///
357    /// ```
358    /// # use kevy_embedded::{Config, Store};
359    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
360    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
361    /// # s.set(b"cold", &[b'x'; 4096])?;
362    /// # assert!(s.debug_force_demote(b"cold"));
363    /// let c = s.tier_compression().unwrap();
364    /// assert!(c.vlog_payload_bytes < c.vlog_raw_bytes); // compressed
365    /// # Ok::<(), kevy_embedded::KevyError>(())
366    /// ```
367    pub vlog_payload_bytes: u64,
368    /// Frame header bytes on disk (tag + LEB128 length per record).
369    ///
370    /// ```
371    /// # use kevy_embedded::{Config, Store};
372    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
373    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
374    /// # s.set(b"cold", &[b'x'; 4096])?;
375    /// # assert!(s.debug_force_demote(b"cold"));
376    /// // a tag byte, and 4096 as two LEB128 bytes
377    /// assert_eq!(s.tier_compression().unwrap().vlog_frame_header_bytes, 3);
378    /// # Ok::<(), kevy_embedded::KevyError>(())
379    /// ```
380    pub vlog_frame_header_bytes: u64,
381    /// Dictionary bytes in memory.
382    ///
383    /// ```
384    /// # use kevy_embedded::{Config, Store};
385    /// # let dir = kevy_tmpdir::TmpDir::new("tier-info-doc");
386    /// # let s = Store::open(Config::default().with_persist(dir.path()).with_tier_budget(1 << 20))?;
387    /// # s.set(b"cold", &[b'x'; 4096])?;
388    /// # assert!(s.debug_force_demote(b"cold"));
389    /// // the first file has nothing to train a dictionary on
390    /// assert_eq!(s.tier_compression().unwrap().vlog_dict_bytes, 0);
391    /// # Ok::<(), kevy_embedded::KevyError>(())
392    /// ```
393    pub vlog_dict_bytes: u64,
394}
395
396impl Store {
397    /// One-shot snapshot of the store's introspection counters. See
398    /// [`KevyInfo`]. Takes the embedded mutex once; safe to call from a
399    /// health endpoint.
400    pub fn info(&self) -> KevyInfo {
401        KevyInfo {
402            keys: self.sum_shards(|i| i.store.dbsize()),
403            used_memory: self.sum_shards_u64(|i| i.store.used_memory()),
404            #[cfg(feature = "persist")]
405            aof_bytes: self
406                .sum_shards_u64(|i| i.aof.as_ref().map_or(0, kevy_persist::Aof::size_bytes)),
407            #[cfg(not(feature = "persist"))]
408            aof_bytes: 0,
409            expire_pending: self.sum_shards(|i| i.store.ttl_pending_count()),
410            evictions: self.sum_shards_u64(|i| i.store.evictions_total()),
411            expired_keys: self.sum_shards_u64(|i| i.store.expired_keys_total()),
412            tiering: self.tier_info(),
413        }
414    }
415
416    /// The `# Tiering` gauges summed across shards, or `None` when
417    /// tiering is off (the section is absent, not zeroed — INFO
418    /// stability for untiered stores).
419    #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
420    pub fn tier_info(&self) -> Option<KevyTierInfo> {
421        self.config.tier_budget?;
422        let mut t = KevyTierInfo::default();
423        for shard in self.shards.iter() {
424            let g = crate::store::lock_read(shard);
425            let s = g.store.tier_stats();
426            t.tier_budget_bytes += s.budget;
427            t.tier_effective_target += s.effective_target;
428            t.cold_keys += s.cold_keys;
429            t.cold_bytes += s.cold_bytes;
430            t.stub_bytes += s.stub_bytes;
431            t.index_reserved_bytes += s.reserved_bytes;
432            t.vlog_size_bytes += s.vlog_bytes;
433            t.vlog_live_bytes += s.vlog_live_bytes;
434            t.vlog_files += s.vlog_files;
435            t.vlog_epoch += s.vlog_epoch;
436            t.demotions_total += s.demotions_total;
437            t.promotions_total += s.promotions_total;
438            t.peek_preads_total += s.peek_preads_total;
439            t.batch_submissions_total += s.batch_submissions_total;
440        }
441        Some(t)
442    }
443
444    /// No tier backend compiled in — never a section.
445    #[cfg(not(all(feature = "tier", not(target_arch = "wasm32"))))]
446    pub fn tier_info(&self) -> Option<KevyTierInfo> {
447        None
448    }
449
450    /// The value log's compression accounting summed across shards, or
451    /// `None` when tiering is off. See [`KevyTierCompression`].
452    /// # Examples
453    ///
454    /// ```
455    /// use kevy_embedded::{Config, Store};
456    /// let dir = kevy_tmpdir::TmpDir::new("tier-compression-doc");
457    /// let cfg = Config::default().with_persist(dir.path()).with_tier_budget(1 << 20);
458    /// let s = Store::open(cfg).unwrap();
459    /// s.set(b"cold", &[b'x'; 4096]).unwrap();
460    /// assert!(s.debug_force_demote(b"cold"));
461    /// let c = s.tier_compression().unwrap();
462    /// assert_eq!(c.vlog_raw_bytes, 4096);
463    /// // a run of one byte keeps a small fraction of itself
464    /// assert!(c.vlog_payload_bytes < 4096);
465    /// ```
466    #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
467    pub fn tier_compression(&self) -> Option<KevyTierCompression> {
468        self.config.tier_budget?;
469        let mut t = KevyTierCompression::default();
470        for shard in self.shards.iter() {
471            let c = crate::store::lock_read(shard).store.tier_compression();
472            t.vlog_raw_bytes += c.raw_bytes;
473            t.vlog_payload_bytes += c.payload_bytes;
474            t.vlog_frame_header_bytes += c.frame_header_bytes;
475            t.vlog_dict_bytes += c.dict_bytes;
476        }
477        Some(t)
478    }
479
480    /// No tier backend compiled in — never a section.
481    #[cfg(not(all(feature = "tier", not(target_arch = "wasm32"))))]
482    pub fn tier_compression(&self) -> Option<KevyTierCompression> {
483        None
484    }
485
486    /// Number of live keys that currently carry a TTL (the expire-set size,
487    /// summed across shards).
488    pub fn expire_pending_count(&self) -> usize {
489        self.sum_shards(|i| i.store.ttl_pending_count())
490    }
491
492    /// Remaining TTL for `key` as a [`Duration`], or `None` when the key is
493    /// absent or has no TTL (persistent). For the raw Redis `PTTL` sentinels
494    /// (`-2` no key, `-1` no TTL) use [`Store::ttl_ms`].
495    pub fn ttl(&self, key: &[u8]) -> Option<Duration> {
496        let ms = self.wshard(key).store.pttl(key);
497        if ms < 0 { None } else { Some(Duration::from_millis(ms as u64)) }
498    }
499}