Skip to main content

static_web_server/mem_cache/
cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! It provides in-memory files cache functionality with expiration policy support
7//! such as Time to live (TTL) and Time to idle (TTI).
8//!
9//! Admission to a cache is controlled by the Least Frequently Used (LFU) policy
10//! and the eviction from a cache is controlled by the Least Recently Used (LRU) policy.
11//!
12
13use bytes::Bytes;
14use compact_str::CompactString;
15use headers::{AcceptRanges, ContentLength, ContentRange, HeaderMap, HeaderMapExt, LastModified};
16use hyper::header::{CONTENT_TYPE, ETAG, HeaderName, HeaderValue};
17use hyper::{Response, StatusCode};
18use mini_moka::sync::Cache;
19use std::path::Path;
20use std::sync::{Arc, OnceLock};
21use std::time::Duration;
22
23use crate::Result;
24use crate::body::{self, Body};
25use crate::conditional_headers::{ConditionalBody, ConditionalHeaders, Validators};
26use crate::handler::RequestHandlerOpts;
27use crate::response::range::{BadRangeError, bytes_range};
28
29/// Global cache that stores all files in memory.
30/// It provides expiration policies like Time to live (TTL) and Time to idle (TTI) support.
31pub(crate) static CACHE_STORE: OnceLock<Cache<CompactString, Arc<MemFile>>> = OnceLock::new();
32
33/// Standard `X-Cache` header to indicate whether a response was served from cache.
34pub(crate) static X_CACHE: HeaderName = HeaderName::from_static("x-cache");
35/// Value for the `X-Cache` header when the response is a cache hit.
36pub(crate) static X_CACHE_HIT: HeaderValue = HeaderValue::from_static("HIT");
37
38/// It defines the in-memory files cache options.
39pub struct MemCacheOpts {
40    /// The maximum size per file in bytes.
41    pub max_file_size: u64,
42}
43
44/// Default capacity (number of entries).
45pub const DEFAULT_CAPACITY: u64 = 100;
46/// Default time-to-live in seconds (30 minutes).
47pub const DEFAULT_TTL: u64 = 1800;
48/// Default time-to-idle in seconds (5 minutes).
49pub const DEFAULT_TTI: u64 = 300;
50/// Default maximum file size in KiB (8 MiB = 8192 KiB).
51pub const DEFAULT_MAX_FILE_SIZE: u64 = 8192;
52
53/// Maximum allowed capacity (entries).
54const MAX_CAPACITY: u64 = 100_000;
55/// Maximum allowed TTL in seconds (24 hours).
56const MAX_TTL: u64 = 86_400;
57/// Maximum allowed TTI in seconds (1 hour).
58const MAX_TTI: u64 = 3_600;
59/// Maximum allowed file size in KiB (32 MiB = 32768 KiB).
60const MAX_FILE_SIZE: u64 = 32_768;
61
62impl MemCacheOpts {
63    /// Creates a new instance of `MemCacheOpts`.
64    /// `max_file_size` is in KiB and gets converted to bytes internally.
65    #[inline]
66    pub fn new(max_file_size: u64) -> Self {
67        Self {
68            max_file_size: max_file_size * 1024,
69        }
70    }
71}
72
73/// Initialize the in-memory cache store from handler options.
74///
75/// If `[advanced.memory-cache]` is present in the configuration, a cache store
76/// is created with the specified (or default) parameters. Values exceeding
77/// their allowed maximums are clamped silently.
78pub fn init(handler_opts: &mut RequestHandlerOpts) -> Result {
79    if let Some(advanced_opts) = handler_opts.advanced_opts.as_ref()
80        && let Some(opts) = advanced_opts.memory_cache.as_ref()
81    {
82        let capacity = opts.capacity.unwrap_or(DEFAULT_CAPACITY).min(MAX_CAPACITY);
83        let ttl = opts.ttl.unwrap_or(DEFAULT_TTL).min(MAX_TTL);
84        let tti = opts.tti.unwrap_or(DEFAULT_TTI).min(MAX_TTI);
85        let max_file_size = opts
86            .max_file_size
87            .unwrap_or(DEFAULT_MAX_FILE_SIZE)
88            .min(MAX_FILE_SIZE);
89
90        tracing::info!(
91            enabled = true,
92            capacity,
93            ttl_seconds = ttl,
94            tti_seconds = tti,
95            max_file_size_kib = max_file_size,
96            "in-memory cache"
97        );
98
99        let mem_opts = MemCacheOpts::new(max_file_size);
100
101        let cache = Cache::builder()
102            .max_capacity(capacity)
103            .time_to_live(Duration::from_secs(ttl))
104            .time_to_idle(Duration::from_secs(tti))
105            .build();
106
107        if CACHE_STORE.set(cache).is_err() {
108            tracing::debug!("in-memory cache store already initialized; reusing existing store");
109        }
110
111        handler_opts.memory_cache = Some(mem_opts);
112
113        return Ok(());
114    }
115
116    tracing::info!(enabled = false, "in-memory cache");
117
118    Ok(())
119}
120
121/// Try to get a cached response for the given file path.
122///
123/// Returns `Some(result)` on a cache hit (the result itself may be an error
124/// status, e.g. for a malformed `Range` header) or `None` when the cache is
125/// disabled, the path is non-UTF-8, or there is no entry yet (cache miss).
126///
127/// The caller is responsible for reading the file from disk on a miss and
128/// inserting it into the cache via the streaming pipeline. There is no
129/// single-flight serialization: mini-moka's `Cache` is concurrency-safe and
130/// duplicate inserts under contention are benign and rare in practice.
131pub(crate) fn lookup(
132    file_path: &Path,
133    headers_opt: &HeaderMap,
134) -> Option<Result<Response<Body>, StatusCode>> {
135    let file_path_str = file_path.to_str()?;
136    let store = CACHE_STORE.get()?;
137    let key = CompactString::from(file_path_str);
138    let mem_file = store.get(&key)?;
139    tracing::debug!("file `{file_path_str}` served from the in-memory cache store");
140    // Tag the response with `X-Cache: HIT` so clients and tooling can
141    // identify that it was served from the in-memory cache.
142    Some(mem_file.response_body(headers_opt).map(|mut resp| {
143        resp.headers_mut()
144            .insert(X_CACHE.clone(), X_CACHE_HIT.clone());
145        resp
146    }))
147}
148
149#[derive(Debug, Clone)]
150pub(crate) struct MemFileTempOpts {
151    pub(crate) file_path: String,
152    /// Pre-built `Content-Type` `HeaderValue`. Reusing a `HeaderValue`
153    /// (instead of [`ContentType`]) avoids re-encoding the mime string
154    /// when the entry is eventually inserted into the cache.
155    pub(crate) content_type: HeaderValue,
156    pub(crate) last_modified: Option<LastModified>,
157    /// Pre-built weak `ETag` value. Built once on the disk path and
158    /// reused on every cache hit (refcount-clone only).
159    pub(crate) etag: Option<HeaderValue>,
160}
161
162impl MemFileTempOpts {
163    pub(crate) fn new(
164        file_path: String,
165        content_type: HeaderValue,
166        last_modified: Option<LastModified>,
167        etag: Option<HeaderValue>,
168    ) -> Self {
169        Self {
170            file_path,
171            content_type,
172            last_modified,
173            etag,
174        }
175    }
176}
177
178/// In-memory file representation to be stored in the cache.
179///
180/// Holds the full file body as a [`Bytes`] (shared, reference-counted, zero-copy
181/// cloneable) and a pre-built `Content-Type` [`HeaderValue`] so that serving a
182/// cached response avoids any per-request allocation or string conversion.
183#[derive(Debug)]
184pub(crate) struct MemFile {
185    /// Bytes of the current file.
186    data: Bytes,
187    /// Pre-built `Content-Type` header value. Stored as a [`HeaderValue`]
188    /// (rather than [`ContentType`]) so that emitting it on a cache hit is a
189    /// cheap reference-counted clone.
190    content_type: HeaderValue,
191    /// `Last-Modified` header for the current file.
192    last_modified: Option<LastModified>,
193    /// Weak `ETag` header value for the cached representation. When
194    /// present it is both emitted on the response and used for
195    /// `If-None-Match` / `If-Match` / `If-Range` evaluation.
196    etag: Option<HeaderValue>,
197}
198
199impl MemFile {
200    #[inline]
201    pub(crate) fn new(
202        data: Bytes,
203        content_type: HeaderValue,
204        last_modified: Option<LastModified>,
205        etag: Option<HeaderValue>,
206    ) -> Self {
207        Self {
208            data,
209            content_type,
210            last_modified,
211            etag,
212        }
213    }
214
215    /// Build a response for a cache hit.
216    ///
217    /// The body is constructed directly from the in-memory [`Bytes`] (a single
218    /// reference-counted clone for full responses, an O(1) `Bytes::slice` for
219    /// range responses). No allocation or copying of the file contents occurs
220    /// on the hot path; the response body is a single data frame, not a
221    /// chunked stream.
222    pub(crate) fn response_body(&self, headers: &HeaderMap) -> Result<Response<Body>, StatusCode> {
223        let conditionals = ConditionalHeaders::new(headers);
224        let modified = self.last_modified;
225
226        // The typed `ETag` is only required when the request carries one
227        // of `If-None-Match`, `If-Match` or `If-Range`. Parsing is cheap
228        // (only on conditional requests) and lazy.
229        let etag_typed: Option<headers::ETag> = if conditionals.if_none_match.is_some()
230            || conditionals.if_match.is_some()
231            || conditionals.if_range.is_some()
232        {
233            self.etag
234                .as_ref()
235                .and_then(|hv| hv.to_str().ok().and_then(|s| s.parse().ok()))
236        } else {
237            None
238        };
239
240        let validators = Validators {
241            last_modified: modified,
242            etag: etag_typed.as_ref(),
243            etag_value: self.etag.as_ref(),
244        };
245
246        match conditionals.check(validators) {
247            ConditionalBody::NoBody(resp) => Ok(resp),
248            ConditionalBody::WithBody(range) => {
249                let total_len = self.data.len() as u64;
250
251                bytes_range(range, total_len)
252                    .map(|(start, end)| {
253                        let sub_len = end - start;
254                        let is_partial = sub_len != total_len;
255
256                        // Zero-copy body: for a full response we clone the
257                        // `Bytes` (refcount bump); for a range we use
258                        // `Bytes::slice` (O(1), shared buffer).
259                        let body_bytes = if is_partial {
260                            self.data.slice(start as usize..end as usize)
261                        } else {
262                            self.data.clone()
263                        };
264                        let mut resp = Response::new(body::full(body_bytes));
265
266                        if is_partial {
267                            *resp.status_mut() = StatusCode::PARTIAL_CONTENT;
268                            match ContentRange::bytes(start..end, total_len) {
269                                Ok(range) => {
270                                    resp.headers_mut().typed_insert(range);
271                                }
272                                Err(err) => {
273                                    tracing::error!("invalid content range error: {:?}", err);
274                                    let mut resp = Response::new(crate::body::empty());
275                                    *resp.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
276                                    resp.headers_mut()
277                                        .typed_insert(ContentRange::unsatisfied_bytes(total_len));
278                                    return Ok(resp);
279                                }
280                            }
281                        }
282
283                        let h = resp.headers_mut();
284                        h.typed_insert(ContentLength(sub_len));
285                        // Cheap refcount clone of the pre-built header value
286                        // (avoids re-stringifying the mime type per request).
287                        h.insert(CONTENT_TYPE, self.content_type.clone());
288                        h.typed_insert(AcceptRanges::bytes());
289
290                        if let Some(last_modified) = modified {
291                            h.typed_insert(last_modified);
292                        }
293                        if let Some(etag) = self.etag.as_ref() {
294                            h.insert(ETAG, etag.clone());
295                        }
296
297                        Ok(resp)
298                    })
299                    .unwrap_or_else(|BadRangeError| {
300                        let mut resp = Response::new(crate::body::empty());
301                        *resp.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
302                        resp.headers_mut()
303                            .typed_insert(ContentRange::unsatisfied_bytes(total_len));
304                        Ok(resp)
305                    })
306            }
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn mem_cache_opts_converts_kib_to_bytes() {
317        let opts = MemCacheOpts::new(8192);
318        // 8192 KiB = 8 MiB = 8_388_608 bytes
319        assert_eq!(opts.max_file_size, 8192 * 1024);
320    }
321
322    #[test]
323    fn mem_cache_opts_zero_size() {
324        let opts = MemCacheOpts::new(0);
325        assert_eq!(opts.max_file_size, 0);
326    }
327
328    #[test]
329    fn default_constants_are_sane() {
330        assert_eq!(DEFAULT_CAPACITY, 100);
331        assert_eq!(DEFAULT_TTL, 1800);
332        assert_eq!(DEFAULT_TTI, 300);
333        assert_eq!(DEFAULT_MAX_FILE_SIZE, 8192);
334    }
335
336    #[test]
337    fn max_constants_enforce_upper_bounds() {
338        // Capacity capped at 100k
339        const {
340            assert!(MAX_CAPACITY >= DEFAULT_CAPACITY);
341        }
342        // TTL capped at 24h
343        const {
344            assert!(MAX_TTL >= DEFAULT_TTL);
345        }
346        // TTI capped at 1h
347        const {
348            assert!(MAX_TTI >= DEFAULT_TTI);
349        }
350        // File size capped at 32 MiB (in KiB)
351        const {
352            assert!(MAX_FILE_SIZE >= DEFAULT_MAX_FILE_SIZE);
353        }
354    }
355
356    #[test]
357    fn init_returns_ok_without_advanced_opts() {
358        let mut handler_opts = crate::handler::RequestHandlerOpts::default();
359        let result = init(&mut handler_opts);
360        assert!(result.is_ok());
361        assert!(handler_opts.memory_cache.is_none());
362    }
363
364    #[test]
365    fn init_returns_ok_without_memory_cache_section() {
366        let mut handler_opts = RequestHandlerOpts {
367            advanced_opts: Some(crate::settings::Advanced {
368                headers: None,
369                rewrites: None,
370                redirects: None,
371                virtual_hosts: None,
372                memory_cache: None,
373            }),
374            ..Default::default()
375        };
376        let result = init(&mut handler_opts);
377        assert!(result.is_ok());
378        assert!(handler_opts.memory_cache.is_none());
379    }
380
381    #[test]
382    fn init_with_defaults_creates_cache() {
383        let mut handler_opts = RequestHandlerOpts {
384            advanced_opts: Some(crate::settings::Advanced {
385                headers: None,
386                rewrites: None,
387                redirects: None,
388                virtual_hosts: None,
389                memory_cache: Some(crate::settings::file::MemoryCache {
390                    capacity: None,
391                    ttl: None,
392                    tti: None,
393                    max_file_size: None,
394                }),
395            }),
396            ..Default::default()
397        };
398        let result = init(&mut handler_opts);
399        assert!(result.is_ok());
400        assert!(handler_opts.memory_cache.is_some());
401        let opts = handler_opts.memory_cache.unwrap();
402        assert_eq!(opts.max_file_size, DEFAULT_MAX_FILE_SIZE * 1024);
403    }
404
405    #[test]
406    fn init_clamps_values_to_max() {
407        // We can't call init() twice due to OnceLock, so test the clamping
408        // logic directly via the min() expressions.
409        let capacity = 999_999u64.min(MAX_CAPACITY);
410        let ttl = 999_999u64.min(MAX_TTL);
411        let tti = 999_999u64.min(MAX_TTI);
412        let max_file_size = 999_999u64.min(MAX_FILE_SIZE);
413
414        assert_eq!(capacity, MAX_CAPACITY);
415        assert_eq!(ttl, MAX_TTL);
416        assert_eq!(tti, MAX_TTI);
417        assert_eq!(max_file_size, MAX_FILE_SIZE);
418
419        let opts = MemCacheOpts::new(max_file_size);
420        assert_eq!(opts.max_file_size, MAX_FILE_SIZE * 1024);
421    }
422
423    #[test]
424    fn lookup_returns_none_when_store_uninitialized() {
425        // When the global `CACHE_STORE` is not initialized (because `init` was
426        // never called with a `[advanced.memory-cache]` section in TOML), the
427        // lookup must short-circuit to `None` so that the regular file pipeline
428        // serves the request without paying any cache overhead.
429        let headers = HeaderMap::new();
430        let path = std::path::Path::new("/nonexistent/path.txt");
431        // Note: this test relies on the cache not being initialized in unit
432        // tests context. If another test in this module ever initializes the
433        // global store, this assertion becomes a hit/miss check instead.
434        if CACHE_STORE.get().is_none() {
435            assert!(lookup(path, &headers).is_none());
436        }
437    }
438
439    #[test]
440    fn x_cache_header_constants_are_valid() {
441        assert_eq!(X_CACHE.as_str(), "x-cache");
442        assert_eq!(X_CACHE_HIT.to_str().unwrap(), "HIT");
443    }
444}