#[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, 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, pub objects: Vec, 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, /// Additionally remove up to N oldest matching objects. pub oldest: Option, /// 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, } struct Entry { base_key: Key, vary: Vec, scope: String, scheme: String, authority: String, domain: String, path: String, target: String, tags: Vec, 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, origin_last_modified: Option, 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, scope: &String, scheme: &String, authority: &String, domain: &String, path: &String, target: &String, tags: &Vec, 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 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::() .saturating_add(std::mem::size_of::<(u64, Key)>()) .saturating_add(32) .saturating_add(2usize.saturating_mul(std::mem::size_of::())) .saturating_add(32) .saturating_add(32); let string_heap = |value: &String| value.capacity(); let string_vec_heap = |values: &Vec| { values .capacity() .saturating_mul(std::mem::size_of::()) .saturating_add(values.iter().map(String::capacity).sum::()) }; 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::(); body_allocated .saturating_add(std::mem::size_of::()) .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, 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, #[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, #[serde(default)] origin_last_modified: Option, #[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, revision: u64, scope: String, scheme: String, authority: String, domain: String, path: String, target: String, tags: Vec, 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, origin_last_modified: Option, 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, variants: HashMap>, lru: BTreeMap, 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), PurgeDirectory(PathBuf), } #[derive(Default)] struct Store { entries: HashMap, variants: HashMap>, lru: BTreeMap, bytes: usize, logical_bytes: usize, sequence: u64, generation: u64, revision: u64, scope_generations: HashMap, key_generations: HashMap, domains: BTreeMap<(String, String), DomainCounters>, last_sweep: Option, } 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 { 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 } }