1#![forbid(unsafe_code)]
2use 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
60const HEADROOM_FRAC: f64 = 0.10;
65
66#[derive(Clone, Debug)]
68pub struct LruConfig {
69 pub namespace_max_bytes: u64,
71 pub interval: Duration,
73 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 #[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#[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
121pub trait EvictionDeleter: Send + Sync {
126 fn delete_block(&self, namespace: &str, key: &str) -> Result<bool, String>;
131
132 fn block_key_for_hash(&self, hash: &BlockHash) -> String {
137 crate::block_prefetch::block_key_for_hash(hash)
138 }
139}
140
141pub struct LruEvictionWorker {
147 handle: Option<JoinHandle<()>>,
148 stop: Arc<AtomicBool>,
149}
150
151impl LruEvictionWorker {
152 pub fn signal_stop(&self) {
155 self.stop.store(true, Ordering::SeqCst);
156 }
157
158 #[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
174pub type EvictionEmit = Arc<dyn Fn(&EvictionCycleOutcome) + Send + Sync>;
176
177#[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
199pub 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 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#[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 let mut sorted = snapshot;
282 sorted.sort_by_key(|a| a.1.last_access_ns);
283
284 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 if !index.remove_if_unchanged(&hash, meta.last_access_ns) {
301 skipped_changed += 1;
302 continue;
303 }
304 if let Some(idx) = slatedb {
309 let _ = MetadataIndex::remove(idx, &hash);
310 }
311 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 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 #[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 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 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 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 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 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, 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 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 let (h0, m0) = seeds[0];
466 index.insert(h0, BlockMeta::new_root(1024, [0; 24], *b"test-v1\0\0\0\0\0\0\0\0\0"));
470 assert!(!index.remove_if_unchanged(&h0, m0.last_access_ns));
473 assert!(index.get(&h0).is_some());
474
475 let deleter: Arc<dyn EvictionDeleter> = Arc::new(CapturingDeleter::default());
479 let config = LruConfig {
480 namespace_max_bytes: 1024, 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 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 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, interval: Duration::from_secs(30),
511 namespace: "test-ns".to_string(),
512 };
513
514 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 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 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 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 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 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}