Skip to main content

wombatkv_node/
lru.rs

1#![forbid(unsafe_code)]
2//! Per-namespace LRU eviction worker (RFC 0009).
3//!
4//! Production-safety story: `WombatKV`'s block storage in S3 grows
5//! unboundedly without a budget cap. This module periodically scans
6//! the in-memory `MetadataIndex`, sums `payload_bytes` per namespace,
7//! and when the sum exceeds the configured byte budget, evicts the
8//! oldest entries (by `last_access_ns`) until the namespace fits
9//! inside the budget with a 10% headroom.
10//!
11//! ## Env vars
12//!
13//! - `WMBT_KV_NAMESPACE_MAX_BYTES=<N>`, per-namespace byte budget.
14//!   `0` (default) disables eviction entirely (safe default; existing
15//!   deployments see no behavior change). `100 GB` is the suggested
16//!   production setting; a single ds4 model footprint fits in well
17//!   under that.
18//! - `WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS=<N>`, cycle interval, default 30 s.
19//!
20//! ## Race safety
21//!
22//! The worker uses a compare-and-delete pattern against
23//! `InMemoryMetadataIndex::remove_if_unchanged`: each eviction
24//! candidate carries the `last_access_ns` snapshot taken at scoring
25//! time. If a concurrent `get_and_touch` raced the worker between
26//! snapshot and delete, the CAS fails and the worker silently skips
27//! that entry this cycle (revisits next pass). This avoids holding a
28//! cross-namespace `tokio::sync::Mutex` on the hot path: the eviction
29//! cost is paid only by the worker thread, the request path pays
30//! one extra `BlockMeta` comparison.
31//!
32//! The CAS-failure path is observable in the per-cycle event as
33//! `skipped_changed`. A persistently high count indicates either (a)
34//! the namespace is genuinely hotter than the budget allows (raise
35//! the budget) or (b) the eviction interval is too long and many
36//! blocks are getting touched between scan and delete (shrink the
37//! interval).
38//!
39//! ## What gets deleted, by tier
40//!
41//! Per evicted entry the worker:
42//! 1. Removes from `InMemoryMetadataIndex` (CAS as above).
43//! 2. Removes from `SlateDbMetadataIndex` (if opened by the caller).
44//!    Best-effort: a `SlateDB` delete failure logs+continues; the L0
45//!    state is already consistent.
46//! 3. Calls `EvictionDeleter::delete_block(namespace, key)` which
47//!    routes through `WombatKVKvStore::delete_kv`: the object store
48//!    delete + flat-tier unlink. Foyer is intentionally left to age
49//!    out naturally (`foyer::HybridCache` does not expose a public
50//!    single-key remove in our pinned version; the metadata index is
51//!    the authoritative budget so this is correctness-safe).
52
53use std::sync::atomic::{AtomicBool, Ordering};
54use std::sync::Arc;
55use std::thread::JoinHandle;
56use std::time::{Duration, Instant};
57
58use wombatkv_radix::{BlockHash, BlockMeta, InMemoryMetadataIndex, SlateDbMetadataIndex};
59
60/// Headroom fraction kept below the budget after a cycle: an
61/// over-budget namespace shrinks to `budget * (1 - HEADROOM_FRAC)`,
62/// not exactly to `budget`, so the worker does not have to run every
63/// cycle on a steady-state workload near the cap.
64const HEADROOM_FRAC: f64 = 0.10;
65
66/// Tuning knobs for the LRU eviction worker.
67#[derive(Clone, Debug)]
68pub struct LruConfig {
69    /// Per-namespace byte budget. `0` disables the worker entirely.
70    pub namespace_max_bytes: u64,
71    /// Sleep between scoring cycles.
72    pub interval: Duration,
73    /// Namespace the worker scans. (Each handle owns one namespace
74    /// today; if `WombatKV` later supports multi-namespace handles, this
75    /// can fan out per handle.)
76    pub namespace: String,
77}
78
79impl Default for LruConfig {
80    fn default() -> Self {
81        Self { namespace_max_bytes: 0, interval: Duration::from_secs(30), namespace: String::new() }
82    }
83}
84
85impl LruConfig {
86    /// Read the env knobs and build a config. Returns `None` when
87    /// `WMBT_KV_NAMESPACE_MAX_BYTES` is absent, zero, or unparseable -
88    /// that is the off-by-default safe state.
89    #[must_use]
90    pub fn from_env(namespace: impl Into<String>) -> Option<Self> {
91        let max_bytes: u64 = std::env::var("WMBT_KV_NAMESPACE_MAX_BYTES").ok()?.parse().ok()?;
92        if max_bytes == 0 {
93            return None;
94        }
95        let interval_secs: u64 = std::env::var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS")
96            .ok()
97            .and_then(|s| s.parse().ok())
98            .unwrap_or(30);
99        Some(Self {
100            namespace_max_bytes: max_bytes,
101            interval: Duration::from_secs(interval_secs.max(1)),
102            namespace: namespace.into(),
103        })
104    }
105}
106
107/// Per-cycle outcome counts. Surfaced via the `[MyelonInstr]` event
108/// emitted by [`default_emit`].
109#[derive(Clone, Debug, Default)]
110pub struct EvictionCycleOutcome {
111    pub scanned: usize,
112    pub total_bytes_before: u64,
113    pub over_budget: bool,
114    pub blocks_freed: usize,
115    pub bytes_freed: u64,
116    pub skipped_changed: usize,
117    pub delete_failures: usize,
118    pub cycle_ms: u128,
119}
120
121/// Sync deleter surface implemented by `WombatKVKvStore<S>` so the
122/// algorithm crate can call delete without depending on the embed
123/// crate's generic `S: ObjectStore` shape. Mirrors the
124/// `PrefetchFetcher` pattern in `block_prefetch.rs`.
125pub trait EvictionDeleter: Send + Sync {
126    /// Delete one block by `(namespace, key)`. Returns true if the
127    /// object store reported a delete; false on miss (already gone).
128    /// Errors propagate as a string for logging, the worker logs and
129    /// continues; one bad delete does not stop the cycle.
130    fn delete_block(&self, namespace: &str, key: &str) -> Result<bool, String>;
131
132    /// Resolve a `BlockHash` to the canonical object-store key. The
133    /// worker uses this to build the delete call; mirrors
134    /// `wombatkv_node::block_prefetch::block_key_for_hash` so both
135    /// modules agree on the path layout.
136    fn block_key_for_hash(&self, hash: &BlockHash) -> String {
137        crate::block_prefetch::block_key_for_hash(hash)
138    }
139}
140
141/// Owns a background thread that runs the eviction cycle.
142///
143/// Dropping the worker signals stop and joins the thread. The join
144/// runs in `Drop`, so the worker is guaranteed not to outlive its
145/// owner.
146pub struct LruEvictionWorker {
147    handle: Option<JoinHandle<()>>,
148    stop: Arc<AtomicBool>,
149}
150
151impl LruEvictionWorker {
152    /// Request shutdown without joining. Calling `drop` afterwards
153    /// will still join.
154    pub fn signal_stop(&self) {
155        self.stop.store(true, Ordering::SeqCst);
156    }
157
158    /// Returns true while the worker thread has not yet exited.
159    #[must_use]
160    pub fn is_running(&self) -> bool {
161        self.handle.as_ref().is_some_and(|h| !h.is_finished())
162    }
163}
164
165impl Drop for LruEvictionWorker {
166    fn drop(&mut self) {
167        self.stop.store(true, Ordering::SeqCst);
168        if let Some(h) = self.handle.take() {
169            let _ = h.join();
170        }
171    }
172}
173
174/// Closure-based callback for the per-cycle outcome event.
175pub type EvictionEmit = Arc<dyn Fn(&EvictionCycleOutcome) + Send + Sync>;
176
177/// Default emit: a `[MyelonInstr]` JSON line on stderr per cycle.
178#[must_use]
179pub fn default_emit(namespace: String) -> EvictionEmit {
180    Arc::new(move |o: &EvictionCycleOutcome| {
181        eprintln!(
182            "[MyelonInstr] {{\"scope\":\"wmbt_kv_eviction\",\"fn\":\"eviction_cycle\",\
183             \"namespace\":\"{}\",\"stages\":{{\"scanned\":{},\"total_bytes_before\":{},\
184             \"over_budget\":{},\"blocks_freed\":{},\"bytes_freed\":{},\
185             \"skipped_changed\":{},\"delete_failures\":{},\"cycle_ms\":{}}}}}",
186            namespace,
187            o.scanned,
188            o.total_bytes_before,
189            o.over_budget,
190            o.blocks_freed,
191            o.bytes_freed,
192            o.skipped_changed,
193            o.delete_failures,
194            o.cycle_ms,
195        );
196    })
197}
198
199/// Spawn the eviction worker. The returned worker holds the join
200/// handle and signals stop on drop. Returns immediately; the loop
201/// runs on the spawned thread.
202pub fn spawn_worker(
203    index: Arc<InMemoryMetadataIndex>,
204    slatedb: Option<Arc<SlateDbMetadataIndex>>,
205    deleter: Arc<dyn EvictionDeleter>,
206    config: LruConfig,
207    emit: EvictionEmit,
208) -> LruEvictionWorker {
209    let stop = Arc::new(AtomicBool::new(false));
210    let stop_for_thread = stop.clone();
211    let handle = std::thread::Builder::new()
212        .name("wombatkv-lru-evict".to_string())
213        .spawn(move || {
214            // Bounded sleep loop: react to stop within ≤ slice (50 ms).
215            let slice = Duration::from_millis(50);
216            loop {
217                if stop_for_thread.load(Ordering::SeqCst) {
218                    break;
219                }
220                let outcome =
221                    run_cycle(index.as_ref(), slatedb.as_deref(), deleter.as_ref(), &config);
222                emit(&outcome);
223
224                let mut remaining = config.interval;
225                while remaining > Duration::ZERO {
226                    if stop_for_thread.load(Ordering::SeqCst) {
227                        break;
228                    }
229                    let s = remaining.min(slice);
230                    std::thread::sleep(s);
231                    remaining = remaining.saturating_sub(s);
232                }
233            }
234        })
235        .expect("spawn lru worker");
236
237    LruEvictionWorker { handle: Some(handle), stop }
238}
239
240/// Run one eviction cycle synchronously. Exposed for unit tests so
241/// the cycle can run inline (no thread orchestration).
242///
243/// Algorithm:
244/// 1. Snapshot `index.entries()` and sum `payload_bytes`.
245/// 2. If sum ≤ budget, no-op. Emit "no-op" outcome and return.
246/// 3. Otherwise sort the snapshot ascending by `last_access_ns`
247///    (oldest first).
248/// 4. Walk the sorted list, calling `remove_if_unchanged` + delete
249///    until `sum_remaining ≤ budget * (1 - HEADROOM_FRAC)`.
250///
251/// Step 3 builds a private `Vec<(hash, meta)>` copy; the index's
252/// internal lock is released as soon as `entries()` returns.
253#[must_use]
254pub fn run_cycle(
255    index: &InMemoryMetadataIndex,
256    slatedb: Option<&SlateDbMetadataIndex>,
257    deleter: &dyn EvictionDeleter,
258    config: &LruConfig,
259) -> EvictionCycleOutcome {
260    use wombatkv_radix::MetadataIndex;
261    let started = Instant::now();
262    let snapshot: Vec<(BlockHash, BlockMeta)> = index.entries();
263    let scanned = snapshot.len();
264    let total_bytes_before: u64 = snapshot.iter().map(|(_, m)| m.payload_bytes).sum();
265    let budget = config.namespace_max_bytes;
266
267    if budget == 0 || total_bytes_before <= budget {
268        return EvictionCycleOutcome {
269            scanned,
270            total_bytes_before,
271            over_budget: false,
272            blocks_freed: 0,
273            bytes_freed: 0,
274            skipped_changed: 0,
275            delete_failures: 0,
276            cycle_ms: started.elapsed().as_millis(),
277        };
278    }
279
280    // Sort by last_access_ns ascending, oldest first.
281    let mut sorted = snapshot;
282    sorted.sort_by_key(|a| a.1.last_access_ns);
283
284    // Target: leave HEADROOM_FRAC empty at the top of the budget so
285    // we don't run the cycle every interval on a steady-state workload.
286    let target = (budget as f64 * (1.0 - HEADROOM_FRAC)) as u64;
287    let need_to_free = total_bytes_before.saturating_sub(target);
288
289    let mut bytes_freed = 0_u64;
290    let mut blocks_freed = 0_usize;
291    let mut skipped_changed = 0_usize;
292    let mut delete_failures = 0_usize;
293
294    for (hash, meta) in sorted {
295        if bytes_freed >= need_to_free {
296            break;
297        }
298        // CAS on the L0 index. If the stamp drifted, a get_and_touch
299        // raced us, skip this block and try the next one.
300        if !index.remove_if_unchanged(&hash, meta.last_access_ns) {
301            skipped_changed += 1;
302            continue;
303        }
304        // L1 SlateDB remove (best-effort). If this fails, the L0 is
305        // already consistent; the next bootstrap_from_slatedb might
306        // re-introduce the entry, but the next eviction cycle will
307        // re-evict it.
308        if let Some(idx) = slatedb {
309            let _ = MetadataIndex::remove(idx, &hash);
310        }
311        // Delete from object store + flat tier.
312        let key = deleter.block_key_for_hash(&hash);
313        match deleter.delete_block(&config.namespace, &key) {
314            Ok(_) => {
315                bytes_freed = bytes_freed.saturating_add(meta.payload_bytes);
316                blocks_freed += 1;
317            }
318            Err(err) => {
319                eprintln!("wombatkv[lru]: delete_block({key}) failed: {err}");
320                delete_failures += 1;
321                // Even on delete failure, we still count the block as
322                // freed from the budget, the metadata-index removal
323                // has already happened. The S3 object is then an
324                // orphan: a future GC pass can clean it up. Acceptable
325                // for production: budget integrity matters more than
326                // a small number of orphaned objects.
327                bytes_freed = bytes_freed.saturating_add(meta.payload_bytes);
328                blocks_freed += 1;
329            }
330        }
331    }
332
333    EvictionCycleOutcome {
334        scanned,
335        total_bytes_before,
336        over_budget: true,
337        blocks_freed,
338        bytes_freed,
339        skipped_changed,
340        delete_failures,
341        cycle_ms: started.elapsed().as_millis(),
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::sync::Mutex;
349    use wombatkv_radix::{BlockMeta, MetadataIndex};
350
351    /// Capture-only deleter for tests. Records (namespace, key) calls
352    /// and reports success.
353    #[derive(Default)]
354    struct CapturingDeleter {
355        deleted: Mutex<Vec<(String, String)>>,
356    }
357
358    impl EvictionDeleter for CapturingDeleter {
359        fn delete_block(&self, namespace: &str, key: &str) -> Result<bool, String> {
360            self.deleted.lock().unwrap().push((namespace.to_string(), key.to_string()));
361            Ok(true)
362        }
363    }
364
365    fn mk_block(seq: u32, payload_bytes: u64, age_ns_offset: u64) -> ([u8; 32], BlockMeta) {
366        let mut hash = [0u8; 32];
367        // Encode seq into the hash so each block has a unique key.
368        hash[..4].copy_from_slice(&seq.to_le_bytes());
369        let mut meta = BlockMeta::new_root(payload_bytes, [0u8; 24], *b"test-v1\0\0\0\0\0\0\0\0\0");
370        // Force a deterministic last_access_ns: smaller seq = older.
371        // (We can't override the constructor's `now_ns()` from outside,
372        // so set the field directly through a mutable copy.)
373        meta.last_access_ns = 1_000_000_000_u64 + age_ns_offset;
374        meta.block_seq = seq;
375        (hash, meta)
376    }
377
378    #[test]
379    fn evicts_oldest_when_over_budget() {
380        // Seed 100 blocks of 1024 bytes each → 102_400 bytes total.
381        // Budget = 50 blocks worth = 51_200 bytes. With 10% headroom
382        // the target is 46_080 bytes, so the worker must evict at
383        // least (102_400 - 46_080) / 1024 = 55 blocks.
384        //
385        // `bulk_load` preserves the `last_access_ns` from `mk_block`
386        // (it uses `entry().or_insert(m)` without calling `.touch()`),
387        // which is what we need for a deterministic LRU test.
388        let index = Arc::new(InMemoryMetadataIndex::new());
389        let mut seeds = Vec::with_capacity(100);
390        for i in 0..100u32 {
391            seeds.push(mk_block(i, 1024, u64::from(i) * 1_000_000));
392        }
393        index.bulk_load(seeds);
394        assert_eq!(index.len(), 100);
395
396        let deleter: Arc<dyn EvictionDeleter> = Arc::new(CapturingDeleter::default());
397        let config = LruConfig {
398            namespace_max_bytes: 50 * 1024,
399            interval: Duration::from_secs(30),
400            namespace: "test-ns".to_string(),
401        };
402
403        let outcome = run_cycle(index.as_ref(), None, deleter.as_ref(), &config);
404
405        assert_eq!(outcome.scanned, 100);
406        assert_eq!(outcome.total_bytes_before, 100 * 1024);
407        assert!(outcome.over_budget);
408        // budget=51_200, target=46_080, need_to_free=56_320, blocks=55.
409        assert_eq!(outcome.blocks_freed, 55);
410        assert_eq!(outcome.bytes_freed, 55 * 1024);
411        assert_eq!(outcome.skipped_changed, 0);
412        assert_eq!(outcome.delete_failures, 0);
413
414        // The 55 oldest blocks (seq 0..55) should be gone; 55..100 remain.
415        assert_eq!(index.len(), 45);
416        for i in 0..55u32 {
417            let (h, _) = mk_block(i, 0, 0);
418            assert!(index.get(&h).is_none(), "expected seq={i} (oldest) to be evicted");
419        }
420        for i in 55..100u32 {
421            let (h, _) = mk_block(i, 0, 0);
422            assert!(index.get(&h).is_some(), "expected seq={i} (newest) to be retained");
423        }
424    }
425
426    #[test]
427    fn no_op_when_under_budget() {
428        let index = Arc::new(InMemoryMetadataIndex::new());
429        let mut seeds = Vec::with_capacity(10);
430        for i in 0..10u32 {
431            seeds.push(mk_block(i, 1024, u64::from(i) * 1_000_000));
432        }
433        index.bulk_load(seeds);
434
435        let deleter: Arc<dyn EvictionDeleter> = Arc::new(CapturingDeleter::default());
436        let config = LruConfig {
437            namespace_max_bytes: 100 * 1024, // way over what we have
438            interval: Duration::from_secs(30),
439            namespace: "test-ns".to_string(),
440        };
441
442        let outcome = run_cycle(index.as_ref(), None, deleter.as_ref(), &config);
443
444        assert_eq!(outcome.scanned, 10);
445        assert_eq!(outcome.total_bytes_before, 10 * 1024);
446        assert!(!outcome.over_budget);
447        assert_eq!(outcome.blocks_freed, 0);
448        assert_eq!(outcome.bytes_freed, 0);
449        assert_eq!(index.len(), 10);
450    }
451
452    #[test]
453    fn cas_skips_concurrently_touched_block() {
454        // Pre-seed 3 blocks; "touch" the oldest between snapshot and
455        // cycle by mutating its stamp directly via re-insert. (In the
456        // real path this is what `get_and_touch` does.)
457        let index = Arc::new(InMemoryMetadataIndex::new());
458        let seeds =
459            vec![mk_block(0, 1024, 0), mk_block(1, 1024, 1_000_000), mk_block(2, 1024, 2_000_000)];
460        index.bulk_load(seeds.clone());
461
462        // We want to demonstrate that if we hold a stale snapshot and
463        // then "race" a touch, the CAS rejects the eviction. Simulate
464        // by manually calling remove_if_unchanged with a stale stamp.
465        let (h0, m0) = seeds[0];
466        // Pretend the stamp drifted: caller passes the old stamp but
467        // the actual entry was touched (we trigger this by inserting
468        // a copy with a different last_access_ns).
469        index.insert(h0, BlockMeta::new_root(1024, [0; 24], *b"test-v1\0\0\0\0\0\0\0\0\0"));
470        // The actual stored stamp is now "now_ns" (set by insert+touch),
471        // which differs from the stale m0.last_access_ns we hold.
472        assert!(!index.remove_if_unchanged(&h0, m0.last_access_ns));
473        assert!(index.get(&h0).is_some());
474
475        // Now force eviction with a tiny budget. The worker will see
476        // h0 with its new (fresh) stamp; h1 and h2 are the oldest now
477        // (their stamps are still 1e6 and 2e6).
478        let deleter: Arc<dyn EvictionDeleter> = Arc::new(CapturingDeleter::default());
479        let config = LruConfig {
480            namespace_max_bytes: 1024, // only room for one block
481            interval: Duration::from_secs(30),
482            namespace: "test-ns".to_string(),
483        };
484        let outcome = run_cycle(index.as_ref(), None, deleter.as_ref(), &config);
485
486        // budget=1024, target=921, total=3072, need=2151 → 3 blocks.
487        // But CAS for h0 sees the fresh stamp (no race in this
488        // synchronous-only run), so all 3 are evicted in order.
489        assert!(outcome.over_budget);
490        assert_eq!(outcome.blocks_freed, 3);
491        assert_eq!(outcome.skipped_changed, 0);
492        assert_eq!(index.len(), 0);
493    }
494
495    #[test]
496    fn concurrent_put_and_evict_does_not_crash() {
497        // Best-effort race test: spawn a producer thread that
498        // continuously inserts blocks, then run one eviction cycle
499        // from the main thread. We verify only that the cycle returns
500        // without panicking AND the index converges to ≤ budget within
501        // a few cycles. (A precise count is intentionally not
502        // asserted, the CAS path will skip blocks whose stamps drift
503        // mid-cycle, and the producer is racing.)
504        use std::sync::atomic::{AtomicBool, Ordering};
505
506        let index = Arc::new(InMemoryMetadataIndex::new());
507        let deleter: Arc<dyn EvictionDeleter> = Arc::new(CapturingDeleter::default());
508        let config = LruConfig {
509            namespace_max_bytes: 10 * 1024, // 10 blocks worth
510            interval: Duration::from_secs(30),
511            namespace: "test-ns".to_string(),
512        };
513
514        // Seed: 200 blocks → well over the 10-block budget.
515        let mut seeds = Vec::with_capacity(200);
516        for i in 0..200u32 {
517            seeds.push(mk_block(i, 1024, u64::from(i) * 1_000_000));
518        }
519        index.bulk_load(seeds);
520
521        // Producer: insert NEW (fresh) blocks while the evictor runs.
522        let stop = Arc::new(AtomicBool::new(false));
523        let stop_for_producer = stop.clone();
524        let index_for_producer = index.clone();
525        let producer = std::thread::spawn(move || {
526            let mut next_seq = 1_000_u32;
527            while !stop_for_producer.load(Ordering::SeqCst) {
528                let (h, m) = mk_block(next_seq, 1024, u64::MAX / 2);
529                index_for_producer.insert(h, m);
530                next_seq += 1;
531            }
532        });
533
534        // Run a few cycles back-to-back so we exercise the CAS skip
535        // path with a high probability of mid-cycle insertions.
536        for _ in 0..3 {
537            let _ = run_cycle(index.as_ref(), None, deleter.as_ref(), &config);
538        }
539
540        stop.store(true, Ordering::SeqCst);
541        producer.join().expect("producer thread");
542
543        // One final cycle with the producer halted; the index must
544        // now satisfy the budget (no live races left).
545        let final_outcome = run_cycle(index.as_ref(), None, deleter.as_ref(), &config);
546
547        let final_bytes: u64 = index.entries().iter().map(|(_, m)| m.payload_bytes).sum();
548        assert!(
549            final_bytes <= config.namespace_max_bytes,
550            "post-eviction bytes {final_bytes} > budget {} (outcome={final_outcome:?})",
551            config.namespace_max_bytes
552        );
553    }
554
555    #[test]
556    fn from_env_returns_none_when_unset() {
557        // Snapshot + restore the env vars so tests don't pollute the
558        // process state.
559        let saved_max = std::env::var("WMBT_KV_NAMESPACE_MAX_BYTES").ok();
560        let saved_int = std::env::var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS").ok();
561        std::env::remove_var("WMBT_KV_NAMESPACE_MAX_BYTES");
562        std::env::remove_var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS");
563        assert!(LruConfig::from_env("any").is_none());
564
565        std::env::set_var("WMBT_KV_NAMESPACE_MAX_BYTES", "0");
566        assert!(LruConfig::from_env("any").is_none());
567
568        std::env::set_var("WMBT_KV_NAMESPACE_MAX_BYTES", "1048576");
569        std::env::set_var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS", "5");
570        let cfg = LruConfig::from_env("ns-a").expect("config");
571        assert_eq!(cfg.namespace_max_bytes, 1_048_576);
572        assert_eq!(cfg.interval, Duration::from_secs(5));
573        assert_eq!(cfg.namespace, "ns-a");
574
575        // Restore.
576        match saved_max {
577            Some(v) => std::env::set_var("WMBT_KV_NAMESPACE_MAX_BYTES", v),
578            None => std::env::remove_var("WMBT_KV_NAMESPACE_MAX_BYTES"),
579        }
580        match saved_int {
581            Some(v) => std::env::set_var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS", v),
582            None => std::env::remove_var("WMBT_KV_DAEMON_EVICTION_INTERVAL_SECS"),
583        }
584    }
585}