├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── examples └── basic.rs ├── src ├── cache.rs ├── entry.rs └── lib.rs └── tests ├── basic.rs └── runtimes.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: 15 | - macos-latest 16 | - ubuntu-latest 17 | - windows-latest 18 | rust: 19 | - stable 20 | - beta 21 | - nightly 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | - uses: actions-rs/toolchain@v1 27 | with: 28 | profile: minimal 29 | toolchain: ${{ matrix.rust }} 30 | override: true 31 | components: rustfmt, clippy 32 | 33 | - uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | 37 | - uses: actions-rs/cargo@v1 38 | with: 39 | command: test 40 | 41 | - uses: actions-rs/cargo@v1 42 | with: 43 | command: fmt 44 | args: --all -- --check 45 | 46 | - uses: actions-rs/cargo@v1 47 | with: 48 | command: clippy 49 | args: --all --all-features --profile test 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | /tarpaulin-report.html 4 | /**/*.rs.bk 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "retainer" 3 | version = "0.4.0" # remember to update html_root_url 4 | authors = ["Isaac Whitfield "] 5 | description = "Minimal async cache in Rust with support for key expirations " 6 | repository = "https://github.com/whitfin/retainer" 7 | keywords = ["async", "futures", "caching", "expiration", "ttl"] 8 | categories = ["algorithms", "asynchronous", "caching", "data-structures"] 9 | readme = "README.md" 10 | edition = "2018" 11 | license = "MIT" 12 | 13 | [dependencies] 14 | log = "0.4" 15 | rand = "0.9" 16 | async-lock = "3.4" 17 | async-io = "2.4" 18 | futures-lite = "2.6" 19 | 20 | [dev-dependencies] 21 | smol = "2.0" 22 | tokio = { version = "1.45", features = ["full"] } 23 | async-std = { version = "1.13", features = ["attributes"] } 24 | simple_logger = "5.0" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Isaac Whitfield 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Retainer 2 | [![Build Status](https://img.shields.io/github/actions/workflow/status/whitfin/retainer/ci.yml?branch=main)](https://github.com/whitfin/retainer/actions) 3 | [![Crates.io](https://img.shields.io/crates/v/retainer.svg)](https://crates.io/crates/retainer) 4 | 5 | This crate offers a very small cache with asynchronous bindings, allowing it to be 6 | used in async Rust contexts (Tokio, async-std, smol, etc.) without blocking the 7 | worker thread completely. 8 | 9 | It also includes the ability to expire entries in the cache based on their time 10 | inside; this is done by spawning a monitor on your async runtime in order to 11 | perform cleanup tasks periodically. The eviction algorithm is similar to the one 12 | found inside [Redis](https://redis.io/commands/expire), although keys are not 13 | removed on access in order to reduce borrow complexity. 14 | 15 | This crate is still a work in progress, so feel free to file any suggestions or 16 | improvements and I'll get to them as soon as possible :). 17 | 18 | ### Getting Started 19 | 20 | This crate is available on [crates.io](https://crates.io/crates/retainer). The 21 | easiest way to use it is to add an entry to your `Cargo.toml` defining the dependency 22 | using `cargo add`: 23 | 24 | ```sh 25 | $ cargo add retainer 26 | ``` 27 | 28 | ### Basic Usage 29 | 30 | The construction of a cache is very simple, and (currently) requires no options. If 31 | you need to make use of key expiration, you must ensure to either await a monitor or 32 | spawn a monitor on your runtime. 33 | 34 | There are many ways to provide an expiration time when inserting into a cache, by 35 | making use of several types implementing the `Into` trait. Below 36 | are some examples of types which are available and some of the typical APIs you 37 | will find yourself using. This code uses the Tokio runtime, but this crate should 38 | be compatible with most of the popular asynchronous runtimes. Currently a small 39 | set of tests are run against async-std, smol and Tokio. 40 | 41 | ```rust 42 | use retainer::*; 43 | use tokio::time::sleep; 44 | 45 | use std::sync::Arc; 46 | use std::time::{Duration, Instant}; 47 | 48 | #[tokio::main] 49 | async fn main() { 50 | // construct our cache 51 | let cache = Arc::new(Cache::new()); 52 | let clone = cache.clone(); 53 | 54 | // don't forget to monitor your cache to evict entries 55 | let monitor = tokio::spawn(async move { 56 | clone.monitor(4, 0.25, Duration::from_secs(3)).await 57 | }); 58 | 59 | // insert using an `Instant` type to specify expiration 60 | cache.insert("one", 1usize, Instant::now()).await; 61 | 62 | // insert using a `Duration` type to wait before expiration 63 | cache.insert("two", 2, Duration::from_secs(2)).await; 64 | 65 | // insert using a number of milliseconds 66 | cache.insert("three", 3, 3500).await; 67 | 68 | // insert using a random number of milliseconds 69 | cache.insert("four", 4, 3500..5000).await; 70 | 71 | // insert without expiration (i.e. manual removal) 72 | cache.insert("five", 5, CacheExpiration::none()).await; 73 | 74 | // wait until the monitor has run once 75 | sleep(Duration::from_millis(3250)).await; 76 | 77 | // the first two keys should have been removed 78 | assert!(cache.get(&"one").await.is_none()); 79 | assert!(cache.get(&"two").await.is_none()); 80 | 81 | // the rest should be there still for now 82 | assert!(cache.get(&"three").await.is_some()); 83 | assert!(cache.get(&"four").await.is_some()); 84 | assert!(cache.get(&"five").await.is_some()); 85 | 86 | // wait until the monitor has run again 87 | sleep(Duration::from_millis(3250)).await; 88 | 89 | // the other two keys should have been removed 90 | assert!(cache.get(&"three").await.is_none()); 91 | assert!(cache.get(&"four").await.is_none()); 92 | 93 | // the key with no expiration should still exist 94 | assert!(cache.get(&"five").await.is_some()); 95 | 96 | // but we should be able to manually remove it 97 | assert!(cache.remove(&"five").await.is_some()); 98 | assert!(cache.get(&"five").await.is_none()); 99 | 100 | // and now our cache should be empty 101 | assert!(cache.is_empty().await); 102 | 103 | // shutdown monitor 104 | monitor.abort(); 105 | } 106 | 107 | ``` 108 | 109 | In the case this example is not kept up to date, you can look for any types which 110 | implement the `Into` trait in the documentation for a complete list. 111 | 112 | ### Cache Monitoring 113 | 114 | All key expiration is done on an interval, carried out when you `await` the future 115 | returned by `Cache::monitor`. The basis for how this is done has been lifted roughly 116 | from the implementation found inside Redis, as it's simple but still works well. 117 | 118 | When you call `Cache::monitor`, you need to provide 3 arguments: 119 | 120 | * sample 121 | * frequency 122 | * threshold 123 | 124 | Below is a summarization of the flow of eviction, hopefully in a clear way: 125 | 126 | 1. Wait until the next tick of `frequency`. 127 | 2. Take a batch of `sample` entries from the cache at random. 128 | 3. Check for and remove any expired entries found in the batch. 129 | 4. If more than `threshold` percent of the entries in the batch were removed, 130 | immediately goto #2, else goto #1. 131 | 132 | This allows the user to control the aggressiveness of eviction quite effectively, 133 | by tweaking the `threshold` and `frequency` values. Naturally a cache uses more 134 | memory on average the higher your threshold is, so please do keep this in mind. 135 | 136 | ### Cache Logging 137 | 138 | As of v0.2, minimal logging is included using the [log](https://crates.io/crates/log) 139 | crate. You can attach any of the compatible logging backends to see what is happening 140 | in the cache (particularly the eviction loop) to better gauge your usage and parameters. 141 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | use retainer::*; 2 | use simple_logger::SimpleLogger; 3 | 4 | use std::time::Duration; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | // enable logs for example purposes 9 | SimpleLogger::new().init().unwrap(); 10 | 11 | // create our new cache 12 | let cache = Cache::new(); 13 | 14 | // insert 100K entries 15 | for i in 0..100000 { 16 | cache.insert(i, i, Duration::from_millis(i)).await; 17 | } 18 | 19 | // spawn a monitor using Redis config; 20 keys every 100ms 20 | cache.monitor(20, 0.25, Duration::from_millis(100)).await; 21 | } 22 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | //! Caching structures for use in an asynchronous context. 2 | //! 3 | //! The main point of this module is the `Cache` type, which offers a small 4 | //! implementation of a cache with time based expiration support. The underlying 5 | //! structure is nothing more than a map wrapped inside some asynchronous locking 6 | //! mechanisms to avoid blocking the entire async runtime when waiting for a handle. 7 | //! 8 | //! The eviction algorithm has been based on Redis, and essentially just samples 9 | //! the entry set on an interval to prune the inner tree over time. More information 10 | //! on how this works can be seen on the `monitor` method of the `Cache` type. 11 | use std::borrow::Borrow; 12 | use std::cmp; 13 | use std::collections::{BTreeMap, BTreeSet}; 14 | use std::marker::PhantomData; 15 | use std::time::{Duration, Instant}; 16 | 17 | use async_io::Timer; 18 | use async_lock::{RwLock, RwLockUpgradableReadGuard}; 19 | use futures_lite::stream::StreamExt; 20 | use log::{debug, log_enabled, trace, Level}; 21 | use rand::prelude::*; 22 | 23 | use crate::entry::{CacheEntry, CacheExpiration, CacheReadGuard}; 24 | 25 | // Define small private macro to unpack entry references. 26 | macro_rules! unpack { 27 | ($entry: expr) => { 28 | if $entry.expiration().is_expired() { 29 | None 30 | } else { 31 | Some($entry) 32 | } 33 | }; 34 | } 35 | 36 | /// Basic caching structure with asynchronous locking support. 37 | /// 38 | /// This structure provides asynchronous access wrapped around a standard 39 | /// `BTreeMap` to avoid blocking event loops when a writer cannot gain a 40 | /// handle - which is what would happen with standard locking implementations. 41 | pub struct Cache { 42 | store: RwLock>>, 43 | label: String, 44 | } 45 | 46 | impl Cache 47 | where 48 | K: Ord + Clone, 49 | { 50 | /// Construct a new `Cache`. 51 | pub fn new() -> Self { 52 | Self { 53 | store: RwLock::new(BTreeMap::new()), 54 | label: "".to_owned(), 55 | } 56 | } 57 | 58 | /// Sets the label inside this cache for logging purposes. 59 | pub fn with_label(mut self, s: &str) -> Self { 60 | self.label = format!("cache({}): ", s); 61 | self 62 | } 63 | 64 | /// Remove all entries from the cache. 65 | pub async fn clear(&self) { 66 | self.store.write().await.clear() 67 | } 68 | 69 | /// Retrieve the number of expired entries inside the cache. 70 | /// 71 | /// Note that this is calculated by walking the set of entries and 72 | /// should therefore not be used in performance sensitive situations. 73 | pub async fn expired(&self) -> usize { 74 | self.store 75 | .read() 76 | .await 77 | .iter() 78 | .filter(|(_, entry)| entry.expiration().is_expired()) 79 | .count() 80 | } 81 | 82 | /// Retrieve a reference to a value inside the cache. 83 | /// 84 | /// The returned reference is bound inside a `RwLockReadGuard`. 85 | pub async fn get(&self, k: &B) -> Option> 86 | where 87 | K: Borrow, 88 | B: Ord + ?Sized, 89 | { 90 | let guard = self.store.read().await; 91 | let found = guard.get(k)?; 92 | let valid = unpack!(found)?; 93 | 94 | Some(CacheReadGuard { 95 | entry: valid, 96 | marker: PhantomData, 97 | }) 98 | } 99 | 100 | /// Retrieve the number of entries inside the cache. 101 | /// 102 | /// This *does* include entries which may be expired but are not yet evicted. In 103 | /// future there may be an API addition to find the unexpired count, but as it's 104 | /// relatively expensive it has been omitted for the time being. 105 | pub async fn len(&self) -> usize { 106 | self.store.read().await.len() 107 | } 108 | 109 | /// Insert a key/value pair into the cache with an associated expiration. 110 | /// 111 | /// The third argument controls expiration, which can be provided using any type which 112 | /// implements `Into`. This allows for various different syntax based 113 | /// on your use case. If you do not want expiration, use `CacheExpiration::none()`. 114 | pub async fn insert(&self, k: K, v: V, e: E) -> Option 115 | where 116 | E: Into, 117 | { 118 | let entry = CacheEntry::new(v, e.into()); 119 | self.store 120 | .write() 121 | .await 122 | .insert(k, entry) 123 | .and_then(|entry| unpack!(entry)) 124 | .map(CacheEntry::into_inner) 125 | } 126 | 127 | /// Check whether the cache is empty. 128 | pub async fn is_empty(&self) -> bool { 129 | self.store.read().await.is_empty() 130 | } 131 | 132 | /// Retrieve a `Future` used to monitor expired keys. 133 | /// 134 | /// This future must be spawned on whatever runtime you are using inside your 135 | /// application; not doing this will result in keys never being expired. 136 | /// 137 | /// For expiration logic, please see `Cache::purge`, as this is used under the hood. 138 | pub async fn monitor(&self, sample: usize, threshold: f64, frequency: Duration) { 139 | let mut interval = Timer::interval(frequency); 140 | loop { 141 | interval.next().await; 142 | self.purge(sample, threshold).await; 143 | } 144 | } 145 | 146 | /// Cleanses the cache of expired entries. 147 | /// 148 | /// Keys are expired using the same logic as the popular caching system Redis: 149 | /// 150 | /// 1. Wait until the next tick of `frequency`. 151 | /// 2. Take a sample of `sample` keys from the cache. 152 | /// 3. Remove any expired keys from the sample. 153 | /// 4. Based on `threshold` percentage: 154 | /// 4a. If more than `threshold` were expired, goto #2. 155 | /// 4b. If less than `threshold` were expired, goto #1. 156 | /// 157 | /// This means that at any point you may have up to `threshold` percent of your 158 | /// cache storing expired entries (assuming the monitor just ran), so make sure 159 | /// to tune your frequency, sample size, and threshold accordingly. 160 | pub async fn purge(&self, sample: usize, threshold: f64) { 161 | let start = Instant::now(); 162 | 163 | let mut locked = Duration::from_nanos(0); 164 | let mut removed = 0; 165 | 166 | loop { 167 | // lock the store and grab a generator 168 | let store = self.store.upgradable_read().await; 169 | 170 | // once we're empty, no point carrying on 171 | if store.is_empty() { 172 | break; 173 | } 174 | 175 | // determine the sample size of the batch 176 | let total = store.len(); 177 | let sample = cmp::min(sample, total); 178 | 179 | // counter to track removed keys 180 | let mut gone = 0; 181 | 182 | // create our temporary key store and index tree 183 | let mut keys = Vec::with_capacity(sample); 184 | let mut indices: BTreeSet = BTreeSet::new(); 185 | 186 | { 187 | // fetch `sample` keys at random 188 | let mut rng = rand::rng(); 189 | while indices.len() < sample { 190 | indices.insert(rng.random_range(0..total)); 191 | } 192 | } 193 | 194 | { 195 | // tracker for previous index 196 | let mut prev = 0; 197 | 198 | // boxed iterator to allow us to iterate a single time for all indices 199 | let mut iter: Box)>> = 200 | Box::new(store.iter()); 201 | 202 | // walk our index list 203 | for idx in indices { 204 | // calculate how much we need to shift the iterator 205 | let offset = idx 206 | .checked_sub(prev) 207 | .and_then(|idx| idx.checked_sub(1)) 208 | .unwrap_or(0); 209 | 210 | // shift and mark the current index 211 | iter = Box::new(iter.skip(offset)); 212 | prev = idx; 213 | 214 | // fetch the next pair (at our index) 215 | let (key, entry) = iter.next().unwrap(); 216 | 217 | // skip if not expired 218 | if !entry.expiration().is_expired() { 219 | continue; 220 | } 221 | 222 | // otherwise mark for removal 223 | keys.push(key.to_owned()); 224 | 225 | // and increment remove count 226 | gone += 1; 227 | } 228 | } 229 | 230 | { 231 | // upgrade to a write guard so that we can make our changes 232 | let acquired = Instant::now(); 233 | let mut store = RwLockUpgradableReadGuard::upgrade(store).await; 234 | 235 | // remove all expired keys 236 | for key in &keys { 237 | store.remove(key); 238 | } 239 | 240 | // increment the lock timer tracking directly 241 | locked = locked.checked_add(acquired.elapsed()).unwrap(); 242 | } 243 | 244 | // log out now many of the sampled keys were removed 245 | if log_enabled!(Level::Trace) { 246 | trace!( 247 | "{}removed {} / {} ({:.2}%) of the sampled keys", 248 | self.label, 249 | gone, 250 | sample, 251 | (gone as f64 / sample as f64) * 100f64, 252 | ); 253 | } 254 | 255 | // bump total remove count 256 | removed += gone; 257 | 258 | // break the loop if we don't meet thresholds 259 | if (gone as f64) < (sample as f64 * threshold) { 260 | break; 261 | } 262 | } 263 | 264 | // log out the completion as well as the time taken in millis 265 | if log_enabled!(Level::Debug) { 266 | debug!( 267 | "{}purge loop removed {} entries in {:.0?} ({:.0?} locked)", 268 | self.label, 269 | removed, 270 | start.elapsed(), 271 | locked 272 | ); 273 | } 274 | } 275 | 276 | /// Remove an entry from the cache and return any stored value. 277 | pub async fn remove(&self, k: &B) -> Option 278 | where 279 | K: Borrow, 280 | B: Ord + ?Sized, 281 | { 282 | self.store 283 | .write() 284 | .await 285 | .remove(k) 286 | .and_then(|entry| unpack!(entry)) 287 | .map(CacheEntry::into_inner) 288 | } 289 | 290 | /// Retrieve the number of unexpired entries inside the cache. 291 | /// 292 | /// Note that this is calculated by walking the set of entries and 293 | /// should therefore not be used in performance sensitive situations. 294 | pub async fn unexpired(&self) -> usize { 295 | self.store 296 | .read() 297 | .await 298 | .iter() 299 | .filter(|(_, entry)| !entry.expiration().is_expired()) 300 | .count() 301 | } 302 | 303 | /// Updates an entry in the cache without changing the expiration. 304 | pub async fn update(&self, k: &B, f: F) 305 | where 306 | K: Borrow, 307 | B: Ord + ?Sized, 308 | F: FnOnce(&mut V), 309 | { 310 | let mut guard = self.store.write().await; 311 | if let Some(entry) = guard.get_mut(k).and_then(|entry| unpack!(entry)) { 312 | f(entry.value_mut()); 313 | } 314 | } 315 | } 316 | 317 | /// Default implementation. 318 | impl Default for Cache 319 | where 320 | K: Ord + Clone, 321 | { 322 | fn default() -> Self { 323 | Cache::new() 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/entry.rs: -------------------------------------------------------------------------------- 1 | //! Small structures based around entries in the cache. 2 | //! 3 | //! Each entry has an associated value and optional expiration, 4 | //! and access functions for both. To be more convenient to the 5 | //! called, a `CacheEntry` will also dereference to `V`. 6 | use std::marker::PhantomData; 7 | use std::ops::{Deref, Range}; 8 | use std::time::{Duration, Instant}; 9 | 10 | use rand::prelude::*; 11 | 12 | /// Represents an entry inside the cache. 13 | /// 14 | /// Each entry has a value and optional expiration associated, with 15 | /// the value being seen through the `Deref` trait for convenience. 16 | #[derive(Debug)] 17 | pub(crate) struct CacheEntry { 18 | value: V, 19 | expiration: CacheExpiration, 20 | } 21 | 22 | impl CacheEntry { 23 | /// Create a new cache entry from a value and expiration. 24 | pub fn new(value: V, expiration: CacheExpiration) -> Self { 25 | Self { value, expiration } 26 | } 27 | 28 | /// Retrieve the internal expiration. 29 | pub fn expiration(&self) -> &CacheExpiration { 30 | &self.expiration 31 | } 32 | 33 | /// Retrieve the internal value. 34 | pub fn value(&self) -> &V { 35 | &self.value 36 | } 37 | 38 | /// Retrieve the mutable internal value. 39 | pub fn value_mut(&mut self) -> &mut V { 40 | &mut self.value 41 | } 42 | 43 | /// Take the internal value. 44 | pub fn into_inner(self) -> V { 45 | self.value 46 | } 47 | } 48 | 49 | /// Small structure to represent expiration in a cache. 50 | /// 51 | /// Expirations are constructed using the `From` and `Into` traits 52 | /// from the standard library; there are no other functions. 53 | /// 54 | /// There are currently several supported conversions: 55 | /// 56 | /// * `u64` -> a number of milliseconds to pass before an entry should expire. 57 | /// * `Instant` -> an exact time that an entry should expire. 58 | /// * `Duration` -> a duration to pass before an entry should expire. 59 | /// * `Range` -> a random range of milliseconds to sample expiry from. 60 | /// 61 | /// Other conversions may be added in future, but this should suffice for most 62 | /// cases. Any of these types may be passed to the insertion methods on a cache 63 | /// type when adding entries to a cache. 64 | #[derive(Debug)] 65 | pub struct CacheExpiration { 66 | instant: Option, 67 | } 68 | 69 | impl CacheExpiration { 70 | /// Create an expiration at a given instant. 71 | pub fn new(instant: I) -> Self 72 | where 73 | I: Into, 74 | { 75 | Self { 76 | instant: Some(instant.into()), 77 | } 78 | } 79 | 80 | /// Create an empty expiration (i.e. no expiration). 81 | pub fn none() -> Self { 82 | Self { instant: None } 83 | } 84 | 85 | /// Retrieve the instant associated with this expiration. 86 | pub fn instant(&self) -> &Option { 87 | &self.instant 88 | } 89 | 90 | /// Retrieve whether a cache entry has passed expiration. 91 | pub fn is_expired(&self) -> bool { 92 | self.instant() 93 | .map(|expiration| expiration < Instant::now()) 94 | .unwrap_or(false) 95 | } 96 | 97 | /// Retrieve the time remaining before expiration. 98 | pub fn remaining(&self) -> Option { 99 | self.instant 100 | .map(|i| i.saturating_duration_since(Instant::now())) 101 | } 102 | } 103 | 104 | // Automatic conversation from `Instant`. 105 | impl From for CacheExpiration { 106 | fn from(instant: Instant) -> Self { 107 | Self::new(instant) 108 | } 109 | } 110 | 111 | // Automatic conversation from `u64`. 112 | impl From for CacheExpiration { 113 | fn from(millis: u64) -> Self { 114 | Duration::from_millis(millis).into() 115 | } 116 | } 117 | 118 | // Automatic conversation from `Duration`. 119 | impl From for CacheExpiration { 120 | fn from(duration: Duration) -> Self { 121 | Instant::now().checked_add(duration).unwrap().into() 122 | } 123 | } 124 | 125 | // Automatic conversation from `u64`. 126 | impl From> for CacheExpiration { 127 | fn from(range: Range) -> Self { 128 | rand::rng().random_range(range).into() 129 | } 130 | } 131 | 132 | /// Read guard for references to the inner cache structure. 133 | /// 134 | /// This structure is required to return references to the inner cache entries 135 | /// when using locking mechanisms. This structure should be transparent for the 136 | /// most part as it implements `Deref` to convert itself into the inner value. 137 | #[derive(Debug)] 138 | pub struct CacheReadGuard<'a, V> { 139 | pub(crate) entry: *const CacheEntry, 140 | pub(crate) marker: PhantomData<&'a CacheEntry>, 141 | } 142 | 143 | impl CacheReadGuard<'_, V> { 144 | /// Retrieve the internal guarded expiration. 145 | pub fn expiration(&self) -> &CacheExpiration { 146 | self.entry().expiration() 147 | } 148 | 149 | /// Retrieve the internal guarded value. 150 | pub fn value(&self) -> &V { 151 | self.entry().value() 152 | } 153 | 154 | /// Retrieve a reference to the internal entry. 155 | fn entry(&self) -> &CacheEntry { 156 | unsafe { &*self.entry } 157 | } 158 | } 159 | 160 | impl Deref for CacheReadGuard<'_, V> { 161 | type Target = V; 162 | 163 | // Derefs a cache guard to the internal entry. 164 | fn deref(&self) -> &Self::Target { 165 | self.value() 166 | } 167 | } 168 | 169 | // Stores a raw pointer to `T`, so if `T` is `Sync`, the lock guard over `T` is `Send`. 170 | unsafe impl Send for CacheReadGuard<'_, V> where V: Sized + Sync {} 171 | unsafe impl Sync for CacheReadGuard<'_, V> where V: Sized + Send + Sync {} 172 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | // exposed modules 4 | pub mod cache; 5 | pub mod entry; 6 | 7 | // lifted types to the top level 8 | pub use crate::cache::Cache; 9 | pub use crate::entry::CacheExpiration; 10 | -------------------------------------------------------------------------------- /tests/basic.rs: -------------------------------------------------------------------------------- 1 | use retainer::*; 2 | 3 | #[tokio::test] 4 | async fn test_cache_size_operations() { 5 | let cache = Cache::::new(); 6 | 7 | cache.insert(1, 2, CacheExpiration::none()).await; 8 | cache.insert(2, 2, CacheExpiration::none()).await; 9 | cache.insert(3, 3, CacheExpiration::none()).await; 10 | 11 | assert_eq!(cache.len().await, 3); 12 | assert_eq!(cache.expired().await, 0); 13 | assert_eq!(cache.unexpired().await, 3); 14 | 15 | cache.clear().await; 16 | 17 | assert_eq!(cache.len().await, 0); 18 | assert_eq!(cache.expired().await, 0); 19 | assert_eq!(cache.unexpired().await, 0); 20 | } 21 | 22 | #[tokio::test] 23 | async fn test_cache_update_operations() { 24 | let cache = Cache::::new(); 25 | 26 | cache.insert(1, 1, CacheExpiration::none()).await; 27 | 28 | assert_eq!(cache.get(&1).await.unwrap().value(), &1); 29 | 30 | cache 31 | .update(&1, |value| { 32 | *value = 5; 33 | }) 34 | .await; 35 | 36 | assert_eq!(cache.get(&1).await.unwrap().value(), &5); 37 | } 38 | 39 | #[tokio::test] 40 | async fn test_cache_borrow_types() { 41 | let key = "key".to_string(); 42 | let cache = Cache::::new(); 43 | 44 | cache 45 | .insert(key.clone(), true, CacheExpiration::none()) 46 | .await; 47 | 48 | let lookup: &str = &key; 49 | 50 | assert!(cache.get(lookup).await.unwrap().value()); 51 | } 52 | -------------------------------------------------------------------------------- /tests/runtimes.rs: -------------------------------------------------------------------------------- 1 | use async_io::Timer; 2 | use async_std::task; 3 | use retainer::*; 4 | 5 | use std::sync::Arc; 6 | use std::time::{Duration, Instant}; 7 | 8 | #[async_std::test] 9 | async fn test_async_std() { 10 | // construct our cache 11 | let cache = Arc::new(Cache::new()); 12 | let clone = cache.clone(); 13 | 14 | // spawn the monitor 15 | task::spawn(async move { 16 | // don't forget to monitor your cache to evict entries 17 | clone.monitor(25, 0.25, Duration::from_secs(1)).await 18 | }); 19 | 20 | // execute the set of base tests 21 | execute_base_test(cache).await 22 | } 23 | 24 | #[test] 25 | fn test_smol() { 26 | smol::block_on(async { 27 | // construct our cache 28 | let cache = Arc::new(Cache::new()); 29 | let clone = cache.clone(); 30 | 31 | // spawn the monitor 32 | let handle = smol::spawn(async move { 33 | // don't forget to monitor your cache to evict entries 34 | clone.monitor(25, 0.25, Duration::from_secs(1)).await 35 | }); 36 | 37 | // execute the set of base tests 38 | execute_base_test(cache).await; 39 | 40 | // cancel the monitor 41 | handle.cancel().await; 42 | }); 43 | } 44 | 45 | #[tokio::test] 46 | async fn test_tokio() { 47 | // construct our cache 48 | let cache = Arc::new(Cache::new()); 49 | let clone = cache.clone(); 50 | 51 | // spawn the monitor 52 | let monitor = tokio::spawn(async move { 53 | // don't forget to monitor your cache to evict entries 54 | clone.monitor(3, 0.25, Duration::from_secs(3)).await 55 | }); 56 | 57 | // execute the set of base tests 58 | execute_base_test(cache).await; 59 | 60 | // shutdown monitor 61 | monitor.abort(); 62 | } 63 | 64 | async fn execute_base_test(cache: Arc>) { 65 | // insert using an `Instant` type to specify expiration 66 | cache.insert("one", 1, Instant::now()).await; 67 | 68 | // insert using a `Duration` type to wait before expiration 69 | cache.insert("two", 2, Duration::from_secs(2)).await; 70 | 71 | // insert using a number of milliseconds 72 | cache.insert("three", 3, 3500).await; 73 | 74 | // insert using a random number of milliseconds 75 | cache.insert("four", 4, 3500..5000).await; 76 | 77 | // insert without expiration (i.e. manual removal) 78 | cache.insert("five", 5, CacheExpiration::none()).await; 79 | 80 | // wait until the monitor has run once 81 | Timer::after(Duration::from_millis(3250)).await; 82 | 83 | // the first two keys should have been removed 84 | assert!(cache.get(&"one").await.is_none()); 85 | assert!(cache.get(&"two").await.is_none()); 86 | 87 | // the rest should be there still for now 88 | assert!(cache.get(&"three").await.is_some()); 89 | assert!(cache.get(&"four").await.is_some()); 90 | assert!(cache.get(&"five").await.is_some()); 91 | 92 | // wait until the monitor has run again 93 | Timer::after(Duration::from_millis(3250)).await; 94 | 95 | // the other two keys should have been removed 96 | assert!(cache.get(&"three").await.is_none()); 97 | assert!(cache.get(&"four").await.is_none()); 98 | 99 | // the key with no expiration should still exist 100 | assert!(cache.get(&"five").await.is_some()); 101 | 102 | // but we should be able to manually remove it 103 | assert!(cache.remove(&"five").await.is_some()); 104 | assert!(cache.get(&"five").await.is_none()); 105 | 106 | // and now our cache should be empty 107 | assert!(cache.is_empty().await); 108 | } 109 | --------------------------------------------------------------------------------