use reqwest::header::HeaderMap;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub(crate) struct CacheEntry {
pub(crate) etag: Option<String>,
pub(crate) last_modified: Option<String>,
pub(crate) body: String,
pub(crate) headers: HeaderMap,
pub(crate) expires_at: i64,
pub(crate) last_used: i64,
}
impl CacheEntry {
fn can_revalidate(&self) -> bool {
self.etag.is_some() || self.last_modified.is_some()
}
fn eviction_group(&self, now: i64) -> u8 {
match (self.expires_at <= now, self.can_revalidate()) {
(true, false) => 0,
(true, true) => 1,
(false, _) => 2,
}
}
}
impl CacheEntry {
fn size(&self) -> usize {
self.body.len()
+ self.etag.as_deref().map_or(0, str::len)
+ self.last_modified.as_deref().map_or(0, str::len)
+ self
.headers
.iter()
.map(|(name, value)| name.as_str().len() + value.len())
.sum::<usize>()
}
}
pub(crate) const DEFAULT_MAX_ENTRIES: usize = 1024;
#[derive(Debug)]
pub(crate) struct ResponseCache {
entries: HashMap<String, CacheEntry>,
max_entries: usize,
max_bytes: Option<usize>,
bytes: usize,
clock: i64,
}
impl Default for ResponseCache {
fn default() -> Self {
Self::new(DEFAULT_MAX_ENTRIES)
}
}
impl ResponseCache {
pub(crate) fn new(max_entries: usize) -> Self {
Self::with_limits(max_entries, None)
}
pub(crate) fn with_limits(max_entries: usize, max_bytes: Option<usize>) -> Self {
ResponseCache {
entries: HashMap::new(),
max_entries: max_entries.max(1),
max_bytes,
bytes: 0,
clock: 0,
}
}
pub(crate) fn key(
token: Option<&str>,
variant: &str,
url: &str,
query: &[(&str, &str)],
) -> String {
let mut pairs: Vec<String> = query.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.sort();
format!(
"{}|{variant}|{url}?{}",
token.unwrap_or(""),
pairs.join("&")
)
}
#[cfg(test)]
pub(crate) fn get(&self, key: &str) -> Option<&CacheEntry> {
self.entries.get(key)
}
pub(crate) fn lookup(&mut self, key: &str) -> Option<CacheEntry> {
let stamp = self.tick();
let entry = self.entries.get_mut(key)?;
entry.last_used = stamp;
Some(entry.clone())
}
pub(crate) fn insert(&mut self, key: String, mut entry: CacheEntry, now: i64) {
self.remove(&key);
let size = key.len() + entry.size();
if self.max_bytes.is_some_and(|max| size > max) {
return;
}
while self.entries.len() >= self.max_entries
|| self.max_bytes.is_some_and(|max| self.bytes + size > max)
{
if !self.evict_one(now) {
break;
}
}
entry.last_used = self.tick();
self.bytes += size;
self.entries.insert(key, entry);
}
pub(crate) fn refresh(&mut self, key: &str, expires_at: i64) {
let stamp = self.tick();
if let Some(entry) = self.entries.get_mut(key) {
entry.expires_at = expires_at;
entry.last_used = stamp;
}
}
fn evict_one(&mut self, now: i64) -> bool {
let victim = self
.entries
.iter()
.min_by_key(|(_, e)| (e.eviction_group(now), e.last_used))
.map(|(k, _)| k.clone());
victim.is_some_and(|key| self.remove(&key))
}
fn remove(&mut self, key: &str) -> bool {
match self.entries.remove(key) {
Some(entry) => {
self.bytes -= key.len() + entry.size();
true
}
None => false,
}
}
fn tick(&mut self) -> i64 {
self.clock += 1;
self.clock
}
pub(crate) fn max_age(headers: &HeaderMap) -> Option<i64> {
let value = headers.get("cache-control")?.to_str().ok()?;
value
.split(',')
.filter_map(|d| d.trim().strip_prefix("max-age="))
.find_map(|v| v.trim().parse().ok())
}
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
pub(crate) fn bytes(&self) -> usize {
self.bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
impl ResponseCache {
fn entry(expires_at: i64, etag: bool) -> CacheEntry {
CacheEntry {
etag: etag.then(|| "\"x\"".to_owned()),
last_modified: None,
body: "[]".into(),
headers: HeaderMap::new(),
expires_at,
last_used: 0,
}
}
}
#[test]
fn test_key_ignores_query_order() {
let a = ResponseCache::key(Some("t"), "en", "u", &[("a", "1"), ("b", "2")]);
let b = ResponseCache::key(Some("t"), "en", "u", &[("b", "2"), ("a", "1")]);
assert_eq!(a, b);
assert_ne!(
a,
ResponseCache::key(None, "en", "u", &[("a", "1"), ("b", "2")])
);
assert_ne!(
a,
ResponseCache::key(Some("t"), "de", "u", &[("a", "1"), ("b", "2")])
);
}
#[test]
fn test_max_age() {
let mut headers = HeaderMap::new();
assert_eq!(ResponseCache::max_age(&headers), None);
headers.insert("cache-control", "public, max-age=300".parse().unwrap());
assert_eq!(ResponseCache::max_age(&headers), Some(300));
}
#[test]
fn test_insert_and_refresh() {
let mut cache = ResponseCache::default();
cache.insert("k".into(), ResponseCache::entry(10, true), 0);
cache.refresh("k", 99);
assert_eq!(cache.get("k").unwrap().expires_at, 99);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_nothing_is_dropped_while_there_is_room() {
let mut cache = ResponseCache::new(3);
cache.insert("expired".into(), ResponseCache::entry(1, false), 100);
cache.insert("live".into(), ResponseCache::entry(1_000, true), 100);
assert_eq!(cache.len(), 2);
assert!(cache.get("expired").is_some());
}
#[test]
fn test_full_cache_evicts_by_group_then_least_recently_used() {
let now = 100;
let mut cache = ResponseCache::new(4);
cache.insert("live_old".into(), ResponseCache::entry(1_000, true), now);
cache.insert("expired_etag".into(), ResponseCache::entry(5, true), now);
cache.insert("expired_plain".into(), ResponseCache::entry(5, false), now);
cache.insert("live_new".into(), ResponseCache::entry(1_000, true), now);
cache.insert("a".into(), ResponseCache::entry(1_000, true), now);
assert!(cache.get("expired_plain").is_none());
assert!(cache.get("expired_etag").is_some());
cache.insert("b".into(), ResponseCache::entry(1_000, true), now);
assert!(cache.get("expired_etag").is_none());
assert!(cache.get("live_old").is_some());
cache.insert("c".into(), ResponseCache::entry(1_000, true), now);
assert!(cache.get("live_old").is_none());
assert_eq!(cache.len(), 4);
}
#[test]
fn test_reads_and_refreshes_protect_an_entry() {
let now = 100;
let mut cache = ResponseCache::new(3);
for key in ["a", "b", "c"] {
cache.insert(key.into(), ResponseCache::entry(1_000, true), now);
}
assert!(cache.lookup("a").is_some());
cache.insert("d".into(), ResponseCache::entry(1_000, true), now);
assert!(cache.get("b").is_none());
assert!(cache.get("a").is_some());
cache.refresh("a", 2_000);
cache.refresh("d", 2_000);
cache.insert("e".into(), ResponseCache::entry(1_000, true), now);
assert!(cache.get("c").is_none());
assert_eq!(cache.len(), 3);
}
fn sized(body_len: usize, expires_at: i64, etag: bool) -> CacheEntry {
CacheEntry {
body: "x".repeat(body_len),
..ResponseCache::entry(expires_at, etag)
}
}
#[test]
fn test_byte_limit_evicts_in_the_same_order() {
let one = 1 + 3 + 10;
let mut cache = ResponseCache::with_limits(100, Some(3 * one));
cache.insert("a".into(), sized(10, 1_000, true), 100);
cache.insert("b".into(), sized(10, 5, true), 100);
cache.insert("c".into(), sized(10, 5, false), 100);
assert_eq!(cache.bytes(), 3 * one - 3);
cache.insert("d".into(), sized(10, 1_000, true), 100);
assert!(cache.get("c").is_none() && cache.get("b").is_some());
cache.insert("e".into(), sized(10, 1_000, true), 100);
assert!(cache.get("b").is_none() && cache.get("a").is_some());
assert!(cache.bytes() <= 3 * one);
}
#[test]
fn test_a_large_entry_evicts_as_many_as_it_needs() {
let mut cache = ResponseCache::with_limits(100, Some(90));
for key in ["a", "b", "c"] {
cache.insert(key.into(), sized(10, 1_000, true), 0);
}
cache.insert("big".into(), sized(60, 1_000, true), 0);
assert!(cache.get("a").is_none() && cache.get("b").is_none());
assert!(cache.get("c").is_some() && cache.get("big").is_some());
assert_eq!(cache.bytes(), 14 + 66);
}
#[test]
fn test_an_entry_over_the_byte_limit_is_not_stored() {
let mut cache = ResponseCache::with_limits(100, Some(50));
cache.insert("a".into(), sized(10, 1_000, true), 0);
cache.insert("a".into(), sized(500, 1_000, true), 0);
assert_eq!((cache.len(), cache.bytes()), (0, 0));
cache.insert("b".into(), sized(10, 1_000, true), 0);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_replacing_adjusts_the_byte_count() {
let mut cache = ResponseCache::with_limits(100, Some(1_000));
cache.insert("a".into(), sized(10, 1_000, true), 0);
let before = cache.bytes();
cache.insert("a".into(), sized(30, 1_000, true), 0);
assert_eq!(cache.bytes(), before + 20);
cache.refresh("a", 5);
assert_eq!(cache.bytes(), before + 20);
}
#[test]
fn test_replacing_a_key_never_evicts() {
let mut cache = ResponseCache::new(2);
cache.insert("a".into(), ResponseCache::entry(1_000, true), 0);
cache.insert("b".into(), ResponseCache::entry(1_000, true), 0);
cache.insert("a".into(), ResponseCache::entry(2_000, true), 0);
assert_eq!(cache.len(), 2);
assert!(cache.get("b").is_some());
}
}