diff --git a/src/kventry.rs b/src/kventry.rs index e168605..29dd719 100644 --- a/src/kventry.rs +++ b/src/kventry.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct KvEntry { pub key: String, pub value: V, @@ -32,4 +32,19 @@ mod tests { assert!(item.expired_at > std::time::Instant::now() + std::time::Duration::from_secs(9)); assert!(item.expired_at <= std::time::Instant::now() + std::time::Duration::from_secs(10)); } + + #[test] + fn test_eq() { + let item = KvEntry::new( + "key", + String::from("value"), + std::time::Duration::from_secs(10), + ); + let clone = item.clone(); + assert_eq!(item, clone); + + let mut different = item.clone(); + different.value = String::from("other"); + assert_ne!(item, different); + } } diff --git a/src/lib.rs b/src/lib.rs index e48d2c4..cafe42f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,16 +47,29 @@ impl SparKV { Some(item.value) } + /// Number of physically-present entries. + /// + /// This is an O(1) count that may include expired-but-unswept entries. + /// For the live view use [`get`](Self::get) / [`get_item`](Self::get_item), + /// or call [`clear_expired`](Self::clear_expired) first. pub fn len(&self) -> usize { self.data.len() } + /// Whether there are no physically-present entries. + /// + /// O(1) and physical, mirroring [`len`](Self::len): it may still report + /// `false` while every remaining entry is expired-but-unswept. pub fn is_empty(&self) -> bool { self.data.len() == 0 } + /// Whether a live (non-expired) entry exists for `key`. + /// + /// Expiry-aware and O(1): consistent with [`get`](Self::get), it returns + /// `false` for an expired-but-unswept entry. pub fn contains_key(&self, key: &str) -> bool { - self.data.contains_key(key) + self.get_item(key).is_some() } pub fn clear_expired(&mut self) -> usize { @@ -66,12 +79,13 @@ impl SparKV { match peeked { Some(exp_item) => { if exp_item.is_expired() { - let kv_entry = self.data.get(&exp_item.key).unwrap(); - if kv_entry.key == exp_item.key - && kv_entry.expired_at == exp_item.expired_at - { - cleared_count += 1; - self.delete(&exp_item.key); + if let Some(kv_entry) = self.data.get(&exp_item.key) { + if kv_entry.key == exp_item.key + && kv_entry.expired_at == exp_item.expired_at + { + cleared_count += 1; + self.data.remove(&exp_item.key); // not self.delete() -> avoids re-entrant auto-clear recursion + } } self.expiries.pop(); } else { @@ -98,7 +112,9 @@ impl SparKV { } fn ensure_capacity_ignore_key(&self, key: &str) -> Result<(), Error> { - if self.contains_key(key) { + // Physical presence (not the expiry-aware public `contains_key`) so + // overwrite/capacity semantics stay identical regardless of expiry. + if self.data.contains_key(key) { return Ok(()); } self.ensure_capacity() @@ -431,6 +447,77 @@ mod tests { assert_eq!(sparkv.len(), 2); // but not actually deleting } + #[test] + fn test_contains_key_is_expiry_aware() { + let mut config: Config = Config::new(); + config.auto_clear_expired = false; + let mut sparkv = SparKV::with_config(config); + _ = sparkv.set_with_ttl( + "expiring", + String::from("value"), + std::time::Duration::from_millis(1), + ); + _ = sparkv.set_with_ttl( + "live", + String::from("value"), + std::time::Duration::from_secs(60), + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + + // Expired-but-unswept: contains_key agrees with get (both report absent). + assert!(!sparkv.contains_key("expiring")); + assert!(sparkv.get("expiring").is_none()); + // Yet it is still physically present (not swept). + assert_eq!(sparkv.len(), 2); + + // Live key is reported present. + assert!(sparkv.contains_key("live")); + assert_eq!(sparkv.get("live"), Some(String::from("value"))); + + assert!(!sparkv.contains_key("non-existent")); + } + + #[test] + fn test_clear_expired_does_not_panic_on_deleted_key() { + let mut config: Config = Config::new(); + config.auto_clear_expired = false; + let mut sparkv = SparKV::with_config(config); + _ = sparkv.set_with_ttl( + "gone", + String::from("value"), + std::time::Duration::from_millis(1), + ); + // Delete leaves a stale ExpEntry on the heap that will later expire. + assert_eq!(sparkv.delete("gone"), Some(String::from("value"))); + std::thread::sleep(std::time::Duration::from_millis(2)); + + // Must not panic on the unwrap of an already-removed key. + let cleared_count = sparkv.clear_expired(); + assert_eq!(cleared_count, 0); + assert_eq!(sparkv.expiries.len(), 0); // stale entry popped + assert!(!sparkv.contains_key("gone")); + } + + #[test] + fn test_clear_expired_does_not_recurse_under_default_config() { + // Default config has auto_clear_expired = true. + let mut sparkv = SparKV::new(); + _ = sparkv.set_with_ttl( + "expiring", + String::from("value"), + std::time::Duration::from_millis(1), + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + + // A subsequent set triggers auto-clear; must not overflow the stack. + _ = sparkv.set("live", String::from("value")); + + assert!(!sparkv.contains_key("expiring")); + assert!(sparkv.get("expiring").is_none()); + assert_eq!(sparkv.get("live"), Some(String::from("value"))); + assert_eq!(sparkv.len(), 1); + } + #[test] fn test_clear_expired_with_auto_clear_expired_enabled() { let mut config: Config = Config::new();