//! Open-file cache (analogous to nginx's `open_file_cache`).
//!
//! Repeated requests for the same static file are extremely common (it is
//! exactly what HTTP benchmarks do). Without a cache, every request pays
//! for an `open(2)`, a `stat(2)`, a MIME-type lookup, and the allocation of
//! the `ETag` / `Last-Modified` header strings.
//!
//! [`FileCache`] keeps a small map of recently served files, holding an
//! already-open, read-only file descriptor together with the precomputed
//! response metadata. Within a short validity window a cache hit serves the
//! file with **zero** filesystem syscalls -- only the final `sendfile(2)`
//! remains. The open descriptor can be shared across concurrent requests
//! because `sendfile` is driven with an explicit offset and never touches
//! the descriptor's own file offset.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::RwLock;
use crate::http::response::CachedResponse;
/// An open file together with the precomputed `200 OK` response for it.
#[derive(Debug)]
pub struct CachedFile {
/// File size in bytes.
pub size: u64,
/// Precomputed `ETag` value (for conditional-request handling).
pub etag: String,
/// Precomputed `Last-Modified` header value.
pub last_modified: String,
/// Fully serialized, ready-to-send response (and the open descriptor for
/// the `sendfile` path).
pub response: Arc<CachedResponse>,
}
#[derive(Debug)]
struct Entry {
file: Arc<CachedFile>,
expires: Instant,
}
/// A bounded, time-validated cache of open files.
#[derive(Debug)]
pub struct FileCache {
map: RwLock<HashMap<PathBuf, Entry>>,
ttl: Duration,
max_entries: usize,
}
impl FileCache {
/// Create a cache whose entries remain valid for `ttl` and which holds
/// at most `max_entries` distinct files.
pub fn new(ttl: Duration, max_entries: usize) -> Self {
Self {
map: RwLock::new(HashMap::new()),
ttl,
max_entries,
}
}
/// Return a still-valid cached entry for `path`, or `None` if absent or
/// expired. This performs no filesystem syscalls on a hit.
pub fn get(&self, path: &Path) -> Option<Arc<CachedFile>> {
let map = self.map.read();
let entry = map.get(path)?;
if entry.expires > Instant::now() {
Some(entry.file.clone())
} else {
None
}
}
/// Insert (or refresh) an entry for `path`.
pub fn insert(&self, path: PathBuf, file: Arc<CachedFile>) {
let mut map = self.map.write();
// Simple bound: when full and inserting a new key, drop expired
// entries first; if still full, clear. Static workloads touch a
// handful of files, so this is rarely exercised.
if map.len() >= self.max_entries && !map.contains_key(&path) {
let now = Instant::now();
map.retain(|_, e| e.expires > now);
if map.len() >= self.max_entries {
map.clear();
}
}
map.insert(
path,
Entry {
file,
expires: Instant::now() + self.ttl,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use std::fs::File;
fn dummy(size: u64) -> Arc<CachedFile> {
let f = File::open("Cargo.toml")
.or_else(|_| File::open(file!()))
.unwrap();
Arc::new(CachedFile {
size,
etag: "a-b".into(),
last_modified: "now".into(),
response: Arc::new(CachedResponse {
full: Some(Bytes::from_static(b"x")),
header_block: Bytes::from_static(b"HTTP/1.1 200 OK\r\n\r\n"),
file: Arc::new(f),
size,
}),
})
}
#[test]
fn miss_then_hit() {
let cache = FileCache::new(Duration::from_secs(60), 8);
let p = PathBuf::from("/tmp/x");
assert!(cache.get(&p).is_none());
cache.insert(p.clone(), dummy(1));
assert!(cache.get(&p).is_some());
}
#[test]
fn expiry() {
let cache = FileCache::new(Duration::from_millis(1), 8);
let p = PathBuf::from("/tmp/y");
cache.insert(p.clone(), dummy(1));
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get(&p).is_none());
}
}