From 071de2a9ef34723139525832a6ce98858b62697f Mon Sep 17 00:00:00 2001 From: "Y.Horie" Date: Mon, 25 May 2026 15:06:29 +0900 Subject: [PATCH 1/2] feat(http): expose cache_status and cache_zone_name accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a typed `CacheStatus` mirror of nginx's `NGX_HTTP_CACHE_*` constants and two safe `Request` accessors: - `Request::cache_status() -> Option` reads `r->upstream->cache_status` (the same value `$upstream_cache_status` exposes) and converts the raw `ngx_uint_t` into a typed variant; the "no cache lookup" sentinel (`0`) and any unknown value surface as `None`. - `Request::cache_zone_name() -> Option<&NgxStr>` walks `r->cache->file_cache->shm_zone->shm.name` to return the `proxy_cache_path` keys-zone name — handy as the `zone=` label for cache metrics. Both are gated by `#[cfg(ngx_feature = "http_cache")]` so builds without `--without-http_cache` still link. Unit tests use the `MaybeUninit::zeroed` pattern from #272 to construct the request / upstream / cache chain on the stack and verify the null / zero-sentinel / populated paths. --- src/http/cache.rs | 92 +++++++++++++++++++++++++++ src/http/mod.rs | 4 ++ src/http/request.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 src/http/cache.rs 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..5fb9a28b 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -221,6 +221,50 @@ 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)) + } + } + /// Pointer to a [`ngx_connection_t`] client connection object. /// /// [`ngx_connection_t`]: https://nginx.org/en/docs/dev/development_guide.html#connection @@ -800,3 +844,111 @@ 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_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 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); + } + } +} From b38aa204238e938fb7ba72943ad254063aa07c6f Mon Sep 17 00:00:00 2001 From: "Y.Horie" Date: Mon, 31 Aug 2026 20:50:54 +0900 Subject: [PATCH 2/2] feat(http): report the cache zone's size in bytes cache_zone_name() gives a metric its zone= label; the numbers to put against it are still out of reach. A module reporting on the cache wants how large it may grow and how much of that it is using. Adds cache_zone_max_size() and cache_zone_used_size(), both in bytes, and cache_zone_block_size() for callers that want the raw unit. Bytes rather than what the struct holds, because what it holds is surprising. ngx_http_file_cache_init divides the configured max_size by the filesystem block size so it can be compared against sh->size, which the cache manager also accumulates in blocks. Reading either field directly answers in blocks, which on a 4k filesystem is a figure some thousands of times too small, and nothing about the field says so. The test covers exactly that: a 1 MiB zone holding 256 KiB reads as 256 and 64 off the struct. Signed-off-by: Y.Horie --- src/http/request.rs | 120 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/src/http/request.rs b/src/http/request.rs index 5fb9a28b..d6105ed5 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -265,6 +265,70 @@ impl Request { } } + /// 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 @@ -866,8 +930,8 @@ mod tests { mod cache { use super::*; use crate::ffi::{ - NGX_HTTP_CACHE_HIT, NGX_HTTP_CACHE_MISS, ngx_http_cache_t, ngx_http_file_cache_t, - ngx_http_upstream_t, ngx_shm_zone_t, ngx_str_t, + 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; @@ -931,6 +995,58 @@ mod tests { 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";