Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / veld / src / handler / file_cache.rs
//! 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());
    }
}