v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
Vendored
+396
@@ -0,0 +1,396 @@
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DomainView {
|
||||
pub vhost: String,
|
||||
pub domain: String,
|
||||
pub entries: usize,
|
||||
pub logical_bytes: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub hot_entries: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
pub hit_ratio: f64,
|
||||
#[serde(flatten)]
|
||||
pub counters: DomainCounters,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ObjectView {
|
||||
pub vhost: String,
|
||||
pub scheme: String,
|
||||
pub authority: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
pub target: String,
|
||||
pub cache_key: String,
|
||||
pub tags: Vec<String>,
|
||||
pub transformed: bool,
|
||||
pub body_bytes: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub storage_bytes: u64,
|
||||
pub tier: &'static str,
|
||||
pub hits: u64,
|
||||
pub age_seconds: u64,
|
||||
pub remaining_ttl_seconds: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Dashboard {
|
||||
pub domains: Vec<DomainView>,
|
||||
pub objects: Vec<ObjectView>,
|
||||
pub matching_objects: usize,
|
||||
pub matching_body_bytes: usize,
|
||||
pub matching_resident_bytes: usize,
|
||||
pub matching_cold_bytes: u64,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub statistics_revision: u64,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
fn default() -> Self {
|
||||
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
|
||||
}
|
||||
}
|
||||
impl ColdCleanupRequest {
|
||||
fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.unused_for_seconds.is_some() || self.oldest.is_some() || self.hits_zero,
|
||||
"at least one cold-cache cleanup criterion is required"
|
||||
);
|
||||
if let Some(value) = self.unused_for_seconds {
|
||||
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
|
||||
}
|
||||
if let Some(value) = self.oldest {
|
||||
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ColdCleanupResult {
|
||||
pub removed_entries: usize,
|
||||
pub freed_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ColdCleanupJob {
|
||||
pub id: u64,
|
||||
pub status: &'static str,
|
||||
pub removed_entries: usize,
|
||||
pub freed_bytes: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
struct Entry {
|
||||
base_key: Key,
|
||||
vary: Vec<String>,
|
||||
scope: String,
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
tags: Vec<String>,
|
||||
created_at: SystemTime,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
/// Backing allocation retained by `body`; can exceed `body.len()`.
|
||||
body_allocated: usize,
|
||||
received: Instant,
|
||||
last_access: Instant,
|
||||
resident_since: Instant,
|
||||
resident_age_base: Duration,
|
||||
initial_age: Duration,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
cost: usize,
|
||||
sequence: u64,
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
|
||||
fn hot_entry_cost(
|
||||
body_allocated: usize,
|
||||
headers: &HeaderMap,
|
||||
vary: &Vec<String>,
|
||||
scope: &String,
|
||||
scheme: &String,
|
||||
authority: &String,
|
||||
domain: &String,
|
||||
path: &String,
|
||||
target: &String,
|
||||
tags: &Vec<String>,
|
||||
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)
|
||||
.saturating_add(2usize.saturating_mul(std::mem::size_of::<Key>()))
|
||||
.saturating_add(32)
|
||||
.saturating_add(32);
|
||||
let string_heap = |value: &String| value.capacity();
|
||||
let string_vec_heap = |values: &Vec<String>| {
|
||||
values
|
||||
.capacity()
|
||||
.saturating_mul(std::mem::size_of::<String>())
|
||||
.saturating_add(values.iter().map(String::capacity).sum::<usize>())
|
||||
};
|
||||
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())
|
||||
.saturating_add(64)
|
||||
})
|
||||
.sum::<usize>();
|
||||
body_allocated
|
||||
.saturating_add(std::mem::size_of::<Entry>())
|
||||
.saturating_add(index_heap)
|
||||
.saturating_add(header_heap)
|
||||
.saturating_add(string_vec_heap(vary))
|
||||
.saturating_add(string_heap(scope))
|
||||
.saturating_add(string_heap(scheme))
|
||||
.saturating_add(string_heap(authority))
|
||||
.saturating_add(string_heap(domain))
|
||||
.saturating_add(string_heap(path))
|
||||
.saturating_add(string_heap(target))
|
||||
.saturating_add(string_vec_heap(tags))
|
||||
.saturating_add(origin_etag.map_or(0, |value| value.as_bytes().len()))
|
||||
.saturating_add(origin_last_modified.map_or(0, |value| value.as_bytes().len()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ColdFile {
|
||||
format: u8,
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
base_key: String,
|
||||
#[serde(default)]
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
#[serde(default = "default_cache_scheme")]
|
||||
scheme: String,
|
||||
#[serde(default)]
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
target: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
created_at_ms: u64,
|
||||
headers: Vec<(String, String)>,
|
||||
body_file: String,
|
||||
body_bytes: usize,
|
||||
body_sha256: String,
|
||||
stored_at_ms: u64,
|
||||
age_at_store_seconds: u64,
|
||||
#[serde(default)]
|
||||
resident_age_at_store_seconds: u64,
|
||||
ttl: u64,
|
||||
#[serde(default)]
|
||||
stale_while_revalidate: u64,
|
||||
#[serde(default)]
|
||||
stale_if_error: u64,
|
||||
#[serde(default)]
|
||||
must_revalidate: bool,
|
||||
#[serde(default)]
|
||||
proxy_revalidate: bool,
|
||||
#[serde(default)]
|
||||
origin_etag: Option<String>,
|
||||
#[serde(default)]
|
||||
origin_last_modified: Option<String>,
|
||||
#[serde(default)]
|
||||
last_access_ms: u64,
|
||||
hits: u64,
|
||||
#[serde(default)]
|
||||
transformed: bool,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ColdEntry {
|
||||
key: Key,
|
||||
base_key: Key,
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
tags: Vec<String>,
|
||||
created_at: SystemTime,
|
||||
headers: HeaderMap,
|
||||
body_path: PathBuf,
|
||||
meta_path: PathBuf,
|
||||
body_bytes: usize,
|
||||
body_sha256: String,
|
||||
disk_bytes: u64,
|
||||
stored_at: SystemTime,
|
||||
last_access: SystemTime,
|
||||
age_at_store_seconds: u64,
|
||||
resident_age_at_store_seconds: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
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,
|
||||
pinned_reads: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ColdStore {
|
||||
entries: HashMap<Key, ColdEntry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: u64,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
struct ColdDraft {
|
||||
key: Key,
|
||||
revision: u64,
|
||||
generation: u64,
|
||||
scope_generation: u64,
|
||||
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,
|
||||
}
|
||||
|
||||
enum ColdCommand {
|
||||
Store(ColdDraft),
|
||||
Delete(Vec<PathBuf>),
|
||||
PurgeDirectory(PathBuf),
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Store {
|
||||
entries: HashMap<Key, Entry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: usize,
|
||||
logical_bytes: usize,
|
||||
sequence: u64,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
domains: BTreeMap<(String, String), DomainCounters>,
|
||||
last_sweep: Option<Instant>,
|
||||
}
|
||||
impl Store {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
let keys = self.variants.entry(base_key).or_default();
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, key: &Key) -> Option<Entry> {
|
||||
let entry = self.entries.remove(key)?;
|
||||
self.lru.remove(&entry.sequence);
|
||||
self.bytes = self.bytes.saturating_sub(entry.cost);
|
||||
self.logical_bytes = self.logical_bytes.saturating_sub(entry.body.len());
|
||||
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
|
||||
keys.retain(|candidate| candidate != key);
|
||||
keys.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if empty {
|
||||
self.variants.remove(&entry.base_key);
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
fn domain(&mut self, scope: &str, domain: &str) -> Option<&mut DomainCounters> {
|
||||
let key = (scope.to_owned(), domain.to_owned());
|
||||
// Cardinality is bounded independently of resident cache capacity.
|
||||
if self.domains.len() >= 4096 && !self.domains.contains_key(&key) {
|
||||
return None;
|
||||
}
|
||||
Some(self.domains.entry(key).or_default())
|
||||
}
|
||||
fn sweep(&mut self) -> usize {
|
||||
if self
|
||||
.last_sweep
|
||||
.is_some_and(|v| v.elapsed() < Duration::from_secs(1))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
self.last_sweep = Some(Instant::now());
|
||||
let stale: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
|
||||
age >= retention_deadline(
|
||||
e.ttl,
|
||||
e.stale_while_revalidate,
|
||||
e.stale_if_error,
|
||||
e.origin_etag.is_some() || e.origin_last_modified.is_some(),
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
for key in &stale {
|
||||
self.remove(key);
|
||||
}
|
||||
stale.len()
|
||||
}
|
||||
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();
|
||||
self.lru.clear();
|
||||
self.bytes = 0;
|
||||
self.logical_bytes = 0;
|
||||
self.sequence = 0;
|
||||
}
|
||||
self.sequence += 1;
|
||||
self.sequence
|
||||
}
|
||||
}
|
||||
Vendored
+428
@@ -0,0 +1,428 @@
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.max_bytes >= 65_536,
|
||||
"cache.max_bytes must be at least 64 KiB"
|
||||
);
|
||||
ensure!(
|
||||
self.max_object_bytes >= 1024 && self.max_object_bytes <= self.max_bytes,
|
||||
"cache.max_object_bytes must be at least 1 KiB and no larger than max_bytes"
|
||||
);
|
||||
ensure!(
|
||||
(1..=16_384).contains(&self.max_entries),
|
||||
"cache.max_entries must be 1..16384"
|
||||
);
|
||||
ensure!(
|
||||
self.transient_max_bytes >= 65_536,
|
||||
"cache.transient_max_bytes must be at least 64 KiB"
|
||||
);
|
||||
ensure!(
|
||||
self.cold_queue_max_bytes >= 65_536
|
||||
&& self.cold_queue_max_bytes <= self.transient_max_bytes,
|
||||
"cache.cold_queue_max_bytes must be at least 64 KiB and no larger than transient_max_bytes"
|
||||
);
|
||||
ensure!(
|
||||
(1..=4096).contains(&self.hot_eviction_max_objects),
|
||||
"cache.hot_eviction_max_objects must be 1..4096"
|
||||
);
|
||||
ensure!(
|
||||
(5..=86_400).contains(&self.hot_auto_trim_after_seconds),
|
||||
"cache.hot_auto_trim_after_seconds must be 5..86400"
|
||||
);
|
||||
ensure!(
|
||||
(10..=95).contains(&self.hot_auto_trim_target_percent),
|
||||
"cache.hot_auto_trim_target_percent must be 10..95"
|
||||
);
|
||||
ensure!(
|
||||
(65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes),
|
||||
"cache.cold_max_bytes must be 64 KiB..1 TiB"
|
||||
);
|
||||
ensure!(
|
||||
(1..=1_000_000).contains(&self.cold_max_entries),
|
||||
"cache.cold_max_entries must be 1..1000000"
|
||||
);
|
||||
ensure!(
|
||||
(5..=86_400).contains(&self.cold_gc_interval_seconds),
|
||||
"cache.cold_gc_interval_seconds must be 5..86400"
|
||||
);
|
||||
ensure!(
|
||||
(1..=10_000).contains(&self.cold_gc_max_delete),
|
||||
"cache.cold_gc_max_delete must be 1..10000"
|
||||
);
|
||||
ensure!(
|
||||
(5..=604_800).contains(&self.cold_after_seconds),
|
||||
"cache.cold_after_seconds must be 5..604800"
|
||||
);
|
||||
ensure!(
|
||||
(5..=2_592_000).contains(&self.cold_max_hot_seconds),
|
||||
"cache.cold_max_hot_seconds must be 5..2592000"
|
||||
);
|
||||
ensure!(
|
||||
valid_relative_cache_path(&self.cold_path),
|
||||
"cache.cold_path must be a simple relative path inside the data directory"
|
||||
);
|
||||
ensure!(
|
||||
(1..=604_800).contains(&self.max_ttl_seconds)
|
||||
&& self.default_ttl_seconds <= self.max_ttl_seconds,
|
||||
"cache TTL must be at most seven days; default TTL cannot exceed maximum"
|
||||
);
|
||||
ensure!(
|
||||
self.bypass_prefixes.len() <= 64,
|
||||
"at most 64 cache bypass prefixes"
|
||||
);
|
||||
for prefix in &self.bypass_prefixes {
|
||||
ensure!(
|
||||
prefix.len() <= 512
|
||||
&& prefix.starts_with('/')
|
||||
&& crate::acl::routing_path(prefix)? == *prefix,
|
||||
"cache bypass prefixes must be normalized absolute paths"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.static_extensions.len() <= 64,
|
||||
"at most 64 static cache extensions"
|
||||
);
|
||||
let mut extensions = std::collections::HashSet::new();
|
||||
for extension in &self.static_extensions {
|
||||
ensure!(
|
||||
!extension.is_empty()
|
||||
&& extension.len() <= 16
|
||||
&& extension.bytes().all(|b| b.is_ascii_alphanumeric()),
|
||||
"static cache extensions must be 1..16 ASCII letters or numbers without a dot"
|
||||
);
|
||||
ensure!(
|
||||
extensions.insert(extension.to_ascii_lowercase()),
|
||||
"duplicate static cache extension"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
(10..=60_000).contains(&self.collapse_wait_timeout_ms),
|
||||
"cache.collapse_wait_timeout_ms must be 10..60000"
|
||||
);
|
||||
ensure!(
|
||||
self.stale_if_error_statuses.len() <= 32,
|
||||
"cache.stale_if_error_statuses supports at most 32 statuses"
|
||||
);
|
||||
let mut statuses = std::collections::HashSet::new();
|
||||
for status in &self.stale_if_error_statuses {
|
||||
ensure!(
|
||||
(500..=599).contains(status),
|
||||
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
|
||||
);
|
||||
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
|
||||
}
|
||||
ensure!(
|
||||
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
|
||||
"cache.proactive_refresh_before_expiry_seconds must be 1..604800"
|
||||
);
|
||||
ensure!(
|
||||
self.proactive_refresh_percent <= 100,
|
||||
"cache.proactive_refresh_percent must be 0..100"
|
||||
);
|
||||
ensure!(
|
||||
(1..=64).contains(&self.proactive_refresh_max_inflight),
|
||||
"cache.proactive_refresh_max_inflight must be 1..64"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn active_for(&self, route: Option<bool>) -> bool {
|
||||
self.enabled && route != Some(false)
|
||||
}
|
||||
pub fn allows_stale_error_class(&self, class: StaleErrorClass) -> bool {
|
||||
match class {
|
||||
StaleErrorClass::Timeout => self.stale_if_error_timeout,
|
||||
StaleErrorClass::Connection => self.stale_if_error_connection,
|
||||
StaleErrorClass::Dns => self.stale_if_error_dns,
|
||||
StaleErrorClass::Tls => self.stale_if_error_tls,
|
||||
}
|
||||
}
|
||||
pub fn allows_stale_error_status(&self, status: StatusCode) -> bool {
|
||||
self.stale_if_error_statuses.contains(&status.as_u16())
|
||||
}
|
||||
pub fn ignore_cookie_for_path(&self, path: &str) -> bool {
|
||||
if !self.static_ignore_cookies {
|
||||
return false;
|
||||
}
|
||||
let segment = path.rsplit('/').next().unwrap_or(path);
|
||||
let Some((_, extension)) = segment.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
!extension.is_empty()
|
||||
&& self
|
||||
.static_extensions
|
||||
.iter()
|
||||
.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",
|
||||
"x-cache-status",
|
||||
"cache-status",
|
||||
"x-cache-reason",
|
||||
"x-cache-resident-age",
|
||||
] {
|
||||
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 self.expose_status {
|
||||
headers.insert("x-cache", HeaderValue::from_static(outcome));
|
||||
}
|
||||
if self.expose_hits {
|
||||
headers.insert(
|
||||
"x-cache-hits",
|
||||
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
let value = match outcome {
|
||||
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
||||
"MISS" => "RustProxy; fwd=uri-miss".into(),
|
||||
_ => "RustProxy; fwd=bypass".into(),
|
||||
};
|
||||
headers.insert(
|
||||
"cache-status",
|
||||
HeaderValue::from_str(&value).expect("generated cache status"),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
|
||||
headers.remove("x-cache-resident-age");
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if self.expose_resident_age {
|
||||
headers.insert(
|
||||
"x-cache-resident-age",
|
||||
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
|
||||
if !self.enabled || !self.expose_cache_status || !transformed {
|
||||
return;
|
||||
}
|
||||
let base = headers
|
||||
.get("cache-status")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("RustProxy");
|
||||
if base
|
||||
.split(';')
|
||||
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(value) = format!("{base}; transformed").parse() {
|
||||
headers.insert("cache-status", value);
|
||||
}
|
||||
}
|
||||
/// Reasons are fixed internal tokens: never echo request values or secrets.
|
||||
pub fn annotate_reason(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
outcome: &'static str,
|
||||
hits: u64,
|
||||
ttl: u64,
|
||||
reason: &'static str,
|
||||
) {
|
||||
self.annotate(headers, outcome, hits, ttl);
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if self.expose_status {
|
||||
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
let base = headers
|
||||
.get("cache-status")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("RustProxy");
|
||||
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
|
||||
headers.insert("cache-status", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn valid_relative_cache_path(value: &str) -> bool {
|
||||
if value.is_empty() || value.len() > 256 {
|
||||
return false;
|
||||
}
|
||||
let path = Path::new(value);
|
||||
!path.is_absolute()
|
||||
&& path
|
||||
.components()
|
||||
.all(|part| matches!(part, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub enabled: Option<bool>,
|
||||
pub default_ttl_seconds: Option<u64>,
|
||||
pub max_ttl_seconds: Option<u64>,
|
||||
pub max_object_bytes: Option<usize>,
|
||||
pub bypass_query: Option<bool>,
|
||||
pub bypass_prefixes: Option<Vec<String>>,
|
||||
pub static_ignore_cookies: Option<bool>,
|
||||
pub static_extensions: Option<Vec<String>>,
|
||||
pub expose_status: Option<bool>,
|
||||
pub expose_hits: Option<bool>,
|
||||
pub expose_cache_status: Option<bool>,
|
||||
pub expose_resident_age: Option<bool>,
|
||||
}
|
||||
impl Overrides {
|
||||
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
||||
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;
|
||||
}
|
||||
if let Some(value) = self.max_ttl_seconds {
|
||||
result.max_ttl_seconds = value;
|
||||
}
|
||||
if let Some(value) = self.max_object_bytes {
|
||||
ensure!(
|
||||
value <= global.max_object_bytes,
|
||||
"vhost object limit cannot exceed the global ceiling"
|
||||
);
|
||||
result.max_object_bytes = value;
|
||||
}
|
||||
if let Some(value) = self.bypass_query {
|
||||
result.bypass_query = value;
|
||||
}
|
||||
if let Some(value) = &self.bypass_prefixes {
|
||||
result.bypass_prefixes = value.clone();
|
||||
}
|
||||
if let Some(value) = self.static_ignore_cookies {
|
||||
result.static_ignore_cookies = value;
|
||||
}
|
||||
if let Some(value) = &self.static_extensions {
|
||||
result.static_extensions = value.clone();
|
||||
}
|
||||
if let Some(value) = self.expose_status {
|
||||
result.expose_status = value;
|
||||
}
|
||||
if let Some(value) = self.expose_hits {
|
||||
result.expose_hits = value;
|
||||
}
|
||||
if let Some(value) = self.expose_cache_status {
|
||||
result.expose_cache_status = value;
|
||||
}
|
||||
if let Some(value) = self.expose_resident_age {
|
||||
result.expose_resident_age = value;
|
||||
}
|
||||
result.validate()?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ScopeView {
|
||||
pub entries: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
bypasses: AtomicU64,
|
||||
stored: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
expired: AtomicU64,
|
||||
rejected_fills: AtomicU64,
|
||||
memory_pressure_rejections: AtomicU64,
|
||||
hot_eviction_limited: AtomicU64,
|
||||
purges: AtomicU64,
|
||||
cold_hits: AtomicU64,
|
||||
cold_promotions: AtomicU64,
|
||||
hot_demotions: AtomicU64,
|
||||
cold_evictions: AtomicU64,
|
||||
cold_errors: AtomicU64,
|
||||
cold_queue_dropped: AtomicU64,
|
||||
collapsed_fills: AtomicU64,
|
||||
collapse_timeouts: AtomicU64,
|
||||
purge_entries: AtomicU64,
|
||||
bans_created: AtomicU64,
|
||||
bans_removed: AtomicU64,
|
||||
ban_matches: AtomicU64,
|
||||
proactive_scheduled: AtomicU64,
|
||||
proactive_started: AtomicU64,
|
||||
proactive_skipped: AtomicU64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct View {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub bypasses: u64,
|
||||
pub stored: u64,
|
||||
pub evictions: u64,
|
||||
pub expired: u64,
|
||||
pub rejected_fills: u64,
|
||||
pub memory_pressure_rejections: u64,
|
||||
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,
|
||||
pub hot_limit_bytes: usize,
|
||||
pub transient_limit_bytes: usize,
|
||||
pub cold_queue_limit_bytes: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
pub cold_limit_bytes: u64,
|
||||
pub cold_limit_entries: usize,
|
||||
pub cold_hits: u64,
|
||||
pub cold_promotions: u64,
|
||||
pub hot_demotions: u64,
|
||||
pub cold_evictions: u64,
|
||||
pub cold_errors: u64,
|
||||
pub cold_queue_dropped: u64,
|
||||
pub collapsed_fills: u64,
|
||||
pub collapse_timeouts: u64,
|
||||
pub collapse_waiters: u64,
|
||||
pub purge_entries: u64,
|
||||
pub bans_created: u64,
|
||||
pub bans_removed: u64,
|
||||
pub ban_matches: u64,
|
||||
pub active_bans: usize,
|
||||
pub proactive_scheduled: u64,
|
||||
pub proactive_started: u64,
|
||||
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,
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct DomainCounters {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub bypasses: u64,
|
||||
pub stored: u64,
|
||||
pub saved_body_bytes: u64,
|
||||
pub reasons: BTreeMap<String, u64>,
|
||||
}
|
||||
Vendored
+426
@@ -0,0 +1,426 @@
|
||||
impl ColdStore {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
let keys = self.variants.entry(base_key).or_default();
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
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();
|
||||
self.bytes = 0;
|
||||
self.sequence = 0;
|
||||
}
|
||||
self.sequence += 1;
|
||||
self.sequence
|
||||
}
|
||||
fn remove(&mut self, key: &Key) -> Option<ColdEntry> {
|
||||
let entry = self.entries.remove(key)?;
|
||||
self.lru.remove(&entry.sequence);
|
||||
self.bytes = self.bytes.saturating_sub(entry.disk_bytes);
|
||||
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
|
||||
keys.retain(|candidate| candidate != key);
|
||||
keys.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if empty {
|
||||
self.variants.remove(&entry.base_key);
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct TransientLease {
|
||||
_inner: Arc<TransientLeaseInner>,
|
||||
}
|
||||
|
||||
struct TransientLeaseInner {
|
||||
counter: Arc<AtomicU64>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Drop for TransientLeaseInner {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cache {
|
||||
store: Arc<Mutex<Store>>,
|
||||
revision: AtomicU64,
|
||||
cold: Arc<Mutex<ColdStore>>,
|
||||
tier_settings: Mutex<Settings>,
|
||||
data_root: Option<PathBuf>,
|
||||
cold_tx: Option<mpsc::Sender<ColdCommand>>,
|
||||
cold_delete_fallbacks: Arc<Semaphore>,
|
||||
cleanup_slots: Arc<Semaphore>,
|
||||
fills: Arc<Semaphore>,
|
||||
fill_bytes_active: AtomicU64,
|
||||
transient_bytes_active: Arc<AtomicU64>,
|
||||
cold_queue_items: AtomicU64,
|
||||
cold_queue_bytes: AtomicU64,
|
||||
cold_write_bytes_active: AtomicU64,
|
||||
cold_read_bytes_active: AtomicU64,
|
||||
revalidating: Mutex<HashSet<Key>>,
|
||||
fill_flights: Mutex<HashMap<Key, Arc<FillFlight>>>,
|
||||
fill_flight_sequence: AtomicU64,
|
||||
collapse_waiters: AtomicU64,
|
||||
bans: Mutex<Vec<BanRule>>,
|
||||
ban_sequence: AtomicU64,
|
||||
proactive_scheduled_keys: Mutex<HashSet<Key>>,
|
||||
proactive_inflight: AtomicU64,
|
||||
cleanup_jobs: Mutex<HashMap<u64, ColdCleanupJob>>,
|
||||
cleanup_sequence: AtomicU64,
|
||||
counters: Counters,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Ticket {
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
key: Key,
|
||||
request_headers: HeaderMap,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
scope_generation: u64,
|
||||
key_generation: u64,
|
||||
created_at: SystemTime,
|
||||
}
|
||||
pub struct BackgroundRevalidationGuard {
|
||||
cache: std::sync::Weak<Cache>,
|
||||
key: Key,
|
||||
}
|
||||
impl Drop for BackgroundRevalidationGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(cache) = self.cache.upgrade() {
|
||||
cache
|
||||
.revalidating
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
struct ColdReadGuard<'a> {
|
||||
cache: &'a Cache,
|
||||
key: Key,
|
||||
generation: u64,
|
||||
}
|
||||
impl Drop for ColdReadGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut cold = self.cache.cold.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = cold.entries.get_mut(&self.key) {
|
||||
if entry.generation == self.generation {
|
||||
entry.pinned_reads = entry.pinned_reads.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct Hit {
|
||||
pub response: Response,
|
||||
body: Bytes,
|
||||
pub hits: u64,
|
||||
pub transformed: bool,
|
||||
pub remaining_ttl: u64,
|
||||
pub resident_age_seconds: u64,
|
||||
pub stale: bool,
|
||||
pub background_revalidate: bool,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
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,
|
||||
storage_key: Key,
|
||||
resident_age_at_lookup: Duration,
|
||||
skip_compression: bool,
|
||||
reusable_without_validation: bool,
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
}
|
||||
impl Revalidation {
|
||||
pub fn transformed(&self) -> bool {
|
||||
self.transformed
|
||||
}
|
||||
pub fn reusable_without_validation(&self) -> bool {
|
||||
self.reusable_without_validation
|
||||
}
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&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;
|
||||
};
|
||||
if !single_if_none_match_matches(headers, public_etag) {
|
||||
return false;
|
||||
}
|
||||
if let Some(origin) = self.origin_etag.clone() {
|
||||
headers.insert("if-none-match", origin);
|
||||
headers.remove("if-modified-since");
|
||||
return true;
|
||||
}
|
||||
if let Some(modified) = self.origin_last_modified.clone() {
|
||||
headers.remove("if-none-match");
|
||||
headers.insert("if-modified-since", modified);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if headers.contains_key("if-modified-since") {
|
||||
let Some(cached) = self
|
||||
.headers
|
||||
.get("last-modified")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(client) = headers
|
||||
.get("if-modified-since")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let same = httpdate::parse_http_date(cached)
|
||||
.ok()
|
||||
.zip(httpdate::parse_http_date(client).ok())
|
||||
.is_some_and(|(cached, client)| cached == client);
|
||||
if !same {
|
||||
return false;
|
||||
}
|
||||
let Some(origin) = self.origin_last_modified.clone() else {
|
||||
return false;
|
||||
};
|
||||
headers.insert("if-modified-since", origin);
|
||||
return true;
|
||||
}
|
||||
if let Some(etag) = self.origin_etag.clone() {
|
||||
headers.insert("if-none-match", etag);
|
||||
headers.remove("if-modified-since");
|
||||
true
|
||||
} else if let Some(modified) = self.origin_last_modified.clone() {
|
||||
headers.insert("if-modified-since", modified);
|
||||
headers.remove("if-none-match");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn reusable_for_request(&self, request_headers: &HeaderMap) -> bool {
|
||||
self.reusable_without_validation
|
||||
&& request_freshness_allows(self.age, self.ttl, request_headers)
|
||||
}
|
||||
|
||||
pub fn request_freshness_revalidation_reason(
|
||||
&self,
|
||||
request_headers: &HeaderMap,
|
||||
) -> Option<&'static str> {
|
||||
let control = directives(request_headers)?;
|
||||
if let Some(Some(value)) = control.get("max-age") {
|
||||
if value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|limit| self.age > limit)
|
||||
{
|
||||
return Some("request_max_age");
|
||||
}
|
||||
}
|
||||
if let Some(Some(value)) = control.get("min-fresh") {
|
||||
if value.parse::<u64>().ok().is_some_and(|required| {
|
||||
self.ttl.saturating_sub(self.age) < required
|
||||
}) {
|
||||
return Some("request_min_fresh");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_stale_if_error(&self) -> bool {
|
||||
self.age >= self.ttl
|
||||
&& self.stale_if_error > 0
|
||||
&& self.age.saturating_sub(self.ttl) <= self.stale_if_error
|
||||
&& !stale_forbidden(self.must_revalidate, self.proxy_revalidate)
|
||||
&& !self.reusable_without_validation
|
||||
}
|
||||
pub fn merge_validation_headers(&self, validation: &HeaderMap) -> HeaderMap {
|
||||
let mut merged = merge_revalidated_headers(&self.headers, validation);
|
||||
let public_etag = self.headers.get("etag").cloned();
|
||||
let representation_etag_differs = public_etag.as_ref().is_some_and(|public| {
|
||||
self.origin_etag
|
||||
.as_ref()
|
||||
.is_none_or(|origin| public != origin)
|
||||
});
|
||||
if representation_etag_differs {
|
||||
if let Some(public) = public_etag {
|
||||
merged.insert("etag", public);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
pub fn age(&self) -> u64 {
|
||||
self.age
|
||||
}
|
||||
pub fn remaining_ttl(&self) -> u64 {
|
||||
self.ttl.saturating_sub(self.age)
|
||||
}
|
||||
pub fn response(&self, headers: HeaderMap) -> Response {
|
||||
let mut response = Response::new(Body::from(self.body.clone()));
|
||||
*response.headers_mut() = headers;
|
||||
if self.transformed {
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::CachedTransformed);
|
||||
}
|
||||
if self.skip_compression {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
fn vary_names(headers: &HeaderMap) -> Option<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for value in headers.get_all("vary") {
|
||||
for raw in value.to_str().ok()?.split(',') {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw == "*" {
|
||||
return None;
|
||||
}
|
||||
let name = raw.parse::<http::HeaderName>().ok()?;
|
||||
names.push(name.as_str().to_owned());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
(names.len() <= 64).then_some(names)
|
||||
}
|
||||
|
||||
fn variant_key(base_key: &Key, vary: &[String], headers: &HeaderMap) -> Key {
|
||||
if vary.is_empty() {
|
||||
return *base_key;
|
||||
}
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"RustProxy-Vary-v1");
|
||||
digest.update(base_key);
|
||||
for name in vary {
|
||||
digest.update((name.len() as u64).to_be_bytes());
|
||||
digest.update(name.as_bytes());
|
||||
let values = headers.get_all(name.as_str());
|
||||
digest.update((values.iter().count() as u64).to_be_bytes());
|
||||
for value in values.iter() {
|
||||
digest.update((value.as_bytes().len() as u64).to_be_bytes());
|
||||
digest.update(value.as_bytes());
|
||||
}
|
||||
}
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn hot_resident_age(entry: &Entry) -> Duration {
|
||||
entry
|
||||
.resident_age_base
|
||||
.saturating_add(entry.resident_since.elapsed())
|
||||
}
|
||||
|
||||
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(&ticket.key)?
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let entry = store.entries.get(key)?;
|
||||
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
|
||||
.then_some((*key, entry.sequence))
|
||||
})
|
||||
.max_by_key(|(_, sequence)| *sequence)
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
fn matching_cold_key(store: &ColdStore, ticket: &Ticket) -> Option<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(&ticket.key)?
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let entry = store.entries.get(key)?;
|
||||
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
|
||||
.then_some((*key, entry.sequence))
|
||||
})
|
||||
.max_by_key(|(_, sequence)| *sequence)
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
fn incompatible_hot_variants(store: &Store, base_key: &Key, vary: &[String]) -> Vec<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(base_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|key| {
|
||||
store
|
||||
.entries
|
||||
.get(key)
|
||||
.filter(|entry| entry.vary.as_slice() != vary)
|
||||
.map(|_| *key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn incompatible_cold_variants(store: &ColdStore, base_key: &Key, vary: &[String]) -> Vec<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(base_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|key| {
|
||||
store
|
||||
.entries
|
||||
.get(key)
|
||||
.filter(|entry| entry.vary.as_slice() != vary)
|
||||
.map(|_| *key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body_sha256(body: &[u8]) -> String {
|
||||
let digest = Sha256::digest(body);
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cold_body_matches(entry: &ColdEntry, body: &[u8]) -> bool {
|
||||
body.len() == entry.body_bytes && body_sha256(body).as_str() == entry.body_sha256.as_str()
|
||||
}
|
||||
|
||||
fn key_hex(key: &Key) -> String {
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in key {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
Vendored
+245
@@ -0,0 +1,245 @@
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
|
||||
path::{Component, Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
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];
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum CleanupPriority {
|
||||
Automatic(u64, SystemTime, SystemTime),
|
||||
Manual(SystemTime, SystemTime, u64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct CleanupCandidate {
|
||||
priority: CleanupPriority,
|
||||
sequence: u64,
|
||||
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 {
|
||||
libc::malloc_trim(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CollapseTimeoutFallback {
|
||||
Origin,
|
||||
StaleIfAvailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StaleErrorClass {
|
||||
Timeout,
|
||||
Connection,
|
||||
Dns,
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl Default for CollapseTimeoutFallback {
|
||||
fn default() -> Self {
|
||||
Self::Origin
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPreset {
|
||||
Conservative,
|
||||
Recommended,
|
||||
Performance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColdPreset {
|
||||
Recommended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[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>,
|
||||
/// 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,
|
||||
pub proactive_refresh_max_inflight: usize,
|
||||
pub expose_status: bool,
|
||||
pub expose_hits: bool,
|
||||
pub expose_cache_status: bool,
|
||||
pub expose_resident_age: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
memory_preset: None,
|
||||
max_bytes: 256 * 1024 * 1024,
|
||||
max_object_bytes: 16 * 1024 * 1024,
|
||||
max_entries: 4096,
|
||||
transient_max_bytes: 64 * 1024 * 1024,
|
||||
cold_queue_max_bytes: 32 * 1024 * 1024,
|
||||
hot_eviction_max_objects: 64,
|
||||
hot_auto_trim_enabled: true,
|
||||
hot_auto_trim_after_seconds: 300,
|
||||
hot_auto_trim_target_percent: 80,
|
||||
cold_enabled: false,
|
||||
cold_preset: None,
|
||||
cold_path: "cache/cold".into(),
|
||||
cold_max_bytes: 4 * 1024 * 1024 * 1024,
|
||||
cold_max_entries: 50_000,
|
||||
cold_gc_interval_seconds: 60,
|
||||
cold_gc_max_delete: 256,
|
||||
cold_after_seconds: 300,
|
||||
cold_on_idle: true,
|
||||
cold_max_hot_seconds: 3600,
|
||||
cold_on_age: false,
|
||||
cold_on_lru: true,
|
||||
cold_on_shutdown: true,
|
||||
default_ttl_seconds: 0,
|
||||
max_ttl_seconds: 86_400,
|
||||
bypass_query: false,
|
||||
bypass_prefixes: 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",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
collapse_enabled: true,
|
||||
collapse_wait_timeout_ms: 1500,
|
||||
collapse_timeout_fallback: CollapseTimeoutFallback::Origin,
|
||||
stale_if_error_timeout: true,
|
||||
stale_if_error_connection: true,
|
||||
stale_if_error_dns: true,
|
||||
stale_if_error_tls: true,
|
||||
stale_if_error_statuses: vec![500, 502, 503, 504],
|
||||
proactive_refresh_enabled: false,
|
||||
proactive_refresh_before_expiry_seconds: 15,
|
||||
proactive_refresh_percent: 10,
|
||||
proactive_refresh_max_inflight: 4,
|
||||
expose_status: false,
|
||||
expose_hits: false,
|
||||
expose_cache_status: false,
|
||||
expose_resident_age: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+232
@@ -0,0 +1,232 @@
|
||||
fn parse_key_hex(value: &str) -> Option<Key> {
|
||||
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0_u8; 32];
|
||||
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
|
||||
let text = std::str::from_utf8(chunk).ok()?;
|
||||
key[index] = u8::from_str_radix(text, 16).ok()?;
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
fn default_cache_scheme() -> String {
|
||||
"http".to_owned()
|
||||
}
|
||||
|
||||
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
|
||||
let mut tags = Vec::new();
|
||||
for value in headers.get_all("cache-tag") {
|
||||
let Ok(value) = value.to_str() else { continue; };
|
||||
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
|
||||
let tag = raw.trim();
|
||||
if tag.is_empty()
|
||||
|| tag.len() > 128
|
||||
|| !tag
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !tags.iter().any(|existing| existing == tag) {
|
||||
tags.push(tag.to_owned());
|
||||
if tags.len() >= 64 {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
fn system_time_ms(value: u64) -> SystemTime {
|
||||
UNIX_EPOCH
|
||||
.checked_add(Duration::from_millis(value))
|
||||
.unwrap_or(UNIX_EPOCH)
|
||||
}
|
||||
|
||||
fn cold_age(entry: &ColdEntry) -> u64 {
|
||||
entry
|
||||
.age_at_store_seconds
|
||||
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
|
||||
}
|
||||
|
||||
fn cold_resident_age(entry: &ColdEntry) -> u64 {
|
||||
entry
|
||||
.resident_age_at_store_seconds
|
||||
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
|
||||
}
|
||||
|
||||
fn retention_deadline(
|
||||
_ttl: u64,
|
||||
_stale_while_revalidate: u64,
|
||||
_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
|
||||
}
|
||||
|
||||
fn has_validator(headers: &HeaderMap) -> bool {
|
||||
headers.contains_key("etag") || headers.contains_key("last-modified")
|
||||
}
|
||||
|
||||
fn stale_forbidden(must_revalidate: bool, proxy_revalidate: bool) -> bool {
|
||||
must_revalidate || proxy_revalidate
|
||||
}
|
||||
|
||||
fn client_max_stale(headers: &HeaderMap) -> Option<Option<u64>> {
|
||||
let control = directives(headers)?;
|
||||
let value = control.get("max-stale")?;
|
||||
match value {
|
||||
None => Some(None),
|
||||
Some(value) => value.parse::<u64>().ok().map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_freshness_allows(age: u64, ttl: u64, headers: &HeaderMap) -> bool {
|
||||
let Some(control) = directives(headers) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(Some(value)) = control.get("max-age") {
|
||||
let Ok(limit) = value.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
if age > limit {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(Some(value)) = control.get("min-fresh") {
|
||||
let Ok(required) = value.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
if ttl.saturating_sub(age) < required {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn stale_mode(
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
requires_revalidation: bool,
|
||||
request_headers: &HeaderMap,
|
||||
) -> Option<bool> {
|
||||
if age < ttl || requires_revalidation || stale_forbidden(must_revalidate, proxy_revalidate) {
|
||||
return None;
|
||||
}
|
||||
if !request_freshness_allows(age, ttl, request_headers) {
|
||||
return None;
|
||||
}
|
||||
let stale_for = age.saturating_sub(ttl);
|
||||
if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate {
|
||||
return Some(true);
|
||||
}
|
||||
match client_max_stale(request_headers) {
|
||||
Some(None) => Some(false),
|
||||
Some(Some(limit)) if stale_for <= limit => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CacheState {
|
||||
Fresh,
|
||||
StaleButServable { background_revalidate: bool },
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl CacheState {
|
||||
fn servable(self) -> bool {
|
||||
!matches!(self, Self::Expired)
|
||||
}
|
||||
|
||||
fn stale(self) -> bool {
|
||||
matches!(self, Self::StaleButServable { .. })
|
||||
}
|
||||
|
||||
fn background_revalidate(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::StaleButServable {
|
||||
background_revalidate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_state(
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
requires_revalidation: bool,
|
||||
request_headers: &HeaderMap,
|
||||
) -> CacheState {
|
||||
if age < ttl
|
||||
&& !requires_revalidation
|
||||
&& request_freshness_allows(age, ttl, request_headers)
|
||||
{
|
||||
return CacheState::Fresh;
|
||||
}
|
||||
match stale_mode(
|
||||
age,
|
||||
ttl,
|
||||
stale_while_revalidate,
|
||||
must_revalidate,
|
||||
proxy_revalidate,
|
||||
requires_revalidation,
|
||||
request_headers,
|
||||
) {
|
||||
Some(background_revalidate) => CacheState::StaleButServable {
|
||||
background_revalidate,
|
||||
},
|
||||
None => CacheState::Expired,
|
||||
}
|
||||
}
|
||||
|
||||
fn cold_paths(root: &Path, key: &Key) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let key = key_hex(key);
|
||||
let shard = root.join(&key[..2]);
|
||||
(
|
||||
shard.join(format!("{key}.body")),
|
||||
shard.join(format!("{key}.json")),
|
||||
shard.join(format!(".{key}.body.tmp")),
|
||||
shard.join(format!(".{key}.json.tmp")),
|
||||
)
|
||||
}
|
||||
|
||||
fn cold_header_pairs(headers: &HeaderMap) -> Option<Vec<(String, String)>> {
|
||||
let mut result = Vec::new();
|
||||
for (name, value) in headers {
|
||||
result.push((name.as_str().to_owned(), value.to_str().ok()?.to_owned()));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn pairs_to_headers(values: &[(String, String)]) -> Option<HeaderMap> {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in values {
|
||||
let name = name.parse::<http::HeaderName>().ok()?;
|
||||
let value = HeaderValue::from_str(value).ok()?;
|
||||
headers.append(name, value);
|
||||
}
|
||||
Some(headers)
|
||||
}
|
||||
Reference in new issue
Block a user