pdk-cache-lib 1.10.0

PDK Cache Library
Documentation
// Copyright (c) 2026, Salesforce, Inc.,
// All rights reserved.
// For full license text, see the LICENSE.txt file

//! TTL (Time-To-Live) decorator for caches.
//!
//! Wraps any [`Cache`] implementation to add time-based expiration. Expired entries
//! are lazily evicted on read, not via background sweeps.

use super::{error::CacheError, Cache};
use pdk_core::classy::{Clock, TimeUnit};
use std::rc::Rc;
use std::time::Duration;

#[derive(serde::Serialize, serde::Deserialize)]
struct TtlEntry {
    expiry_ms: u128,
    value: Vec<u8>,
}

/// TTL decorator that wraps any [`Cache`] implementation.
///
/// Values are serialized with `serde_fixint` as an envelope containing the
/// absolute expiry timestamp (milliseconds since UNIX_EPOCH) and the raw value.
/// On `get()`, if the entry has expired, it is deleted from the inner cache and
/// `None` is returned.
pub(crate) struct TtlCache<C: Cache> {
    inner: C,
    clock: Rc<dyn Clock>,
    ttl_ms: u128,
}

impl<C: Cache> TtlCache<C> {
    pub(crate) fn new(inner: C, clock: Rc<dyn Clock>, ttl: Duration) -> Self {
        Self {
            inner,
            clock,
            ttl_ms: ttl.as_millis(),
        }
    }

    fn now_ms(&self) -> u128 {
        self.clock.get_current_time_unit(TimeUnit::Milliseconds)
    }
}

impl<C: Cache> Cache for TtlCache<C> {
    fn save(&self, key: &str, value: Vec<u8>) -> Result<(), CacheError> {
        let expiry_ms = self.now_ms().saturating_add(self.ttl_ms);
        let entry = TtlEntry { expiry_ms, value };
        let serialized = serde_fixint::to_vec(&entry).map_err(|e| CacheError::SaveError {
            status: format!("Failed to serialize TTL entry: {e}"),
            cache_id: key.to_string(),
        })?;
        self.inner.save(key, serialized)
    }

    fn get(&self, key: &str) -> Option<Vec<u8>> {
        let raw = self.inner.get(key)?;
        let entry: TtlEntry = serde_fixint::from_slice(&raw).ok()?;
        if self.now_ms() >= entry.expiry_ms {
            self.inner.delete(key);
            None
        } else {
            Some(entry.value)
        }
    }

    fn delete(&self, key: &str) -> Option<Vec<u8>> {
        let raw = self.inner.delete(key)?;
        serde_fixint::from_slice::<TtlEntry>(&raw)
            .ok()
            .map(|e| e.value)
    }

    fn purge(&self) {
        self.inner.purge()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;
    use std::collections::HashMap;
    use std::time::{Duration, SystemTime};

    // Clock whose time can be advanced manually, to drive TTL expiry.
    struct ManualClock {
        current: RefCell<SystemTime>,
    }

    impl ManualClock {
        fn new() -> Self {
            Self {
                current: RefCell::new(SystemTime::now()),
            }
        }

        fn move_forward(&self, duration: Duration) {
            let new_time = self.get_current_time() + duration;
            *self.current.borrow_mut() = new_time;
        }
    }

    impl Clock for ManualClock {
        fn get_current_time(&self) -> SystemTime {
            *self.current.borrow()
        }
    }

    // Simple in-memory fake Cache for testing the wrapper in isolation
    struct FakeCache {
        store: RefCell<HashMap<String, Vec<u8>>>,
        delete_calls: RefCell<Vec<String>>,
    }

    impl FakeCache {
        fn new() -> Self {
            Self {
                store: RefCell::new(HashMap::new()),
                delete_calls: RefCell::new(Vec::new()),
            }
        }

        fn delete_was_called(&self, key: &str) -> bool {
            self.delete_calls.borrow().contains(&key.to_string())
        }
    }

    impl Cache for FakeCache {
        fn save(&self, key: &str, value: Vec<u8>) -> Result<(), CacheError> {
            self.store.borrow_mut().insert(key.to_string(), value);
            Ok(())
        }

        fn get(&self, key: &str) -> Option<Vec<u8>> {
            self.store.borrow().get(key).cloned()
        }

        fn delete(&self, key: &str) -> Option<Vec<u8>> {
            self.delete_calls.borrow_mut().push(key.to_string());
            self.store.borrow_mut().remove(key)
        }

        fn purge(&self) {
            self.store.borrow_mut().clear();
        }
    }

    #[test]
    fn get_returns_value_before_ttl() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(5));
        assert_eq!(ttl_cache.get("key1"), Some(b"value1".to_vec()));
    }

    #[test]
    fn get_returns_none_after_ttl() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(11));
        assert!(ttl_cache.get("key1").is_none());
    }

    #[test]
    fn get_deletes_expired_entry_from_inner_cache() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(11));
        assert!(ttl_cache.get("key1").is_none());
        assert!(ttl_cache.inner.delete_was_called("key1"));
    }

    #[test]
    fn get_returns_none_at_exact_expiry_boundary() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(10));
        assert!(ttl_cache.get("key1").is_none());
    }

    #[test]
    fn roundtrip_preserves_value_integrity() {
        let clock: Rc<dyn Clock> = Rc::new(ManualClock::new());
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        let original = b"test value with special \x00 bytes";
        ttl_cache.save("key1", original.to_vec()).unwrap();

        assert_eq!(ttl_cache.get("key1"), Some(original.to_vec()));
    }

    #[test]
    fn delete_returns_unprefixed_value() {
        let clock: Rc<dyn Clock> = Rc::new(ManualClock::new());
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        assert_eq!(ttl_cache.delete("key1"), Some(b"value1".to_vec()));
    }

    #[test]
    fn get_returns_none_for_malformed_bytes_without_panic() {
        let clock: Rc<dyn Clock> = Rc::new(ManualClock::new());
        let inner = FakeCache::new();

        inner.save("key1", vec![1, 2, 3]).unwrap();

        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));
        assert!(ttl_cache.get("key1").is_none());
    }

    #[test]
    fn delete_returns_none_for_malformed_bytes() {
        let clock: Rc<dyn Clock> = Rc::new(ManualClock::new());
        let inner = FakeCache::new();

        inner.save("key1", vec![1, 2, 3]).unwrap();

        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));
        assert!(ttl_cache.delete("key1").is_none());
    }

    #[test]
    fn purge_delegates_to_inner() {
        let clock: Rc<dyn Clock> = Rc::new(ManualClock::new());
        let inner = FakeCache::new();
        let ttl_cache = TtlCache::new(inner, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();
        ttl_cache.save("key2", b"value2".to_vec()).unwrap();

        ttl_cache.purge();

        assert!(ttl_cache.get("key1").is_none());
        assert!(ttl_cache.get("key2").is_none());
    }

    // Orthogonality tests: wrap real LruCache
    use crate::lru_cache::LruCache;
    use data_storage_lib::{BlockingDataStorage, DataStorageError, StoreMode};
    use serde::{de::DeserializeOwned, Serialize};

    // Minimal in-memory data storage backing the LruCache orthogonality tests.
    #[derive(Default)]
    struct FakeStore {
        entries: RefCell<HashMap<String, (Vec<u8>, u32)>>,
        cas_counter: RefCell<u32>,
    }

    impl FakeStore {
        fn next_cas(&self) -> u32 {
            let mut counter = self.cas_counter.borrow_mut();
            *counter += 1;
            *counter
        }
    }

    impl BlockingDataStorage for FakeStore {
        fn get_keys(&self) -> Result<Vec<String>, DataStorageError> {
            Ok(self.entries.borrow().keys().cloned().collect())
        }

        fn store<T: Serialize>(
            &self,
            key: &str,
            mode: &StoreMode,
            item: &T,
        ) -> Result<(), DataStorageError> {
            let serialized = serde_fixint::to_vec(item)?;
            let mut entries = self.entries.borrow_mut();
            match mode {
                StoreMode::Always => {}
                StoreMode::Absent => {
                    if entries.contains_key(key) {
                        return Err(DataStorageError::CasMismatch);
                    }
                }
                StoreMode::Cas(expected) => {
                    let expected: u32 = expected.parse()?;
                    match entries.get(key) {
                        Some((_, cas)) if *cas == expected => {}
                        _ => return Err(DataStorageError::CasMismatch),
                    }
                }
            }
            let cas = self.next_cas();
            entries.insert(key.to_string(), (serialized, cas));
            Ok(())
        }

        fn get<T: DeserializeOwned>(
            &self,
            key: &str,
        ) -> Result<Option<(T, String)>, DataStorageError> {
            match self.entries.borrow().get(key) {
                Some((data, cas)) => {
                    let deserialized: T = serde_fixint::from_slice(data)?;
                    Ok(Some((deserialized, cas.to_string())))
                }
                None => Ok(None),
            }
        }

        fn delete(&self, key: &str) -> Result<(), DataStorageError> {
            self.entries.borrow_mut().remove(key);
            Ok(())
        }

        fn delete_all(&self) -> Result<(), DataStorageError> {
            self.entries.borrow_mut().clear();
            Ok(())
        }
    }

    #[test]
    fn ttl_wrapping_lru_cache_present_before_ttl() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let storage = FakeStore::default();
        let lru = LruCache::new(String::from("test-ttl-lru"), 100, storage, 16);
        let ttl_cache = TtlCache::new(lru, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(5));
        assert_eq!(ttl_cache.get("key1"), Some(b"value1".to_vec()));
    }

    #[test]
    fn ttl_wrapping_lru_cache_gone_after_ttl() {
        let manual_clock = Rc::new(ManualClock::new());
        let clock: Rc<dyn Clock> = Rc::clone(&manual_clock) as Rc<dyn Clock>;
        let storage = FakeStore::default();
        let lru = LruCache::new(String::from("test-ttl-lru"), 100, storage, 16);
        let ttl_cache = TtlCache::new(lru, Rc::clone(&clock), Duration::from_secs(10));

        ttl_cache.save("key1", b"value1".to_vec()).unwrap();

        manual_clock.move_forward(Duration::from_secs(11));
        assert!(ttl_cache.get("key1").is_none());
    }
}