0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

+1 -24
View File
@@ -28,9 +28,7 @@ pub struct ObjectView {
pub storage_bytes: u64,
pub tier: &'static str,
pub hits: u64,
/// RFC 9111 Age, including time before the object entered this cache.
pub age_seconds: u64,
/// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age.
pub resident_age_seconds: u64,
pub remaining_ttl_seconds: u64,
}
@@ -50,11 +48,8 @@ pub struct Dashboard {
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ColdCleanupRequest {
/// Remove objects not used for at least this many seconds.
pub unused_for_seconds: Option<u64>,
/// Additionally remove up to N oldest matching objects.
pub oldest: Option<usize>,
/// Limit the selection to objects which were never served.
pub hits_zero: bool,
}
impl Default for ColdCleanupRequest {
@@ -113,7 +108,6 @@ struct Entry {
created_at: SystemTime,
headers: Arc<HeaderMap>,
body: Bytes,
/// Backing allocation retained by `body`; can exceed `body.len()`.
body_allocated: usize,
received: Instant,
last_access: Instant,
@@ -135,7 +129,7 @@ struct Entry {
requires_revalidation: bool,
}
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
#[allow(clippy::ptr_arg)]
fn hot_entry_cost(
body_allocated: usize,
headers: &HeaderMap,
@@ -150,11 +144,6 @@ fn hot_entry_cost(
origin_etag: Option<&HeaderValue>,
origin_last_modified: Option<&HeaderValue>,
) -> usize {
// The public containers do not expose an exact per-entry allocator charge.
// Account their retained index memory conservatively: HOT HashMap key/control,
// the LRU BTreeMap item/node share and the variants index (including spare
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
// allocation instead of pretending Entry/body are the only HOT owners.
let index_heap = std::mem::size_of::<Key>()
.saturating_add(std::mem::size_of::<(u64, Key)>())
.saturating_add(32)
@@ -171,8 +160,6 @@ fn hot_entry_cost(
let header_heap = headers
.iter()
.map(|(name, value)| {
// HeaderMap does not expose its bucket capacity. Charge a conservative
// per-field bucket/node allowance in addition to visible name/value bytes.
name.as_str()
.len()
.saturating_add(value.as_bytes().len())
@@ -181,7 +168,6 @@ fn hot_entry_cost(
.sum::<usize>();
body_allocated
.saturating_add(std::mem::size_of::<Entry>())
// Arc<HeaderMap> owns the actual map on a separate allocation.
.saturating_add(std::mem::size_of::<HeaderMap>())
.saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(index_heap)
@@ -207,7 +193,6 @@ struct ColdFile {
#[serde(default)]
vary: Vec<String>,
revision: u64,
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
#[serde(default)]
scope_revision: u64,
scope: String,
@@ -284,9 +269,6 @@ struct ColdEntry {
hits: u64,
transformed: bool,
sequence: u64,
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
// never changes on access, so an old async reader cannot unpin a newer
// replacement stored under the same cache key.
generation: u64,
skip_compression: bool,
requires_revalidation: bool,
@@ -311,8 +293,6 @@ struct ColdDraft {
key_generation: u64,
entry: Entry,
root: PathBuf,
/// Keeps this full in-memory object charged to the bounded transient pool
/// until the COLD write completes and the payload is dropped.
_memory_lease: TransientLease,
}
@@ -331,8 +311,6 @@ struct Store {
sequence: u64,
generation: u64,
revision: u64,
/// Current persisted administrative config revision. Distinct from cache
/// representation revision: operational saves do not evict data.
control_revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
@@ -361,7 +339,6 @@ impl Store {
Some(entry)
}
fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX {
self.entries.clear();
self.variants.clear();
+20 -24
View File
@@ -167,7 +167,6 @@ impl Settings {
.any(|item| item.eq_ignore_ascii_case(extension))
}
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
// Origin values must never masquerade as this proxy's diagnostics.
for name in [
"x-cache",
"x-cache-hits",
@@ -178,15 +177,15 @@ impl Settings {
] {
headers.remove(name);
}
// A vhost that explicitly disables cache must look like a normal reverse proxy.
// Keep counters internally, but never expose cache diagnostics for that response.
if !self.enabled {
return;
}
if let Some(value) = &self.via_header {
headers.append(
http::header::VIA,
value.parse::<HeaderValue>().expect("validated cache Via header"),
value
.parse::<HeaderValue>()
.expect("validated cache Via header"),
);
}
if self.expose_only_hits && outcome != "HIT" {
@@ -226,10 +225,15 @@ impl Settings {
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled || !self.expose_cache_status || !transformed
|| (self.expose_only_hits && !headers.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit"))) {
if !self.enabled
|| !self.expose_cache_status
|| !transformed
|| (self.expose_only_hits
&& !headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit")))
{
return;
}
let base = headers
@@ -246,7 +250,6 @@ impl Settings {
headers.insert("cache-status", value);
}
}
/// Reasons are fixed internal tokens: never echo request values or secrets.
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
@@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool {
.all(|part| matches!(part, Component::Normal(_)))
}
/// Explicit, per-vhost-only request-side cache override. It never weakens the
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UnsafeForce {
@@ -294,7 +295,10 @@ pub struct UnsafeForce {
}
impl UnsafeForce {
pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
ensure!(
self.targets.len() <= 32,
"forced cache supports at most 32 target prefixes"
);
for target in &self.targets {
ensure!(
!target.is_empty()
@@ -305,7 +309,10 @@ impl UnsafeForce {
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
);
let path = target.split('?').next().unwrap_or(target);
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized");
ensure!(
crate::acl::routing_path(path)? == path,
"forced cache target path must be normalized"
);
}
Ok(())
}
@@ -323,8 +330,6 @@ impl UnsafeForce {
}
}
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Overrides {
@@ -341,15 +346,12 @@ pub struct Overrides {
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
pub expose_only_hits: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce,
}
impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
self.unsafe_force.validate()?;
let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
result.enabled = global.enabled && self.enabled.unwrap_or(true);
if let Some(value) = self.default_ttl_seconds {
result.default_ttl_seconds = value;
@@ -444,7 +446,6 @@ pub struct View {
pub hot_eviction_limited: u64,
pub purges: u64,
pub entries: usize,
/// Compatibility alias for HOT allocated/charged bytes.
pub resident_bytes: usize,
pub hot_logical_bytes: usize,
pub hot_allocated_bytes: usize,
@@ -474,16 +475,11 @@ pub struct View {
pub proactive_skipped: u64,
pub proactive_inflight: u64,
pub fills_active: usize,
/// Bytes currently allocated by in-progress response cache fills.
pub fill_bytes_active: u64,
/// Demoted objects accepted by the bounded cold-writer queue.
pub cold_queue_items: u64,
pub cold_queue_bytes: u64,
/// Body bytes currently owned by the cold writer after leaving the queue.
pub cold_write_bytes_active: u64,
/// Full cold bodies currently read into RAM for promotion.
pub cold_read_bytes_active: u64,
/// RustProxy-accounted cache RAM outside the published hot store.
pub transient_bytes: u64,
pub hit_ratio: f64,
}
+10 -5
View File
@@ -1,11 +1,11 @@
// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP
// produced by the existing forwarding trust boundary, never an arbitrary header.
fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> {
let value = value.trim();
if let Ok(ip) = value.parse::<std::net::IpAddr>() {
return Ok(ipnet::IpNet::from(ip));
}
value.parse::<ipnet::IpNet>().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
value
.parse::<ipnet::IpNet>()
.map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
}
impl Settings {
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
@@ -17,7 +17,10 @@ impl Settings {
for input in &self.http_invalidation_allowed_cidrs {
ensure!(input.len() <= 64, "HTTP invalidation address too long");
let net = parse_invalidation_cidr(input)?;
ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet");
ensure!(
net.prefix_len() > 0,
"HTTP invalidation cannot authorize the entire Internet"
);
ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR");
}
Ok(())
@@ -25,7 +28,9 @@ impl Settings {
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
let client = crate::vhost::canonical_ip(client);
self.http_invalidation_allowed_cidrs.iter().any(|value| {
parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client))
parse_invalidation_cidr(value)
.ok()
.is_some_and(|network| network.contains(&client))
})
}
}
+5 -9
View File
@@ -7,8 +7,6 @@ impl ColdStore {
}
fn next_sequence(&mut self) -> u64 {
if self.sequence == u64::MAX {
// Practically unreachable. Prefer dropping the in-memory index over
// risking ambiguous LRU ordering after wrap.
self.lru.clear();
self.entries.clear();
self.variants.clear();
@@ -56,7 +54,6 @@ pub struct Cache {
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
/// Per-vhost content compatibility used by persisted COLD metadata.
scope_revisions: Mutex<HashMap<String, u64>>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
@@ -143,8 +140,6 @@ pub struct Revalidation {
headers: HeaderMap,
body: Bytes,
body_allocated: usize,
// COLD revalidation bodies remain charged to transient RAM until every
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
_transient_leases: Vec<TransientLease>,
hits: u64,
transformed: bool,
@@ -171,9 +166,6 @@ impl Revalidation {
&self.headers
}
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
// Client validators refer to the representation RustProxy served. If that
// representation was transformed, translate a matching validator back to
// the origin validator rather than leaking the generated proxy ETag upstream.
if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
return false;
@@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration {
}
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
if store.entries.get(&ticket.key).is_some_and(|entry| entry.vary.is_empty()) {
if store
.entries
.get(&ticket.key)
.is_some_and(|entry| entry.vary.is_empty())
{
return Some(ticket.key);
}
store
+5 -59
View File
@@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
// Cold writes can contain complete response bodies. Keep the queue deliberately
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
const COLD_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32];
@@ -43,10 +41,6 @@ struct CleanupCandidate {
key: Key,
}
/// `Bytes` releases its allocation when the last reference is dropped, but
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
/// return releasable pages to the OS instead of letting RSS stay artificially
/// high. This is intentionally a no-op on other allocators/platforms.
fn release_allocator_memory() {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
@@ -93,81 +87,44 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// UI sizing profile last explicitly selected by the administrator. The
/// actual numeric limits remain authoritative; this only preserves intent
/// across reloads so the dashboard can show which preset was applied.
pub memory_preset: Option<MemoryPreset>,
/// Hard RAM budget for published HOT entries, charged by backing allocation
/// plus directly owned metadata rather than only logical body length.
pub max_bytes: usize,
/// Maximum logical size of a single cacheable object body.
pub max_object_bytes: usize,
/// Hot tier object count.
pub max_entries: usize,
/// Bounded Varnish-style transient RAM used by in-progress fills and full
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
pub transient_max_bytes: usize,
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
pub cold_queue_max_bytes: usize,
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
/// configuration for the same reason as `memory_preset`.
pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String,
pub cold_max_bytes: u64,
pub cold_max_entries: usize,
/// Interval between asynchronous cold-cache GC checks.
pub cold_gc_interval_seconds: u64,
/// Maximum number of objects removed by one GC pass.
pub cold_gc_max_delete: usize,
/// Hot objects not touched for this long are demoted to disk.
pub cold_after_seconds: u64,
/// Demote objects which have been idle for `cold_after_seconds`.
pub cold_on_idle: bool,
/// Demote objects after this much continuous residency in the hot tier
/// when `cold_on_age` is enabled, even when they are still being read.
pub cold_max_hot_seconds: u64,
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
pub cold_on_age: bool,
/// Preserve objects evicted from the hot LRU in the cold tier.
pub cold_on_lru: bool,
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
pub cold_on_shutdown: bool,
/// Only used for responses explicitly marked public and without origin TTL.
pub default_ttl_seconds: u64,
pub max_ttl_seconds: u64,
pub bypass_query: bool,
pub bypass_prefixes: Vec<String>,
/// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these
/// public-listener methods (independent of authenticated admin API).
pub http_invalidation_allowed_cidrs: Vec<String>,
/// For explicitly selected static extensions, ignore request Cookie when
/// deciding cache eligibility and when building the shared cache key.
pub static_ignore_cookies: bool,
pub static_extensions: Vec<String>,
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
pub collapse_enabled: bool,
/// Maximum time a follower waits for the current fill before applying the configured fallback.
pub collapse_wait_timeout_ms: u64,
pub collapse_timeout_fallback: CollapseTimeoutFallback,
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
pub stale_if_error_timeout: bool,
pub stale_if_error_connection: bool,
pub stale_if_error_dns: bool,
pub stale_if_error_tls: bool,
pub stale_if_error_statuses: Vec<u16>,
/// Schedule bounded validator refresh before normal freshness expires.
pub proactive_refresh_enabled: bool,
pub proactive_refresh_before_expiry_seconds: u64,
pub proactive_refresh_percent: u8,
@@ -176,10 +133,7 @@ pub struct Settings {
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
pub expose_only_hits: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>,
}
impl Default for Settings {
@@ -214,21 +168,13 @@ impl Default for Settings {
bypass_query: false,
bypass_prefixes: Vec::new(),
http_invalidation_allowed_cidrs: Vec::new(),
// Static assets are normally representation-identical for logged-in and
// anonymous visitors. Keep this enabled by default once the shared cache
// itself is enabled; response-side privacy guards still reject Set-Cookie,
// private/no-store and Vary: Cookie before anything is stored.
static_ignore_cookies: true,
static_extensions: vec![
// styles / scripts
"css", "js", "mjs", "cjs", "wasm", // images
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
"woff", "woff2", "ttf", "otf", "eot", // video
"mp4", "webm", "ogv", "mov", "m4v", // audio
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
// common static downloads
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
"css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif",
"webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif",
"woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3",
"ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz",
"tgz", "bz2", "xz", "7z", "rar", "tar",
]
.into_iter()
.map(str::to_owned)
-5
View File
@@ -73,11 +73,6 @@ fn retention_deadline(
_stale_if_error: u64,
_has_validator: bool,
) -> u64 {
// Freshness and storage retention are deliberately separate. Expiry only
// changes whether an object can be served without validation; it must not
// physically evict the representation because a later request may carry
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
// mechanisms that decide how long an otherwise valid object is retained.
u64::MAX
}