Quick Cache

repository·master·Indexed 43 days ago

https://github.com/arthurprs/quick-cache

A lightweight, high-performance concurrent cache implementation in Rust (v0.7.0) utilizing the S3-FIFO policy for scan-resistance and high hit rates. It features a closure-based entry API for atomic operations, customizable item weights via the Weighter trait, and lifecycle hooks for pinning or eviction listeners. Supports both sync and async operations with low overhead and a small dependency tree.

Tokens
6.9K
Snippets
17
Records
27
Agent score
54%

What's inside Quick Cache

  1. Overview of Quick Cache

    master

    Quick Cache is a lightweight, high-performance concurrent cache optimized for low overhead. It is designed for use cases where cache access times and overhead are significant costs.

    Key features include:

    • S3-FIFO Policy: Scan-resistant and high hit rate caching policy.
    • Concurrency: Scales well with threads; supports atomic operations via get_or_insert, get_value_or_guard, and their async counterparts (get_or_insert_async, get_value_or_guard_async).
    • Customizable Weight: Users can define weight per item.
    • Non-blocking: Methods return immediately on lock contention.
    • Entry API: Closure-based entry API for atomic inspect-and-act patterns (keep, remove, replace).
    • Low Overhead: Small dependency tree and no background threads.

    Note: If you require features like Time-to-Live (TTL) or event listeners, consider using the Moka crate instead.

  2. Configure custom item weights using Weighter

    master

    You can control how the cache calculates the weight of items by implementing the Weighter trait. This is useful when items have varying sizes (e.g., string length) and you want to limit the cache based on total weight rather than item count.

    Use Cache::with_weighter(max_capacity, max_weight, weighter) to initialize the cache.

    Warning: Be cautious about zero weights, as they can lead to unexpected behavior in capacity management.

    use quick_cache::{Weighter, sync::Cache};
    
    #[derive(Clone)]
    struct StringWeighter;
    
    impl Weighter<u64, String> for StringWeighter {
        fn weight(&self, _key: &u64, val: &String) -> u64 {
            // Be cautious about zero weights!
            val.len() as u64
        }
    }
    
    fn main() {
        let cache = Cache::with_weighter(100, 100_000, StringWeighter);
        cache.insert(1, "1".to_string());
        cache.insert(54, "54".to_string());
        cache.insert(1000, "1000".to_string());
        assert_eq!(cache.get(&1000).unwrap(), "1000");
    }
  3. Batch evictions using Lifecycle Request State

    master

    For high-performance scenarios, you can use Lifecycle::RequestState to batch the work associated with evictions. Instead of performing eviction cleanup immediately on every operation, you can thread a RequestState through multiple operations.

    Methods that support this:

    • insert_with_lifecycle(&mut self, key: Key, value: Val, lcs: &mut L::RequestState)
    • replace_with_lifecycle(&mut self, key: Key, value: Val, soft: bool, lcs: &mut L::RequestState)
    • Guard::insert_with_lifecycle(self, value: Val, lcs: &mut L::RequestState)

    Evicted items are released when the lcs object is dropped.

  4. Estimate quick-cache memory usage

    master

    The memory overhead per entry is approximately 21 bytes. The total memory usage of the cache data structures can be estimated using the formula:

    (size_of::<K>() + size_of::<V>() + 21) * (length * 1.5).next_power_of_two()

    Where:

    • 1.5 is derived from 1 + G.
    • G is the configured ghost allocation specified in OptionsBuilder::ghost_allocation (defaults to 0.5).

    Note that actual usage will be higher if keys or values involve external allocations like String or Vec.

  5. Configure the DefaultLifecycle for eviction handling

    master

    The DefaultLifecycle is the standard implementation for handling evicted items. It is designed to minimize overhead by stashing up to two evicted items in a fixed-size array ([Option<(Key, Val)>; 2]) during a request. This allows the cache to drop these items outside of the critical section (the cache locks), reducing lock contention.

    If you need custom behavior when items are evicted (e.g., logging or external cleanup), you can implement the Lifecycle trait.

    // Using the default lifecycle (standard behavior)
    let cache = Cache::with_options(
        options,
        UnitWeighter,
        DefaultHashBuilder::default(),
        DefaultLifecycle::default(),
    );
  6. Use Lifecycle hooks to manage cache entry lifetimes

    master

    The Lifecycle<Key, Val> trait allows you to hook into the lifecycle of cache entries. This is useful for:

    • Pinning items: Implementing is_pinned prevents an item from being evicted (though it still consumes weight).
    • Eviction listeners: Using on_evict, on_evict_hot, or on_evict_cold to perform actions (like sending items to a channel) when an item is removed.
    • Dynamic weight adjustment: Using before_evict to change an item's weight right before it is evicted. If you set the weight to 0 here, the item will stay in the cache instead of being removed.

    Lifecycle hooks use a RequestState (a per-request accumulator) to thread information through the eviction process efficiently.

  7. Initialize a non-concurrent Cache

    master

    The Cache struct provides a single-threaded, non-concurrent cache implementation. You can initialize it using several methods depending on how you want to manage capacity and weight.

    • new(items_capacity: usize): Creates a cache that holds approximately items_capacity items.
    • with_weighter(estimated_items_capacity: usize, weight_capacity: u64, weighter: We): Creates a cache with a custom Weighter to track weight instead of just item count.
    • with(...): A low-level constructor for specifying capacity, weight, weighter, hash builder, and lifecycle.
    • with_options(options: Options, ...): Constructs a cache using an Options object built via OptionsBuilder.
    use quick_cache::unsync::{Cache, OptionsBuilder, UnitWeighter, DefaultHashBuilder, DefaultLifecycle};
    
    // Example: Using with_options
    Cache::<(String, u64), String>::with_options(
      OptionsBuilder::new()
        .estimated_items_capacity(10000)
        .weight_capacity(10000)
        .build()
        .unwrap(),
        UnitWeighter,
        DefaultHashBuilder::default(),
        DefaultLifecycle::default(),
    );
  8. Initialize a new Cache

    master

    You can create a new Cache using several constructors depending on your requirements for capacity and weighting:

    • new(items_capacity: usize): Creates a cache with an approximate item capacity.
    • with_weighter(estimated_items_capacity: usize, weight_capacity: u64, weighter: We): Creates a cache with a specific weight capacity and a custom Weighter.
    • with(estimated_items_capacity: usize, weight_capacity: u64, weighter: We, hash_builder: B, lifecycle: L): A full constructor for specifying capacity, weighting, hashing, and lifecycle behavior.
    • with_options(options: Options, weighter: We, hash_builder: B, lifecycle: L): Constructs a cache using an Options object from OptionsBuilder.

    Note on Values: Cache values are cloned when fetched. To avoid expensive clones, wrap your values in Arc<_>. For interior mutability, use Arc<Mutex<_>> or Arc<RwLock<_>>.

    use quick_cache::sync::{Cache, DefaultLifecycle, OptionsBuilder, UnitWeighter, DefaultHashBuilder};
    
    // Example using with_options
    let cache = Cache::<(String, u64), String>::with_options(
      OptionsBuilder::new()
        .estimated_items_capacity(10000)
        .weight_capacity(10000)
        .build()
        .unwrap(),
        UnitWeighter,
        DefaultHashBuilder::default(),
        DefaultLifecycle::default(),
    );
  9. Configure cache settings with OptionsBuilder

    master

    Use OptionsBuilder to configure the behavior and capacity of a quick_cache instance. The builder must be used to set weight_capacity and estimated_items_capacity before calling .build(), otherwise an error will be returned. The resulting Options struct is then passed to Cache::with_options.

    Required Fields

    • weight_capacity(u64): The maximum weight the cache can hold.
    • estimated_items_capacity(usize): The expected number of items in the cache (roughly weight_capacity / average item weight). This helps the cache estimate shard sizes and ghost key tracking space.

    Optional Fields

    • shards(usize): Number of internal shards. Higher numbers reduce contention in multi-threaded environments. Defaults to number of detected cores * 4. The value is internally adjusted to the next power of two.
    • hot_allocation(f64): Percentage [0.0, 1.0] of cache space reserved for "hot" items. Defaults to 0.97. Lower this if your workload favors recency over frequency.
    • ghost_allocation(f64): Percentage [0.0, 1.0] of estimated_items_capacity used to track recently evicted keys ("ghost keys"). Defaults to 0.5.
    use quick_cache::{sync::{Cache, DefaultLifecycle}, OptionsBuilder, UnitWeighter, DefaultHashBuilder};
    
    Cache::<String, String>::with_options(
      OptionsBuilder::new()
        .estimated_items_capacity(10000)
        .weight_capacity(10000)
        .build()
        .unwrap(),
        UnitWeighter,
        DefaultHashBuilder::default(),
        DefaultLifecycle::default(),
    );
  10. Use the Equivalent trait for complex keys

    master

    If your cache keys are complex types (like a custom struct), you can implement the Equivalent trait to allow lookups using different but logically equal types (e.g., looking up a Pair<String, i32> using a (String, i32) tuple).

    use quick_cache::{sync::Cache, Equivalent};
    
    #[derive(Debug, Hash)]
    pub struct Pair<A, B>(pub A, B);
    
    impl<A, B, C, D> Equivalent<(C, D)> for Pair<A, B>
    where
        A: PartialEq<C>,
        B: PartialEq<D>,
    {
        fn equivalent(&self, rhs: &(C, D)) -> bool {
            self.0 == rhs.0 && self.1 == rhs.1
        }
    }
    
    fn main() {
        let cache: Cache<(String, i32), String> = Cache::new(5);
        cache.insert(("square".to_string(), 2022), "blue".to_string());
        assert_eq!(cache.get(&Pair("square".to_string(), 2022)).unwrap(), "blue");
    }
    use quick_cache::{sync::Cache, Equivalent};
    
    #[derive(Debug, Hash)]
    pub struct Pair<A, B>(pub A, pub B);
    
    impl<A, B, C, D> Equivalent<(C, D)> for Pair<A, B>
    where
        A: PartialEq<C>,
        B: PartialEq<D>,
    {
        fn equivalent(&self, rhs: &(C, D)) -> bool {
            self.0 == rhs.0 && self.1 == rhs.1
        }
    }
    
    fn main() {
        let cache: Cache<(String, i32), String> = Cache::new(5);
        cache.insert(("square".to_string(), 2022), "blue".to_string());
        assert_eq!(cache.get(&Pair("square".to_string(), 2022)).unwrap(), "blue");
    }
  11. Basic usage with unsync Cache

    master

    For single-threaded scenarios, use quick_cache::unsync::Cache. This is a simple key-value store where you can insert and retrieve items.

    use quick_cache::unsync::Cache;
    
    fn main() {
        let mut cache = Cache::new(5);
        cache.insert("square", "blue");
        cache.insert("circle", "black");
        assert_eq!(*cache.get(&"square").unwrap(), "blue");
        assert_eq!(*cache.get(&"circle").unwrap(), "black");
    }
  12. Use the entry API for atomic inspect-and-act

    master

    The entry API allows you to perform atomic operations on a cache entry using a closure. This prevents race conditions between checking for a value and inserting/removing it.

    Supported actions via EntryAction:

    • Retain(value): Keep the existing entry.
    • Remove: Evict the entry.
    • Replace: (Implicitly handled via the guard in Vacant results).

    EntryResult return types:

    • Retained(v): The entry was present and retained.
    • Vacant(guard): The entry was absent; use the provided guard to insert a new value.
    • Removed(key, value): The entry was present but removed based on your logic.
    use quick_cache::sync::{Cache, EntryAction, EntryResult};
    
    fn main() {
        let cache: Cache<u64, u64> = Cache::new(100);
    
        // Insert-or-get: if absent, compute and insert; if present, return cached
        let result = cache.entry(&0, None, |_key, val| EntryAction::Retain(*val));
        let value = match result {
            EntryResult::Retained(v) => v,
            EntryResult::Vacant(guard) => {
                let v = 42; // expensive computation
                guard.insert(v).unwrap();
                v
            }
            _ => unreachable!(),
        };
        assert_eq!(value, 42);
    
        // Conditionally remove: evict entries below a threshold
        let result = cache.entry(&0, None, |_key, val| {
            if *val < 100 {
                EntryAction::<()>::Remove
            } else {
                EntryAction::Retain(())
            }
        });
        assert!(matches!(result, EntryResult::Removed(0, 42)));
    }