diff --git a/src/http/cache.rs b/src/http/cache.rs new file mode 100644 index 00000000..f183f7d5 --- /dev/null +++ b/src/http/cache.rs @@ -0,0 +1,92 @@ +//! HTTP cache helpers. +//! +//! Only available on nginx builds where `--with-http_cache` (the +//! default for the stock distribution) is enabled. + +use crate::ffi::{ + NGX_HTTP_CACHE_BYPASS, NGX_HTTP_CACHE_EXPIRED, NGX_HTTP_CACHE_HIT, NGX_HTTP_CACHE_MISS, + NGX_HTTP_CACHE_REVALIDATED, NGX_HTTP_CACHE_SCARCE, NGX_HTTP_CACHE_STALE, + NGX_HTTP_CACHE_UPDATING, ngx_uint_t, +}; + +/// Outcome of nginx's cache lookup for a request, mirroring the +/// `$upstream_cache_status` variable. Variants line up with the +/// `NGX_HTTP_CACHE_*` constants nginx core publishes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CacheStatus { + /// The request was a cache miss; nginx forwarded it to upstream + /// and stored the response. + Miss, + /// The request bypassed the cache (e.g. `proxy_cache_bypass`). + Bypass, + /// The cached response had expired and was refreshed from + /// upstream. + Expired, + /// A stale cached response was served (e.g. while upstream + /// was unreachable). + Stale, + /// A stale cached response was served while the cache entry is + /// being refreshed in the background. + Updating, + /// The cached response was revalidated against upstream and + /// served from cache. + Revalidated, + /// The cached response was served directly without contacting + /// upstream. + Hit, + /// `proxy_cache_min_uses` not yet reached; the response was not + /// cached on this miss. + Scarce, +} + +impl CacheStatus { + /// Convert the raw `r->upstream->cache_status` value reported by + /// nginx into a typed variant. Returns `None` for the + /// "no cache lookup performed" sentinel (`0`) and for any value + /// outside the documented range, so callers can distinguish + /// "request had nothing to do with cache" from a known outcome. + pub fn from_raw(raw: ngx_uint_t) -> Option { + match raw as u32 { + NGX_HTTP_CACHE_MISS => Some(Self::Miss), + NGX_HTTP_CACHE_BYPASS => Some(Self::Bypass), + NGX_HTTP_CACHE_EXPIRED => Some(Self::Expired), + NGX_HTTP_CACHE_STALE => Some(Self::Stale), + NGX_HTTP_CACHE_UPDATING => Some(Self::Updating), + NGX_HTTP_CACHE_REVALIDATED => Some(Self::Revalidated), + NGX_HTTP_CACHE_HIT => Some(Self::Hit), + NGX_HTTP_CACHE_SCARCE => Some(Self::Scarce), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_raw_maps_known_values() { + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_MISS as _), Some(CacheStatus::Miss)); + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_BYPASS as _), Some(CacheStatus::Bypass)); + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_EXPIRED as _), Some(CacheStatus::Expired)); + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_STALE as _), Some(CacheStatus::Stale)); + assert_eq!( + CacheStatus::from_raw(NGX_HTTP_CACHE_UPDATING as _), + Some(CacheStatus::Updating) + ); + assert_eq!( + CacheStatus::from_raw(NGX_HTTP_CACHE_REVALIDATED as _), + Some(CacheStatus::Revalidated) + ); + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_HIT as _), Some(CacheStatus::Hit)); + assert_eq!(CacheStatus::from_raw(NGX_HTTP_CACHE_SCARCE as _), Some(CacheStatus::Scarce)); + } + + #[test] + fn from_raw_rejects_no_cache_sentinel_and_unknown_values() { + assert_eq!(CacheStatus::from_raw(0), None); + assert_eq!(CacheStatus::from_raw(9), None); + assert_eq!(CacheStatus::from_raw(ngx_uint_t::MAX), None); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 00c329a8..b183ff09 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,9 +1,13 @@ +#[cfg(ngx_feature = "http_cache")] +mod cache; mod conf; mod module; mod request; mod status; mod upstream; +#[cfg(ngx_feature = "http_cache")] +pub use cache::*; pub use conf::*; pub use module::*; pub use request::*; diff --git a/src/http/request.rs b/src/http/request.rs index 64d7e6e8..d6105ed5 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -221,6 +221,114 @@ impl Request { Some(self.0.upstream) } + /// Cache lookup outcome for this request, mirroring + /// `$upstream_cache_status`. `None` when the request did not + /// consult any cache (no `proxy_cache` / `fastcgi_cache` / + /// configured, or the request was processed before nginx + /// classified it). + #[cfg(ngx_feature = "http_cache")] + pub fn cache_status(&self) -> Option { + if self.0.upstream.is_null() { + return None; + } + // SAFETY: `upstream` is non-null per the check above and is + // populated by nginx core for the lifetime of the request. + let status = unsafe { (*self.0.upstream).cache_status() }; + crate::http::CacheStatus::from_raw(status as ngx_uint_t) + } + + /// Name of the `proxy_cache_path` / `fastcgi_cache_path` + /// keys-zone consulted for this request, or `None` when no + /// cache lookup happened. + /// + /// Useful as the `zone=` label for cache metrics: `nginx_vts`, + /// `vts`, statsd exporters, etc. + #[cfg(ngx_feature = "http_cache")] + pub fn cache_zone_name(&self) -> Option<&NgxStr> { + if self.0.cache.is_null() { + return None; + } + // SAFETY: `cache` is non-null per the check above; the + // chained pointers are populated by `ngx_http_file_cache_init` + // in the master before workers fork and remain valid for the + // lifetime of the process. + unsafe { + let file_cache = (*self.0.cache).file_cache; + if file_cache.is_null() { + return None; + } + let shm_zone = (*file_cache).shm_zone; + if shm_zone.is_null() { + return None; + } + Some(NgxStr::from_ngx_str((*shm_zone).shm.name)) + } + } + + /// The `max_size` of the cache consulted for this request, in bytes, + /// or `None` when no cache lookup happened. + /// + /// nginx does not keep this figure in bytes: + /// `ngx_http_file_cache_init` divides the configured size by the + /// filesystem block size, so that it can be compared against the + /// running total directly. Reading `max_size` off the struct + /// therefore gives a number some thousands of times too small, + /// with nothing to suggest anything is wrong. Both this and + /// [`Request::cache_zone_used_size`] undo that, and + /// [`Request::cache_zone_block_size`] is there for callers that + /// want the raw unit. + #[cfg(ngx_feature = "http_cache")] + pub fn cache_zone_max_size(&self) -> Option { + let file_cache = self.file_cache()?; + // SAFETY: an initialized file cache holds both fields. + Some(unsafe { (*file_cache).max_size as u64 * (*file_cache).bsize as u64 }) + } + + /// What the cache consulted for this request currently holds on + /// disk, in bytes, or `None` when no cache lookup happened. + /// + /// Kept in filesystem blocks for the reason given on + /// [`Request::cache_zone_max_size`], and converted here for the + /// same reason. + #[cfg(ngx_feature = "http_cache")] + pub fn cache_zone_used_size(&self) -> Option { + let file_cache = self.file_cache()?; + // SAFETY: an initialized file cache holds its shared state, + // which the cache manager keeps up to date. + unsafe { + let sh = (*file_cache).sh; + if sh.is_null() { + return None; + } + Some((*sh).size as u64 * (*file_cache).bsize as u64) + } + } + + /// The filesystem block size the cache accounts in, or `None` when + /// no cache lookup happened. + #[cfg(ngx_feature = "http_cache")] + pub fn cache_zone_block_size(&self) -> Option { + let file_cache = self.file_cache()?; + // SAFETY: an initialized file cache holds the field. + Some(unsafe { (*file_cache).bsize }) + } + + /// The file cache behind this request, if it consulted one. + #[cfg(ngx_feature = "http_cache")] + fn file_cache(&self) -> Option<*mut crate::ffi::ngx_http_file_cache_t> { + if self.0.cache.is_null() { + return None; + } + // SAFETY: `cache` is non-null per the check above; the pointer + // it holds is populated by `ngx_http_file_cache_init` in the + // master before workers fork. + let file_cache = unsafe { (*self.0.cache).file_cache }; + if file_cache.is_null() { + return None; + } + Some(file_cache) + } + /// Pointer to a [`ngx_connection_t`] client connection object. /// /// [`ngx_connection_t`]: https://nginx.org/en/docs/dev/development_guide.html#connection @@ -800,3 +908,163 @@ enum MethodInner { Trace, Connect, } + +#[cfg(test)] +mod tests { + use core::mem::MaybeUninit; + + use super::*; + + fn zeroed_request() -> ngx_http_request_t { + // SAFETY: `ngx_http_request_t` is `#[repr(C)]` and tests only + // read the fields they populate below. + unsafe { MaybeUninit::zeroed().assume_init() } + } + + fn request_from(r: &mut ngx_http_request_t) -> &mut Request { + // SAFETY: `Request` is `#[repr(transparent)]` over `ngx_http_request_t`. + unsafe { Request::from_ngx_http_request(r) } + } + + #[cfg(ngx_feature = "http_cache")] + mod cache { + use super::*; + use crate::ffi::{ + NGX_HTTP_CACHE_HIT, NGX_HTTP_CACHE_MISS, ngx_http_cache_t, ngx_http_file_cache_sh_t, + ngx_http_file_cache_t, ngx_http_upstream_t, ngx_shm_zone_t, ngx_str_t, + }; + use crate::http::CacheStatus; + + #[test] + fn cache_status_none_when_upstream_null() { + let mut r = zeroed_request(); + let req = request_from(&mut r); + assert_eq!(req.cache_status(), None); + } + + #[test] + fn cache_status_none_when_field_is_zero_sentinel() { + // `cache_status == 0` is the "no cache lookup" sentinel + // nginx leaves on upstreams created for non-cached + // requests. Must surface as `None`, not a fake variant. + let mut upstream: ngx_http_upstream_t = unsafe { MaybeUninit::zeroed().assume_init() }; + upstream.set_cache_status(0); + let mut r = zeroed_request(); + r.upstream = &raw mut upstream; + let req = request_from(&mut r); + assert_eq!(req.cache_status(), None); + } + + #[test] + fn cache_status_maps_hit_and_miss() { + let mut upstream: ngx_http_upstream_t = unsafe { MaybeUninit::zeroed().assume_init() }; + upstream.set_cache_status(NGX_HTTP_CACHE_HIT); + let mut r = zeroed_request(); + r.upstream = &raw mut upstream; + assert_eq!(request_from(&mut r).cache_status(), Some(CacheStatus::Hit)); + + upstream.set_cache_status(NGX_HTTP_CACHE_MISS); + assert_eq!(request_from(&mut r).cache_status(), Some(CacheStatus::Miss)); + } + + #[test] + fn cache_zone_name_none_when_cache_null() { + let mut r = zeroed_request(); + let req = request_from(&mut r); + assert!(req.cache_zone_name().is_none()); + } + + #[test] + fn cache_zone_name_none_when_file_cache_null() { + let mut cache: ngx_http_cache_t = unsafe { MaybeUninit::zeroed().assume_init() }; + // file_cache stays null after zero-init. + let mut r = zeroed_request(); + r.cache = &raw mut cache; + assert!(request_from(&mut r).cache_zone_name().is_none()); + } + + #[test] + fn cache_zone_name_none_when_shm_zone_null() { + let mut file_cache: ngx_http_file_cache_t = + unsafe { MaybeUninit::zeroed().assume_init() }; + // shm_zone stays null after zero-init. + let mut cache: ngx_http_cache_t = unsafe { MaybeUninit::zeroed().assume_init() }; + cache.file_cache = &raw mut file_cache; + let mut r = zeroed_request(); + r.cache = &raw mut cache; + assert!(request_from(&mut r).cache_zone_name().is_none()); + } + + #[test] + fn zone_sizes_none_when_no_cache_lookup() { + let mut r = zeroed_request(); + let req = request_from(&mut r); + assert_eq!(req.cache_zone_max_size(), None); + assert_eq!(req.cache_zone_used_size(), None); + assert_eq!(req.cache_zone_block_size(), None); + } + + #[test] + fn zone_sizes_come_back_in_bytes_not_blocks() { + // What nginx holds: max_size and sh->size counted in + // blocks of bsize, the division done once in + // ngx_http_file_cache_init. Reading either field + // directly would answer 256 and 64 here. + let mut sh: ngx_http_file_cache_sh_t = unsafe { MaybeUninit::zeroed().assume_init() }; + sh.size = 64; + + let mut file_cache: ngx_http_file_cache_t = + unsafe { MaybeUninit::zeroed().assume_init() }; + file_cache.max_size = 256; + file_cache.bsize = 4096; + file_cache.sh = &raw mut sh; + + let mut cache: ngx_http_cache_t = unsafe { MaybeUninit::zeroed().assume_init() }; + cache.file_cache = &raw mut file_cache; + + let mut r = zeroed_request(); + r.cache = &raw mut cache; + let req = request_from(&mut r); + + assert_eq!(req.cache_zone_max_size(), Some(1024 * 1024)); + assert_eq!(req.cache_zone_used_size(), Some(256 * 1024)); + assert_eq!(req.cache_zone_block_size(), Some(4096)); + } + + #[test] + fn used_size_none_when_shared_state_null() { + // sh stays null after zero-init: a cache the manager has + // not brought up yet has no running total to report. + let mut file_cache: ngx_http_file_cache_t = + unsafe { MaybeUninit::zeroed().assume_init() }; + file_cache.bsize = 4096; + + let mut cache: ngx_http_cache_t = unsafe { MaybeUninit::zeroed().assume_init() }; + cache.file_cache = &raw mut file_cache; + + let mut r = zeroed_request(); + r.cache = &raw mut cache; + assert_eq!(request_from(&mut r).cache_zone_used_size(), None); + } + + #[test] + fn cache_zone_name_returns_zone_name() { + let bytes = b"my_cache"; + let mut shm_zone: ngx_shm_zone_t = unsafe { MaybeUninit::zeroed().assume_init() }; + shm_zone.shm.name = ngx_str_t { len: bytes.len(), data: bytes.as_ptr().cast_mut() }; + + let mut file_cache: ngx_http_file_cache_t = + unsafe { MaybeUninit::zeroed().assume_init() }; + file_cache.shm_zone = &raw mut shm_zone; + + let mut cache: ngx_http_cache_t = unsafe { MaybeUninit::zeroed().assume_init() }; + cache.file_cache = &raw mut file_cache; + + let mut r = zeroed_request(); + r.cache = &raw mut cache; + + let name = request_from(&mut r).cache_zone_name().expect("should resolve"); + assert_eq!(name.as_bytes(), bytes); + } + } +}