397 lines
12 KiB
Rust
397 lines
12 KiB
Rust
#[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
|
|
}
|
|
}
|