add response cache with TTL + Cache-Control parsing

- New cache.rs: FIFO-eviction cache with max_bytes cap
- Cacheable: GET/HEAD only, no-store/no-cache/private/Set-Cookie reject
- TTL from Cache-Control: max-age=, or heuristics by extension (css/js/fonts/images -> 1h)
- Hook in DomainFronter::relay: check cache before network, store after 2xx
- 10 new unit tests (23 total)
This commit is contained in:
therealaleph
2026-04-21 18:18:21 +03:00
parent 00e0d411fc
commit 52d00312ab
3 changed files with 303 additions and 7 deletions
+273
View File
@@ -0,0 +1,273 @@
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const DEFAULT_MAX_BYTES: usize = 50 * 1024 * 1024;
const MAX_ENTRY_FRACTION: usize = 4;
pub struct ResponseCache {
inner: Mutex<Inner>,
max_bytes: usize,
hits: AtomicU64,
misses: AtomicU64,
}
struct Inner {
entries: HashMap<String, CachedResponse>,
order: VecDeque<String>,
size: usize,
}
struct CachedResponse {
bytes: Vec<u8>,
expires: Instant,
}
impl ResponseCache {
pub fn new(max_bytes: usize) -> Self {
Self {
inner: Mutex::new(Inner {
entries: HashMap::new(),
order: VecDeque::new(),
size: 0,
}),
max_bytes,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
pub fn with_default() -> Self {
Self::new(DEFAULT_MAX_BYTES)
}
pub fn get(&self, key: &str) -> Option<Vec<u8>> {
let now = Instant::now();
let mut inner = self.inner.lock().unwrap();
if let Some(entry) = inner.entries.get(key) {
if entry.expires > now {
self.hits.fetch_add(1, Ordering::Relaxed);
return Some(entry.bytes.clone());
}
let size = entry.bytes.len();
inner.entries.remove(key);
inner.order.retain(|k| k != key);
inner.size = inner.size.saturating_sub(size);
}
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
pub fn put(&self, key: String, bytes: Vec<u8>, ttl: Duration) {
let size = bytes.len();
if size == 0 || size > self.max_bytes / MAX_ENTRY_FRACTION {
return;
}
let expires = Instant::now() + ttl;
let mut inner = self.inner.lock().unwrap();
if let Some(old) = inner.entries.remove(&key) {
inner.size = inner.size.saturating_sub(old.bytes.len());
inner.order.retain(|k| k != &key);
}
while inner.size + size > self.max_bytes {
let Some(oldest_key) = inner.order.pop_front() else {
break;
};
if let Some(removed) = inner.entries.remove(&oldest_key) {
inner.size = inner.size.saturating_sub(removed.bytes.len());
}
}
inner.entries.insert(key.clone(), CachedResponse { bytes, expires });
inner.order.push_back(key);
inner.size += size;
}
pub fn hits(&self) -> u64 {
self.hits.load(Ordering::Relaxed)
}
pub fn misses(&self) -> u64 {
self.misses.load(Ordering::Relaxed)
}
pub fn size(&self) -> usize {
self.inner.lock().unwrap().size
}
}
pub fn parse_ttl(raw_response: &[u8], url: &str) -> Option<Duration> {
let sep = b"\r\n\r\n";
let hdr_end = raw_response
.windows(sep.len())
.position(|w| w == sep)?;
let hdr = std::str::from_utf8(&raw_response[..hdr_end]).ok()?;
let hdr_lower = hdr.to_ascii_lowercase();
let first_line = hdr_lower.lines().next()?;
if !first_line.starts_with("http/1.1 200") && !first_line.starts_with("http/1.0 200") {
return None;
}
if hdr_lower.contains("no-store") || hdr_lower.contains("no-cache") || hdr_lower.contains("private") {
return None;
}
if hdr_lower.contains("set-cookie:") {
return None;
}
if let Some(pos) = hdr_lower.find("max-age=") {
let rest = &hdr_lower[pos + 8..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if let Ok(secs) = rest[..end].parse::<u64>() {
if secs == 0 {
return None;
}
return Some(Duration::from_secs(secs.min(86400)));
}
}
let path_no_query = url.split('?').next().unwrap_or(url).to_ascii_lowercase();
const STATIC_EXTS: &[&str] = &[
".css", ".js", ".mjs", ".woff", ".woff2", ".ttf", ".otf", ".eot",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".avif",
".mp3", ".mp4", ".wasm", ".webm", ".ogg",
];
for ext in STATIC_EXTS {
if path_no_query.ends_with(ext) {
return Some(Duration::from_secs(3600));
}
}
let ct_key = "content-type:";
if let Some(pos) = hdr_lower.find(ct_key) {
let rest = &hdr_lower[pos + ct_key.len()..];
let line_end = rest.find('\r').unwrap_or(rest.len());
let ct = &rest[..line_end];
if ct.contains("image/") || ct.contains("font/") {
return Some(Duration::from_secs(3600));
}
if ct.contains("text/css") || ct.contains("javascript") || ct.contains("application/wasm") {
return Some(Duration::from_secs(1800));
}
}
None
}
pub fn is_cacheable_method(method: &str) -> bool {
matches!(
method.to_ascii_uppercase().as_str(),
"GET" | "HEAD"
)
}
pub fn cache_key(method: &str, url: &str) -> String {
format!("{}:{}", method.to_ascii_uppercase(), url)
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_resp(headers: &str, body: &str) -> Vec<u8> {
let mut r = Vec::new();
r.extend_from_slice(headers.as_bytes());
r.extend_from_slice(b"\r\n\r\n");
r.extend_from_slice(body.as_bytes());
r
}
#[test]
fn get_miss_then_put_then_hit() {
let c = ResponseCache::new(1024);
assert!(c.get("k").is_none());
c.put("k".into(), b"hello".to_vec(), Duration::from_secs(60));
assert_eq!(c.get("k").unwrap(), b"hello");
assert_eq!(c.hits(), 1);
assert_eq!(c.misses(), 1);
}
#[test]
fn expired_entry_is_removed_on_get() {
let c = ResponseCache::new(1024);
c.put("k".into(), b"hi".to_vec(), Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
assert!(c.get("k").is_none());
assert_eq!(c.size(), 0);
}
#[test]
fn too_large_entry_rejected() {
let c = ResponseCache::new(100);
c.put("k".into(), vec![0u8; 60], Duration::from_secs(60));
assert!(c.get("k").is_none());
}
#[test]
fn fifo_eviction_when_full() {
let c = ResponseCache::new(1000);
c.put("a".into(), vec![0u8; 200], Duration::from_secs(60));
c.put("b".into(), vec![0u8; 200], Duration::from_secs(60));
c.put("c".into(), vec![0u8; 200], Duration::from_secs(60));
c.put("d".into(), vec![0u8; 200], Duration::from_secs(60));
c.put("e".into(), vec![0u8; 200], Duration::from_secs(60));
c.put("f".into(), vec![0u8; 200], Duration::from_secs(60));
assert!(c.get("a").is_none());
assert!(c.get("f").is_some());
}
#[test]
fn max_age_parsed() {
let raw = mk_resp(
"HTTP/1.1 200 OK\r\nCache-Control: public, max-age=300\r\nContent-Type: text/html",
"body",
);
let ttl = parse_ttl(&raw, "http://example.com/page").unwrap();
assert_eq!(ttl, Duration::from_secs(300));
}
#[test]
fn no_store_rejects_cache() {
let raw = mk_resp(
"HTTP/1.1 200 OK\r\nCache-Control: no-store\r\nContent-Type: text/css",
"body",
);
assert!(parse_ttl(&raw, "http://x.com/a.css").is_none());
}
#[test]
fn static_extension_heuristic() {
let raw = mk_resp("HTTP/1.1 200 OK\r\nContent-Type: text/css", "body");
let ttl = parse_ttl(&raw, "http://x.com/style.css").unwrap();
assert_eq!(ttl, Duration::from_secs(3600));
}
#[test]
fn set_cookie_rejects_cache() {
let raw = mk_resp(
"HTTP/1.1 200 OK\r\nSet-Cookie: a=b\r\nCache-Control: max-age=600",
"body",
);
assert!(parse_ttl(&raw, "http://x.com/page").is_none());
}
#[test]
fn non_200_rejected() {
let raw = mk_resp("HTTP/1.1 404 Not Found\r\nCache-Control: max-age=600", "body");
assert!(parse_ttl(&raw, "http://x.com/page").is_none());
}
#[test]
fn method_check() {
assert!(is_cacheable_method("GET"));
assert!(is_cacheable_method("get"));
assert!(is_cacheable_method("HEAD"));
assert!(!is_cacheable_method("POST"));
assert!(!is_cacheable_method("DELETE"));
}
}
+29 -7
View File
@@ -6,10 +6,8 @@
//! `/macros/s/{script_id}/exec`. Apps Script performs the actual upstream //! `/macros/s/{script_id}/exec`. Apps Script performs the actual upstream
//! HTTP fetch server-side and returns a JSON envelope. //! HTTP fetch server-side and returns a JSON envelope.
//! //!
//! TODO(mvp): add HTTP/2 multiplexing (`h2` crate) for lower latency. //! TODO: add HTTP/2 multiplexing (`h2` crate) for lower latency.
//! TODO(mvp): add fetchAll batching — group concurrent relay calls. //! TODO: add parallel range-based downloads.
//! TODO(mvp): add request coalescing for concurrent identical GETs.
//! TODO(mvp): add response cache and parallel range-based downloads.
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -30,6 +28,7 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server
use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use crate::cache::{cache_key, is_cacheable_method, parse_ttl, ResponseCache};
use crate::config::Config; use crate::config::Config;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -69,6 +68,7 @@ pub struct DomainFronter {
script_idx: AtomicUsize, script_idx: AtomicUsize,
tls_connector: TlsConnector, tls_connector: TlsConnector,
pool: Arc<Mutex<Vec<PoolEntry>>>, pool: Arc<Mutex<Vec<PoolEntry>>>,
cache: Arc<ResponseCache>,
} }
/// Request payload sent to Apps Script (single, non-batch). /// Request payload sent to Apps Script (single, non-batch).
@@ -128,9 +128,14 @@ impl DomainFronter {
script_idx: AtomicUsize::new(0), script_idx: AtomicUsize::new(0),
tls_connector, tls_connector,
pool: Arc::new(Mutex::new(Vec::new())), pool: Arc::new(Mutex::new(Vec::new())),
cache: Arc::new(ResponseCache::with_default()),
}) })
} }
pub fn cache(&self) -> &ResponseCache {
&self.cache
}
fn next_script_id(&self) -> &str { fn next_script_id(&self) -> &str {
let idx = self.script_idx.fetch_add(1, Ordering::Relaxed); let idx = self.script_idx.fetch_add(1, Ordering::Relaxed);
&self.script_ids[idx % self.script_ids.len()] &self.script_ids[idx % self.script_ids.len()]
@@ -182,7 +187,16 @@ impl DomainFronter {
headers: &[(String, String)], headers: &[(String, String)],
body: &[u8], body: &[u8],
) -> Vec<u8> { ) -> Vec<u8> {
match timeout( let cacheable = is_cacheable_method(method) && body.is_empty();
let key = if cacheable { Some(cache_key(method, url)) } else { None };
if let Some(ref k) = key {
if let Some(hit) = self.cache.get(k) {
tracing::debug!("cache hit: {}", url);
return hit;
}
}
let bytes = match timeout(
Duration::from_secs(REQUEST_TIMEOUT_SECS), Duration::from_secs(REQUEST_TIMEOUT_SECS),
self.do_relay_with_retry(method, url, headers, body), self.do_relay_with_retry(method, url, headers, body),
) )
@@ -191,13 +205,21 @@ impl DomainFronter {
Ok(Ok(bytes)) => bytes, Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::error!("Relay failed: {}", e); tracing::error!("Relay failed: {}", e);
error_response(502, &format!("Relay error: {}", e)) return error_response(502, &format!("Relay error: {}", e));
} }
Err(_) => { Err(_) => {
tracing::error!("Relay timeout"); tracing::error!("Relay timeout");
error_response(504, "Relay timeout") return error_response(504, "Relay timeout");
}
};
if let Some(k) = key {
if let Some(ttl) = parse_ttl(&bytes, url) {
tracing::debug!("cache store: {} ttl={}s", url, ttl.as_secs());
self.cache.put(k, bytes.clone(), ttl);
} }
} }
bytes
} }
async fn do_relay_with_retry( async fn do_relay_with_retry(
+1
View File
@@ -1,5 +1,6 @@
#![allow(dead_code)] #![allow(dead_code)]
mod cache;
mod cert_installer; mod cert_installer;
mod config; mod config;
mod domain_fronter; mod domain_fronter;