This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+974
View File
@@ -0,0 +1,974 @@
impl Cache {
fn cold_root_for(&self, settings: &Settings) -> Option<PathBuf> {
settings
.cold_enabled
.then(|| {
self.data_root
.as_ref()
.map(|root| root.join(&settings.cold_path))
})
.flatten()
}
fn load_cold_startup(&self) {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let Some(root) = self.cold_root_for(&settings) else {
return;
};
let revision = self
.store
.lock()
.unwrap_or_else(|e| e.into_inner())
.revision;
let Ok(read_dir) = std::fs::read_dir(&root) else {
return;
};
// v0.21 intentionally does not migrate the legacy flat cold-cache layout.
// Remove only known cache artifacts from the old root and rebuild naturally.
let mut shard_dirs = Vec::new();
for item in read_dir.flatten() {
let path = item.path();
if path.is_file() {
let extension = path.extension().and_then(|v| v.to_str()).unwrap_or_default();
if matches!(extension, "json" | "body" | "tmp") {
let _ = std::fs::remove_file(path);
}
continue;
}
let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
continue;
};
if name.len() == 2 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
shard_dirs.push(path);
}
}
let mut loaded = Vec::new();
for shard in shard_dirs {
let Some(shard_name) = shard.file_name().and_then(|v| v.to_str()) else {
continue;
};
let Ok(items) = std::fs::read_dir(&shard) else {
continue;
};
for item in items.flatten() {
let path = item.path();
if path.extension().and_then(|v| v.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
continue;
};
let Ok(file) = serde_json::from_slice::<ColdFile>(&bytes) else {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
continue;
};
let body_path = shard.join(&file.body_file);
if file.format != COLD_FORMAT_VERSION || file.revision != revision {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&body_path);
continue;
}
let Some(key) = parse_key_hex(&file.key) else {
continue;
};
if !file.key[..2].eq_ignore_ascii_case(shard_name) {
continue;
}
let Some(base_key) = parse_key_hex(&file.base_key) else {
continue;
};
let mut vary = file.vary.clone();
if vary.len() > 64
|| vary
.iter()
.any(|name| name.parse::<http::HeaderName>().is_err())
{
continue;
}
vary.sort();
vary.dedup();
if path.file_stem().and_then(|v| v.to_str()) != Some(file.key.as_str()) {
continue;
}
let Some(headers) = pairs_to_headers(&file.headers) else {
continue;
};
if body_path.parent() != Some(shard.as_path()) {
continue;
}
let Ok(body_meta) = std::fs::metadata(&body_path) else {
continue;
};
if body_meta.len() != file.body_bytes as u64
|| file.body_sha256.len() != 64
|| !file.body_sha256.bytes().all(|b| b.is_ascii_hexdigit())
{
continue;
}
let origin_etag = file
.origin_etag
.as_deref()
.and_then(|v| HeaderValue::from_str(v).ok());
let origin_last_modified = file
.origin_last_modified
.as_deref()
.and_then(|v| HeaderValue::from_str(v).ok());
let stored_at = system_time_ms(file.stored_at_ms);
let current_age = file
.age_at_store_seconds
.saturating_add(stored_at.elapsed().unwrap_or_default().as_secs());
if current_age
>= retention_deadline(
file.ttl,
file.stale_while_revalidate,
file.stale_if_error,
origin_etag.is_some() || origin_last_modified.is_some(),
)
{
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&body_path);
continue;
}
loaded.push(ColdEntry {
key,
base_key,
vary,
revision,
scope: file.scope,
scheme: file.scheme,
authority: if file.authority.is_empty() { file.domain.clone() } else { file.authority },
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() { file.path } else { file.target },
tags: file.tags,
created_at: if file.created_at_ms == 0 { stored_at } else { system_time_ms(file.created_at_ms) },
headers,
body_path,
meta_path: path,
body_bytes: file.body_bytes,
body_sha256: file.body_sha256,
disk_bytes: body_meta.len().saturating_add(bytes.len() as u64),
stored_at,
last_access: if file.last_access_ms == 0 {
stored_at
} else {
system_time_ms(file.last_access_ms)
},
age_at_store_seconds: file.age_at_store_seconds,
resident_age_at_store_seconds: file.resident_age_at_store_seconds,
ttl: file.ttl,
stale_while_revalidate: file.stale_while_revalidate,
stale_if_error: file.stale_if_error,
must_revalidate: file.must_revalidate,
proxy_revalidate: file.proxy_revalidate,
origin_etag,
origin_last_modified,
hits: file.hits,
transformed: file.transformed,
sequence: 0,
generation: 0,
skip_compression: file.skip_compression,
requires_revalidation: file.requires_revalidation,
pinned_reads: 0,
});
}
}
loaded.sort_by_key(|entry| entry.last_access);
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for mut entry in loaded {
for key in incompatible_cold_variants(&cold, &entry.base_key, &entry.vary) {
if let Some(old) = cold.remove(&key) {
let _ = std::fs::remove_file(old.meta_path);
let _ = std::fs::remove_file(old.body_path);
}
}
while cold.entries.len() >= settings.cold_max_entries
|| cold.bytes.saturating_add(entry.disk_bytes) > settings.cold_max_bytes
{
let Some((_, key)) = cold.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
if let Some(old) = cold.remove(&key) {
let _ = std::fs::remove_file(old.meta_path);
let _ = std::fs::remove_file(old.body_path);
}
}
entry.sequence = cold.next_sequence();
entry.generation = entry.sequence;
cold.bytes = cold.bytes.saturating_add(entry.disk_bytes);
cold.lru.insert(entry.sequence, entry.key);
cold.index_variant(entry.base_key, entry.key);
cold.entries.insert(entry.key, entry);
}
}
async fn handle_cold_command(&self, command: ColdCommand) {
match command {
ColdCommand::Store(draft) => {
let bytes = draft.entry.cost as u64;
self.cold_queue_items.fetch_sub(1, Ordering::Relaxed);
self.cold_queue_bytes.fetch_sub(bytes, Ordering::Relaxed);
self.persist_cold(draft).await;
}
ColdCommand::Delete(paths) => {
for path in paths {
let _ = tokio::fs::remove_file(path).await;
}
}
ColdCommand::PurgeDirectory(path) => {
if let Err(error) = tokio::fs::remove_dir_all(&path).await {
if error.kind() != std::io::ErrorKind::NotFound {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
async fn cold_worker(
weak: std::sync::Weak<Self>,
mut rx: mpsc::Receiver<ColdCommand>,
shutdown: CancellationToken,
) {
loop {
let command = tokio::select! {
_ = shutdown.cancelled() => {
// The listener shutdown token is shared with the cache. Drain
// already accepted demotions before exiting so a restart does
// not discard buffers that were already handed to cold storage.
rx.close();
while let Ok(command) = rx.try_recv() {
let Some(cache) = weak.upgrade() else { return; };
cache.handle_cold_command(command).await;
}
break;
},
command = rx.recv() => command,
};
let Some(command) = command else {
break;
};
let Some(cache) = weak.upgrade() else {
break;
};
cache.handle_cold_command(command).await;
}
}
async fn persist_cold(&self, draft: ColdDraft) {
let bytes = draft.entry.cost as u64;
self.cold_write_bytes_active
.fetch_add(bytes, Ordering::Relaxed);
self.persist_cold_inner(draft).await;
self.cold_write_bytes_active
.fetch_sub(bytes, Ordering::Relaxed);
self.cold_gc_pass().await;
// The draft body is out of scope before trimming. `Bytes` has released its
// reference and glibc is explicitly asked to return releasable pages.
release_allocator_memory();
}
async fn persist_cold_inner(&self, draft: ColdDraft) {
let current = {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.revision == draft.revision
&& store.generation == draft.generation
&& !store.entries.contains_key(&draft.key)
&& incompatible_hot_variants(&store, &draft.entry.base_key, &draft.entry.vary)
.is_empty()
&& store
.scope_generations
.get(&draft.entry.scope)
.copied()
.unwrap_or(0)
== draft.scope_generation
&& store
.key_generations
.get(&draft.entry.base_key)
.copied()
.unwrap_or(0)
== draft.key_generation
};
if !current {
return;
}
let Some(headers) = cold_header_pairs(&draft.entry.headers) else {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
};
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if !settings.cold_enabled || self.cold_root_for(&settings).as_ref() != Some(&draft.root) {
return;
}
let key = key_hex(&draft.key);
let body_name = format!("{key}.body");
let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key);
let Some(shard) = body_path.parent() else { return; };
if tokio::fs::create_dir_all(shard).await.is_err() {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
}
let age_at_store = draft
.entry
.initial_age
.saturating_add(draft.entry.received.elapsed())
.as_secs();
let resident_age_at_store = hot_resident_age(&draft.entry).as_secs();
let file = ColdFile {
format: COLD_FORMAT_VERSION,
key: key.clone(),
base_key: key_hex(&draft.entry.base_key),
vary: draft.entry.vary.clone(),
revision: draft.revision,
scope: draft.entry.scope.clone(),
scheme: draft.entry.scheme.clone(),
authority: draft.entry.authority.clone(),
domain: draft.entry.domain.clone(),
path: draft.entry.path.clone(),
target: draft.entry.target.clone(),
tags: draft.entry.tags.clone(),
created_at_ms: draft.entry.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64,
headers,
body_file: body_name,
body_bytes: draft.entry.body.len(),
body_sha256: body_sha256(&draft.entry.body),
stored_at_ms: now_ms(),
age_at_store_seconds: age_at_store,
resident_age_at_store_seconds: resident_age_at_store,
ttl: draft.entry.ttl,
stale_while_revalidate: draft.entry.stale_while_revalidate,
stale_if_error: draft.entry.stale_if_error,
must_revalidate: draft.entry.must_revalidate,
proxy_revalidate: draft.entry.proxy_revalidate,
origin_etag: draft.entry.origin_etag.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
origin_last_modified: draft.entry.origin_last_modified.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
last_access_ms: now_ms(),
hits: draft.entry.hits,
transformed: draft.entry.transformed,
skip_compression: draft.entry.skip_compression,
requires_revalidation: draft.entry.requires_revalidation,
};
let Ok(metadata) = serde_json::to_vec_pretty(&file) else {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
};
let write_ok = tokio::fs::write(&body_tmp, &draft.entry.body).await.is_ok()
&& tokio::fs::write(&meta_tmp, &metadata).await.is_ok()
&& tokio::fs::rename(&body_tmp, &body_path).await.is_ok()
&& tokio::fs::rename(&meta_tmp, &meta_path).await.is_ok();
if !write_ok {
let _ = tokio::fs::remove_file(&body_tmp).await;
let _ = tokio::fs::remove_file(&meta_tmp).await;
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
}
let still_current = {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.revision == draft.revision
&& store.generation == draft.generation
&& !store.entries.contains_key(&draft.key)
&& incompatible_hot_variants(&store, &draft.entry.base_key, &draft.entry.vary)
.is_empty()
&& store
.scope_generations
.get(&draft.entry.scope)
.copied()
.unwrap_or(0)
== draft.scope_generation
&& store
.key_generations
.get(&draft.entry.base_key)
.copied()
.unwrap_or(0)
== draft.key_generation
};
if !still_current {
let _ = tokio::fs::remove_file(&body_path).await;
let _ = tokio::fs::remove_file(&meta_path).await;
return;
}
let disk_bytes = draft.entry.body.len() as u64 + metadata.len() as u64;
let mut delete = Vec::new();
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for key in incompatible_cold_variants(&cold, &draft.entry.base_key, &draft.entry.vary) {
if let Some(old) = cold.remove(&key) {
delete.extend([old.meta_path, old.body_path]);
}
}
if let Some(old) = cold.remove(&draft.key) {
if old.meta_path != meta_path {
delete.push(old.meta_path);
}
if old.body_path != body_path {
delete.push(old.body_path);
}
}
while cold.entries.len() >= settings.cold_max_entries
|| cold.bytes.saturating_add(disk_bytes) > settings.cold_max_bytes
{
let Some((_, key)) = cold.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
if let Some(old) = cold.remove(&key) {
delete.extend([old.meta_path, old.body_path]);
self.counters.cold_evictions.fetch_add(1, Ordering::Relaxed);
}
}
if disk_bytes > settings.cold_max_bytes {
delete.extend([meta_path.clone(), body_path.clone()]);
} else {
let sequence = cold.next_sequence();
let entry = ColdEntry {
key: draft.key,
base_key: draft.entry.base_key,
vary: draft.entry.vary,
revision: draft.revision,
scope: draft.entry.scope,
scheme: draft.entry.scheme,
authority: draft.entry.authority,
domain: draft.entry.domain,
path: draft.entry.path,
target: draft.entry.target,
tags: draft.entry.tags,
created_at: draft.entry.created_at,
headers: draft.entry.headers,
body_path: body_path.clone(),
meta_path: meta_path.clone(),
body_bytes: draft.entry.body.len(),
body_sha256: file.body_sha256,
disk_bytes,
stored_at: SystemTime::now(),
age_at_store_seconds: age_at_store,
resident_age_at_store_seconds: resident_age_at_store,
ttl: draft.entry.ttl,
stale_while_revalidate: draft.entry.stale_while_revalidate,
stale_if_error: draft.entry.stale_if_error,
must_revalidate: draft.entry.must_revalidate,
proxy_revalidate: draft.entry.proxy_revalidate,
origin_etag: draft.entry.origin_etag,
origin_last_modified: draft.entry.origin_last_modified,
last_access: SystemTime::now(),
hits: draft.entry.hits,
transformed: draft.entry.transformed,
sequence,
generation: sequence,
skip_compression: draft.entry.skip_compression,
requires_revalidation: draft.entry.requires_revalidation,
pinned_reads: 0,
};
cold.bytes = cold.bytes.saturating_add(disk_bytes);
cold.lru.insert(sequence, draft.key);
cold.index_variant(entry.base_key, draft.key);
cold.entries.insert(draft.key, entry);
self.counters.hot_demotions.fetch_add(1, Ordering::Relaxed);
}
}
for path in delete {
let _ = tokio::fs::remove_file(path).await;
}
}
fn enqueue_demotion(
&self,
key: Key,
entry: Entry,
revision: u64,
generation: u64,
scope_generation: u64,
key_generation: u64,
) -> bool {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let Some(root) = self.cold_root_for(&settings) else {
return false;
};
let Some(tx) = &self.cold_tx else {
return false;
};
let queued_bytes = entry.cost as u64;
let queue_limit = u64::try_from(settings.cold_queue_max_bytes).unwrap_or(u64::MAX);
loop {
let current = self.cold_queue_bytes.load(Ordering::Relaxed);
let Some(next) = current.checked_add(queued_bytes) else {
self.counters
.cold_queue_dropped
.fetch_add(1, Ordering::Relaxed);
return false;
};
if next > queue_limit {
self.counters
.cold_queue_dropped
.fetch_add(1, Ordering::Relaxed);
return false;
}
if self
.cold_queue_bytes
.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
break;
}
}
let Some(memory_lease) = self.try_reserve_transient(entry.cost, &settings) else {
self.cold_queue_bytes
.fetch_sub(queued_bytes, Ordering::Relaxed);
self.counters
.cold_queue_dropped
.fetch_add(1, Ordering::Relaxed);
return false;
};
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
if tx
.try_send(ColdCommand::Store(ColdDraft {
key,
revision,
generation,
scope_generation,
key_generation,
entry,
root,
_memory_lease: memory_lease,
}))
.is_err()
{
self.cold_queue_items.fetch_sub(1, Ordering::Relaxed);
self.cold_queue_bytes
.fetch_sub(queued_bytes, Ordering::Relaxed);
self.counters
.cold_queue_dropped
.fetch_add(1, Ordering::Relaxed);
return false;
}
true
}
fn demote_idle(&self) {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if !settings.cold_enabled
|| (!settings.cold_on_idle && !settings.cold_on_age)
|| self.cold_root_for(&settings).is_none()
{
return;
}
let available = self.cold_tx.as_ref().map(|tx| tx.capacity()).unwrap_or(0);
if available == 0 {
return;
}
let idle_cutoff = Duration::from_secs(settings.cold_after_seconds);
let age_cutoff = Duration::from_secs(settings.cold_max_hot_seconds);
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let generation = store.generation;
let keys: Vec<_> = store
.entries
.iter()
.filter(|(_, entry)| {
(settings.cold_on_idle && entry.last_access.elapsed() >= idle_cutoff)
|| (settings.cold_on_age && entry.resident_since.elapsed() >= age_cutoff)
})
.map(|(key, _)| *key)
.take(available)
.collect();
for key in keys {
let Some(entry) = store.remove(&key) else {
continue;
};
let scope_generation = store
.scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
if !self.enqueue_demotion(key, entry, revision, generation, scope_generation, key_generation) {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
drop(store);
release_allocator_memory();
}
/// Best-effort graceful shutdown handoff from RAM to the persistent cold
/// tier. The cold worker shares the process shutdown token, so this path
/// writes directly instead of depending on the background queue.
async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let Some(root) = self.cold_root_for(&settings) else {
return 0;
};
if !force_shutdown_policy && !settings.cold_on_shutdown {
return 0;
}
let mut moved = 0usize;
loop {
let draft = {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let generation = store.generation;
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
let Some(entry) = store.remove(&key) else {
continue;
};
let scope_generation = store
.scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
let bytes = entry.cost;
let memory_lease = self.try_reserve_transient(bytes, &settings).unwrap_or_else(|| {
let bytes = bytes as u64;
self.transient_bytes_active.fetch_add(bytes, Ordering::Relaxed);
TransientLease(Arc::new(TransientLeaseInner {
counter: self.transient_bytes_active.clone(),
bytes,
}))
});
ColdDraft {
key,
revision,
generation,
scope_generation,
key_generation,
entry,
root: root.clone(),
_memory_lease: memory_lease,
}
};
self.persist_cold_inner(draft).await;
moved += 1;
}
release_allocator_memory();
moved
}
pub async fn flush_hot_to_cold(&self) -> usize {
self.flush_hot_to_cold_inner(false).await
}
/// Managed software updates explicitly request a hot-to-cold handoff. The
/// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle
/// does not disable update preservation.
pub async fn flush_hot_to_cold_for_update(&self) -> usize {
self.flush_hot_to_cold_inner(true).await
}
async fn sweep_cold(&self) {
const BATCH: usize = 256;
let mut after_sequence = 0u64;
loop {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
cold.lru
.range((
std::ops::Bound::Excluded(after_sequence),
std::ops::Bound::Unbounded,
))
.take(BATCH)
.map(|(sequence, key)| (*sequence, *key))
.collect()
};
if batch.is_empty() {
break;
}
after_sequence = batch.last().map(|value| value.0).unwrap_or(after_sequence);
let mut delete = Vec::new();
let mut expired = 0u64;
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for (_, key) in &batch {
let should_remove = cold.entries.get(key).is_some_and(|entry| {
entry.pinned_reads == 0
&& cold_age(entry)
>= retention_deadline(
entry.ttl,
entry.stale_while_revalidate,
entry.stale_if_error,
entry.origin_etag.is_some()
|| entry.origin_last_modified.is_some(),
)
});
if should_remove {
if let Some(entry) = cold.remove(key) {
delete.extend([entry.meta_path, entry.body_path]);
expired = expired.saturating_add(1);
}
}
}
}
if expired > 0 {
self.counters.expired.fetch_add(expired, Ordering::Relaxed);
}
self.enqueue_cold_delete(delete);
tokio::task::yield_now().await;
}
}
fn enqueue_cold_delete(&self, paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
if let Some(tx) = &self.cold_tx {
if tx.try_send(ColdCommand::Delete(paths.clone())).is_ok() {
return;
}
}
// Queue saturation must not block request processing. The index is already
// updated; a best-effort detached delete keeps physical cleanup asynchronous.
tokio::spawn(async move {
for path in paths {
let _ = tokio::fs::remove_file(path).await;
}
});
}
async fn remove_cold_candidates(
&self,
request: Option<&ColdCleanupRequest>,
target_bytes: Option<u64>,
target_entries: Option<usize>,
max_delete: usize,
) -> ColdCleanupResult {
const SNAPSHOT_BATCH: usize = 256;
const DELETE_BATCH: usize = 32;
let now = SystemTime::now();
let mut candidates = Vec::new();
let mut after_sequence = 0u64;
// Copy candidate metadata in bounded chunks. Request-path lookups only
// contend with this mutex for a small snapshot, never for sorting or I/O.
loop {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
cold.lru
.range((std::ops::Bound::Excluded(after_sequence), std::ops::Bound::Unbounded))
.take(SNAPSHOT_BATCH)
.filter_map(|(sequence, key)| {
cold.entries.get(key).map(|entry| {
(
*sequence,
*key,
entry.hits,
entry.last_access,
entry.stored_at,
)
})
})
.collect()
};
if batch.is_empty() {
break;
}
after_sequence = batch.last().map(|value| value.0).unwrap_or(after_sequence);
for (sequence, key, hits, last_access, stored_at) in batch {
let matches = request.is_none_or(|request| {
(!request.hits_zero || hits == 0)
&& request.unused_for_seconds.is_none_or(|seconds| {
now.duration_since(last_access)
.unwrap_or_default()
.as_secs()
>= seconds
})
});
if matches {
candidates.push((sequence, key, hits, last_access, stored_at));
}
}
tokio::task::yield_now().await;
}
if request.is_some() {
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
(*stored_at, *last_access, *hits)
});
} else {
// Automatic GC prefers rarely used, least recently used, oldest data.
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
(*hits, *last_access, *stored_at)
});
}
let requested_limit = request
.and_then(|value| value.oldest)
.unwrap_or(max_delete)
.min(max_delete);
let mut result = ColdCleanupResult::default();
let mut delete = Vec::new();
for chunk in candidates.chunks(DELETE_BATCH) {
if result.removed_entries >= requested_limit {
break;
}
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for (sequence, key, _, _, _) in chunk {
if result.removed_entries >= requested_limit {
break;
}
if let Some(target) = target_bytes {
if cold.bytes <= target
&& target_entries.is_none_or(|entries| cold.entries.len() <= entries)
{
break;
}
} else if let Some(entries) = target_entries {
if cold.entries.len() <= entries {
break;
}
}
if !cold
.entries
.get(key)
.is_some_and(|entry| entry.sequence == *sequence && entry.pinned_reads == 0)
{
continue;
}
if let Some(entry) = cold.remove(key) {
result.removed_entries += 1;
result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes);
delete.extend([entry.meta_path, entry.body_path]);
}
}
}
tokio::task::yield_now().await;
}
if result.removed_entries > 0 {
self.counters
.cold_evictions
.fetch_add(result.removed_entries as u64, Ordering::Relaxed);
}
self.enqueue_cold_delete(delete);
result
}
async fn cold_gc_pass(&self) -> ColdCleanupResult {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if !settings.cold_enabled {
return ColdCleanupResult::default();
}
let (bytes, entries) = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
(cold.bytes, cold.entries.len())
};
let high_bytes = settings.cold_max_bytes.saturating_mul(90) / 100;
let target_bytes = settings.cold_max_bytes.saturating_mul(80) / 100;
let high_entries = settings
.cold_max_entries
.saturating_mul(90)
.saturating_add(99)
/ 100;
let target_entries = settings.cold_max_entries.saturating_mul(80) / 100;
if bytes < high_bytes && entries < high_entries {
return ColdCleanupResult::default();
}
self.remove_cold_candidates(
None,
Some(target_bytes),
Some(target_entries),
settings.cold_gc_max_delete,
)
.await
}
pub fn start_cold_cleanup(
self: &Arc<Self>,
request: ColdCleanupRequest,
) -> Result<ColdCleanupJob> {
request.validate()?;
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
ensure!(settings.cold_enabled, "cold cache is disabled");
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let job = ColdCleanupJob {
id,
status: "queued",
removed_entries: 0,
freed_bytes: 0,
error: None,
};
{
let mut jobs = self.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if jobs.len() >= 64 {
let mut ids: Vec<_> = jobs.keys().copied().collect();
ids.sort_unstable();
for old in ids.into_iter().take(jobs.len().saturating_sub(63)) {
jobs.remove(&old);
}
}
jobs.insert(id, job.clone());
}
let cache = self.clone();
tokio::spawn(async move {
{
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
job.status = "running";
}
}
let settings = cache
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let result = cache
.remove_cold_candidates(
Some(&request),
None,
None,
settings.cold_gc_max_delete,
)
.await;
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
job.status = "done";
job.removed_entries = result.removed_entries;
job.freed_bytes = result.freed_bytes;
}
});
Ok(job)
}
pub fn cold_cleanup_job(&self, id: u64) -> Option<ColdCleanupJob> {
self.cleanup_jobs
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&id)
.cloned()
}
fn take_cold(&self, key: &Key) -> Option<ColdEntry> {
self.cold
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key)
}
fn delete_cold_entry(&self, entry: ColdEntry) {
if let Some(tx) = &self.cold_tx {
let _ = tx.try_send(ColdCommand::Delete(vec![entry.meta_path, entry.body_path]));
}
}
}
+257
View File
@@ -0,0 +1,257 @@
struct FillFlight {
id: u64,
scope: String,
scheme: String,
authority: String,
target: String,
done: tokio::sync::watch::Sender<bool>,
}
pub enum FillClaim {
Disabled,
Leader(FillLeaderGuard),
Waited,
Timeout,
}
pub struct FillLeaderGuard {
cache: std::sync::Weak<Cache>,
key: Key,
id: u64,
done: tokio::sync::watch::Sender<bool>,
}
struct CollapseWaiterGuard {
cache: std::sync::Weak<Cache>,
}
impl Drop for CollapseWaiterGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
cache.collapse_waiters.fetch_sub(1, Ordering::Relaxed);
}
}
}
impl Drop for FillLeaderGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut flights = cache
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let current = flights
.get(&self.key)
.is_some_and(|flight| flight.id == self.id);
if current {
flights.remove(&self.key);
}
}
// Publish completion even when a purge already removed this flight from the map.
self.done.send_replace(true);
}
}
impl Cache {
pub async fn claim_fill(
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> FillClaim {
if !settings.collapse_enabled {
return FillClaim::Disabled;
}
let existing = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(flight) = flights.get(&ticket.key) {
Some(flight.clone())
} else {
let id = self
.fill_flight_sequence
.fetch_add(1, Ordering::Relaxed)
.wrapping_add(1);
let (done, _) = tokio::sync::watch::channel(false);
flights.insert(
ticket.key,
Arc::new(FillFlight {
id,
scope: ticket.scope.clone(),
scheme: ticket.scheme.clone(),
authority: ticket.authority.clone(),
target: ticket.target.clone(),
done: done.clone(),
}),
);
return FillClaim::Leader(FillLeaderGuard {
cache: Arc::downgrade(self),
key: ticket.key,
id,
done,
});
}
};
let Some(flight) = existing else {
unreachable!("existing fill resolved above")
};
// A watch channel retains the completed state, so a leader finishing between
// map lookup and follower polling cannot lose the wakeup.
let mut done = flight.done.subscribe();
self.collapse_waiters.fetch_add(1, Ordering::Relaxed);
let _waiter = CollapseWaiterGuard {
cache: Arc::downgrade(self),
};
let waited = if *done.borrow() {
Ok(())
} else {
tokio::time::timeout(
Duration::from_millis(settings.collapse_wait_timeout_ms),
done.changed(),
)
.await
.map(|_| ())
};
match waited {
Ok(()) => {
self.counters
.collapsed_fills
.fetch_add(1, Ordering::Relaxed);
FillClaim::Waited
}
Err(_) => {
self.counters
.collapse_timeouts
.fetch_add(1, Ordering::Relaxed);
FillClaim::Timeout
}
}
}
fn cancel_all_fill_flights(&self) {
let flights = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
}
}
fn cancel_scope_fill_flights(&self, scope: &str) {
let removed = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let keys = flights
.iter()
.filter(|(_, flight)| flight.scope == scope)
.map(|(key, _)| *key)
.collect::<Vec<_>>();
keys.into_iter()
.filter_map(|key| flights.remove(&key))
.collect::<Vec<_>>()
};
for flight in removed {
flight.done.send_replace(true);
}
}
}
pub struct ProactiveScheduleGuard {
cache: std::sync::Weak<Cache>,
key: Key,
}
impl Drop for ProactiveScheduleGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
cache
.proactive_scheduled_keys
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.key);
}
}
}
pub struct ProactiveInflightGuard {
cache: std::sync::Weak<Cache>,
}
impl Drop for ProactiveInflightGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
cache.proactive_inflight.fetch_sub(1, Ordering::Relaxed);
}
}
}
impl Cache {
pub fn begin_proactive_schedule(
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> Option<ProactiveScheduleGuard> {
if !settings.proactive_refresh_enabled || settings.proactive_refresh_percent == 0 {
return None;
}
let sample = u16::from_be_bytes([ticket.key[0], ticket.key[1]]) % 100;
if sample >= settings.proactive_refresh_percent as u16 {
self.counters
.proactive_skipped
.fetch_add(1, Ordering::Relaxed);
return None;
}
let mut scheduled = self
.proactive_scheduled_keys
.lock()
.unwrap_or_else(|e| e.into_inner());
if !scheduled.insert(ticket.key) {
return None;
}
self.counters
.proactive_scheduled
.fetch_add(1, Ordering::Relaxed);
Some(ProactiveScheduleGuard {
cache: Arc::downgrade(self),
key: ticket.key,
})
}
pub fn try_begin_proactive_refresh(
self: &Arc<Self>,
settings: &Settings,
) -> Option<ProactiveInflightGuard> {
let limit = settings.proactive_refresh_max_inflight as u64;
loop {
let current = self.proactive_inflight.load(Ordering::Relaxed);
if current >= limit {
self.counters
.proactive_skipped
.fetch_add(1, Ordering::Relaxed);
return None;
}
if self
.proactive_inflight
.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.is_ok()
{
self.counters
.proactive_started
.fetch_add(1, Ordering::Relaxed);
return Some(ProactiveInflightGuard {
cache: Arc::downgrade(self),
});
}
}
}
}
+429
View File
@@ -0,0 +1,429 @@
impl Cache {
pub fn view(&self) -> View {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
self.counters
.expired
.fetch_add(store.sweep() as u64, Ordering::Relaxed);
let n = |value: &AtomicU64| value.load(Ordering::Relaxed);
let hits = n(&self.counters.hits);
let misses = n(&self.counters.misses);
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let fill_bytes_active = n(&self.fill_bytes_active);
let cold_queue_bytes = n(&self.cold_queue_bytes);
let cold_write_bytes_active = n(&self.cold_write_bytes_active);
let cold_read_bytes_active = n(&self.cold_read_bytes_active);
let transient_bytes = n(self.transient_bytes_active.as_ref());
View {
hits,
misses,
bypasses: n(&self.counters.bypasses),
stored: n(&self.counters.stored),
evictions: n(&self.counters.evictions),
expired: n(&self.counters.expired),
rejected_fills: n(&self.counters.rejected_fills),
memory_pressure_rejections: n(&self.counters.memory_pressure_rejections),
hot_eviction_limited: n(&self.counters.hot_eviction_limited),
purges: n(&self.counters.purges),
entries: store.entries.len(),
resident_bytes: store.bytes,
hot_logical_bytes: store.logical_bytes,
hot_allocated_bytes: store.bytes,
hot_limit_bytes: settings.max_bytes,
transient_limit_bytes: settings.transient_max_bytes,
cold_queue_limit_bytes: settings.cold_queue_max_bytes,
cold_entries: cold.entries.len(),
cold_bytes: cold.bytes,
cold_hits: n(&self.counters.cold_hits),
cold_promotions: n(&self.counters.cold_promotions),
hot_demotions: n(&self.counters.hot_demotions),
cold_evictions: n(&self.counters.cold_evictions),
cold_errors: n(&self.counters.cold_errors),
cold_queue_dropped: n(&self.counters.cold_queue_dropped),
collapsed_fills: n(&self.counters.collapsed_fills),
collapse_timeouts: n(&self.counters.collapse_timeouts),
collapse_waiters: n(&self.collapse_waiters),
purge_entries: n(&self.counters.purge_entries),
bans_created: n(&self.counters.bans_created),
bans_removed: n(&self.counters.bans_removed),
ban_matches: n(&self.counters.ban_matches),
active_bans: self.active_bans().len(),
proactive_scheduled: n(&self.counters.proactive_scheduled),
proactive_started: n(&self.counters.proactive_started),
proactive_skipped: n(&self.counters.proactive_skipped),
proactive_inflight: n(&self.proactive_inflight),
fills_active: FILL_SLOTS - self.fills.available_permits(),
fill_bytes_active,
cold_queue_items: n(&self.cold_queue_items),
cold_queue_bytes,
cold_write_bytes_active,
cold_read_bytes_active,
transient_bytes,
hit_ratio: if hits + misses > 0 {
hits as f64 / (hits + misses) as f64
} else {
0.0
},
}
}
pub fn prometheus(&self) -> String {
let view = self.view();
let mut text = String::new();
for (name, kind, value) in [
("hits_total", "counter", view.hits),
("misses_total", "counter", view.misses),
("bypasses_total", "counter", view.bypasses),
("stored_total", "counter", view.stored),
("evictions_total", "counter", view.evictions),
("expired_total", "counter", view.expired),
("rejected_fills_total", "counter", view.rejected_fills),
(
"memory_pressure_rejections_total",
"counter",
view.memory_pressure_rejections,
),
(
"hot_eviction_limited_total",
"counter",
view.hot_eviction_limited,
),
("purges_total", "counter", view.purges),
("purge_entries_total", "counter", view.purge_entries),
("collapsed_fills_total", "counter", view.collapsed_fills),
("collapse_timeouts_total", "counter", view.collapse_timeouts),
("collapse_waiters", "gauge", view.collapse_waiters),
("bans_created_total", "counter", view.bans_created),
("bans_removed_total", "counter", view.bans_removed),
("ban_matches_total", "counter", view.ban_matches),
("active_bans", "gauge", view.active_bans as u64),
("proactive_scheduled_total", "counter", view.proactive_scheduled),
("proactive_started_total", "counter", view.proactive_started),
("proactive_skipped_total", "counter", view.proactive_skipped),
("proactive_inflight", "gauge", view.proactive_inflight),
("entries", "gauge", view.entries as u64),
("resident_bytes", "gauge", view.resident_bytes as u64),
("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64),
("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
(
"transient_limit_bytes",
"gauge",
view.transient_limit_bytes as u64,
),
(
"cold_queue_limit_bytes",
"gauge",
view.cold_queue_limit_bytes as u64,
),
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
("cold_evictions_total", "counter", view.cold_evictions),
("cold_errors_total", "counter", view.cold_errors),
(
"cold_queue_dropped_total",
"counter",
view.cold_queue_dropped,
),
("fills_active", "gauge", view.fills_active as u64),
("fill_bytes_active", "gauge", view.fill_bytes_active),
("cold_queue_items", "gauge", view.cold_queue_items),
("cold_queue_bytes", "gauge", view.cold_queue_bytes),
(
"cold_write_bytes_active",
"gauge",
view.cold_write_bytes_active,
),
(
"cold_read_bytes_active",
"gauge",
view.cold_read_bytes_active,
),
("transient_bytes", "gauge", view.transient_bytes),
] {
text.push_str(&format!(
"# TYPE rust_proxy_cache_{name} {kind}\nrust_proxy_cache_{name} {value}\n"
));
}
text.push_str(&format!(
"# TYPE rust_proxy_cache_hit_ratio gauge\nrust_proxy_cache_hit_ratio {:.6}\n",
view.hit_ratio
));
text
}
pub fn bypass(&self) {
self.counters.bypasses.fetch_add(1, Ordering::Relaxed);
}
/// Clear all keys and cancel publication of older in-flight fills.
pub fn purge(&self, revision: Option<u64>) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let mut count = store.entries.len();
store.entries.clear();
store.variants.clear();
store.lru.clear();
store.bytes = 0;
store.logical_bytes = 0;
store.generation = store.generation.wrapping_add(1);
store.scope_generations.clear();
store.key_generations.clear();
if let Some(revision) = revision {
store.revision = revision;
self.revision.store(revision, Ordering::Relaxed);
store.domains.clear();
}
drop(store);
self.cancel_all_fill_flights();
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
count += cold.entries.len();
cold.entries.clear();
cold.variants.clear();
cold.lru.clear();
cold.bytes = 0;
cold.sequence = 0;
}
if let (Some(root), Some(tx)) = (self.cold_root_for(&settings), &self.cold_tx) {
let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
count
}
/// Purge one vhost without evicting other hosts. Generation bumps also stop
/// an already streaming MISS from repopulating the just-purged scope.
pub fn purge_scope(&self, scope: &str) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys: Vec<_> = store
.entries
.iter()
.filter(|(_, e)| e.scope == scope)
.map(|(k, _)| *k)
.collect();
let mut count = keys.len();
for key in keys {
store.remove(&key);
}
let generation = store.scope_generations.entry(scope.to_owned()).or_default();
*generation = generation.wrapping_add(1);
drop(store);
self.cancel_scope_fill_flights(scope);
let mut delete = Vec::new();
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let keys: Vec<_> = cold
.entries
.iter()
.filter(|(_, entry)| entry.scope == scope)
.map(|(key, _)| *key)
.collect();
count += keys.len();
for key in keys {
if let Some(entry) = cold.remove(&key) {
delete.extend([entry.meta_path, entry.body_path]);
}
}
}
if let (Some(tx), false) = (&self.cold_tx, delete.is_empty()) {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
self.counters
.expired
.fetch_add(store.sweep() as u64, Ordering::Relaxed);
let mut view = ScopeView {
entries: 0,
resident_bytes: 0,
cold_entries: 0,
cold_bytes: 0,
};
for entry in store.entries.values().filter(|entry| entry.scope == scope) {
view.entries += 1;
view.resident_bytes += entry.cost;
}
drop(store);
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for entry in cold.entries.values().filter(|entry| entry.scope == scope) {
view.cold_entries += 1;
view.cold_bytes = view.cold_bytes.saturating_add(entry.disk_bytes);
}
view
}
pub fn record(&self, scope: &str, domain: &str, outcome: &str, reason: &'static str) {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(scope, domain) {
match outcome {
"HIT" => c.hits += 1,
"MISS" => c.misses += 1,
_ => c.bypasses += 1,
}
*c.reasons.entry(reason.into()).or_default() += 1;
}
}
pub fn record_reason(&self, scope: &str, domain: &str, reason: &'static str) {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(scope, domain) {
*c.reasons.entry(reason.into()).or_default() += 1;
}
}
pub fn dashboard(&self, scope: Option<&str>, offset: usize, limit: usize) -> Dashboard {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
self.counters
.expired
.fetch_add(store.sweep() as u64, Ordering::Relaxed);
let revision = store.revision;
let mut groups: BTreeMap<(String, String), DomainView> = store
.domains
.iter()
.filter(|((vhost, _), _)| scope.is_none_or(|s| s == vhost))
.map(|((vhost, domain), counters)| {
(
(vhost.clone(), domain.clone()),
DomainView {
vhost: vhost.clone(),
domain: domain.clone(),
entries: 0,
logical_bytes: 0,
resident_bytes: 0,
hot_entries: 0,
cold_entries: 0,
cold_bytes: 0,
hit_ratio: if counters.hits + counters.misses > 0 {
counters.hits as f64 / (counters.hits + counters.misses) as f64
} else {
0.0
},
counters: counters.clone(),
},
)
})
.collect();
let mut objects = Vec::new();
for key in store.lru.values().rev() {
let Some(e) = store.entries.get(key) else {
continue;
};
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
if age >= e.ttl || !scope.is_none_or(|s| s == e.scope) {
continue;
}
let row = groups
.entry((e.scope.clone(), e.domain.clone()))
.or_insert_with(|| DomainView {
vhost: e.scope.clone(),
domain: e.domain.clone(),
entries: 0,
logical_bytes: 0,
resident_bytes: 0,
hot_entries: 0,
cold_entries: 0,
cold_bytes: 0,
hit_ratio: 0.0,
counters: DomainCounters::default(),
});
row.entries += 1;
row.hot_entries += 1;
row.logical_bytes += e.body.len();
row.resident_bytes += e.cost;
objects.push(ObjectView {
vhost: e.scope.clone(),
scheme: e.scheme.clone(),
authority: e.authority.clone(),
domain: e.domain.clone(),
path: e.path.clone(),
target: e.target.clone(),
cache_key: key_hex(key),
tags: e.tags.clone(),
transformed: e.transformed,
body_bytes: e.body.len(),
resident_bytes: e.cost,
storage_bytes: 0,
tier: "hot",
hits: e.hits,
age_seconds: age,
remaining_ttl_seconds: e.ttl.saturating_sub(age),
});
}
drop(store);
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for key in cold.lru.values().rev() {
let Some(e) = cold.entries.get(key) else {
continue;
};
if e.revision != revision || !scope.is_none_or(|s| s == e.scope) {
continue;
}
let age = cold_age(e);
if age >= e.ttl && !e.requires_revalidation {
continue;
}
let row = groups
.entry((e.scope.clone(), e.domain.clone()))
.or_insert_with(|| DomainView {
vhost: e.scope.clone(),
domain: e.domain.clone(),
entries: 0,
logical_bytes: 0,
resident_bytes: 0,
hot_entries: 0,
cold_entries: 0,
cold_bytes: 0,
hit_ratio: 0.0,
counters: DomainCounters::default(),
});
row.entries += 1;
row.cold_entries += 1;
row.cold_bytes = row.cold_bytes.saturating_add(e.disk_bytes);
objects.push(ObjectView {
vhost: e.scope.clone(),
scheme: e.scheme.clone(),
authority: e.authority.clone(),
domain: e.domain.clone(),
path: e.path.clone(),
target: e.target.clone(),
cache_key: key_hex(key),
tags: e.tags.clone(),
transformed: e.transformed,
body_bytes: e.body_bytes,
resident_bytes: 0,
storage_bytes: e.disk_bytes,
tier: "cold",
hits: e.hits,
age_seconds: age,
remaining_ttl_seconds: e.ttl.saturating_sub(age),
});
}
let total = objects.len();
let matching_body_bytes = objects.iter().map(|entry| entry.body_bytes).sum();
let matching_resident_bytes = objects.iter().map(|entry| entry.resident_bytes).sum();
let matching_cold_bytes = objects.iter().map(|entry| entry.storage_bytes).sum();
let limit = limit.clamp(1, 200);
let objects = objects.into_iter().skip(offset).take(limit).collect();
Dashboard {
domains: groups.into_values().collect(),
objects,
matching_objects: total,
matching_body_bytes,
matching_resident_bytes,
matching_cold_bytes,
offset,
limit,
statistics_revision: revision,
}
}
}
+343
View File
@@ -0,0 +1,343 @@
struct ByteGaugeGuard<'a> {
counter: &'a AtomicU64,
bytes: u64,
}
impl<'a> ByteGaugeGuard<'a> {
fn new(counter: &'a AtomicU64, bytes: u64) -> Self {
counter.fetch_add(bytes, Ordering::Relaxed);
Self { counter, bytes }
}
}
impl Drop for ByteGaugeGuard<'_> {
fn drop(&mut self) {
self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
}
}
struct FillGauge {
cache: Arc<Cache>,
bytes: u64,
leases: Vec<TransientLease>,
}
impl FillGauge {
fn new(cache: Arc<Cache>, bytes: usize, settings: &Settings) -> Option<Self> {
let lease = cache.reserve_fill_allocation(bytes, settings)?;
Some(Self {
cache,
bytes: bytes as u64,
leases: vec![lease],
})
}
fn grow(&mut self, bytes: usize, settings: &Settings) -> bool {
if bytes == 0 {
return true;
}
let Some(lease) = self.cache.reserve_fill_allocation(bytes, settings) else {
return false;
};
self.bytes = self.bytes.saturating_add(bytes as u64);
self.leases.push(lease);
true
}
fn release(&mut self) {
if self.bytes != 0 {
self.cache
.fill_bytes_active
.fetch_sub(self.bytes, Ordering::Relaxed);
self.bytes = 0;
}
self.leases.clear();
}
}
impl Drop for FillGauge {
fn drop(&mut self) {
self.release();
}
}
struct Candidate {
cache: Arc<Cache>,
ticket: Ticket,
plan: Plan,
headers: HeaderMap,
data: Vec<u8>,
fill_gauge: FillGauge,
settings: Settings,
skip_compression: bool,
origin_etag: Option<HeaderValue>,
origin_last_modified: Option<HeaderValue>,
transformed: bool,
_permit: OwnedSemaphorePermit,
_fill_leader: Option<FillLeaderGuard>,
}
impl Candidate {
fn finish(mut self) {
if self
.plan
.expected_length
.is_some_and(|length| self.data.len() != length)
{
let reason = if self.transformed {
"transform_length_mismatch"
} else {
"fill_incomplete"
};
self.cache
.record_reason(&self.ticket.scope, &self.ticket.domain, reason);
self.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
return;
}
if !self.headers.contains_key("date") {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
self.headers.insert("date", value);
}
}
// Each downstream exchange receives a new ID, never a cached origin ID.
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
self.headers.remove(name);
}
let vary = std::mem::take(&mut self.plan.vary);
// Keep the actual backing capacity as the HOT charge. Vec::reserve_exact
// avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers
// the allocation without copying.
let body_allocation = self.data.capacity().max(self.data.len());
let body = Bytes::from(std::mem::take(&mut self.data));
let scope = self.ticket.scope.clone();
let scheme = self.ticket.scheme.clone();
let authority = self.ticket.authority.clone();
let domain = self.ticket.domain.clone();
let path = self.ticket.path.clone();
let target = self.ticket.target.clone();
let tags = cache_tags(&self.headers);
let cost = hot_entry_cost(
body_allocation,
&self.headers,
&vary,
&scope,
&scheme,
&authority,
&domain,
&path,
&target,
&tags,
self.origin_etag.as_ref(),
self.origin_last_modified.as_ref(),
);
let created_at = self.ticket.created_at;
let base_key = self.ticket.key;
self.cache.insert(
self.ticket,
Entry {
base_key,
vary,
scope,
scheme,
authority,
domain,
path,
target,
tags,
created_at,
body,
body_allocated: body_allocation,
headers: self.headers,
received: self.plan.received,
last_access: Instant::now(),
resident_since: Instant::now(),
resident_age_base: Duration::ZERO,
initial_age: self.plan.initial_age,
ttl: self.plan.ttl,
stale_while_revalidate: self.plan.stale_while_revalidate,
stale_if_error: self.plan.stale_if_error,
must_revalidate: self.plan.must_revalidate,
proxy_revalidate: self.plan.proxy_revalidate,
origin_etag: self.origin_etag,
origin_last_modified: self.origin_last_modified,
cost,
sequence: 0,
hits: 0,
transformed: self.transformed,
skip_compression: self.skip_compression,
requires_revalidation: self.plan.requires_revalidation,
},
&self.settings,
);
}
}
struct FillBody {
inner: Body,
candidate: Option<Candidate>,
done: bool,
}
impl HttpBody for FillBody {
type Data = Bytes;
type Error = axum::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
let this = self.get_mut();
match Pin::new(&mut this.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
if let Some(candidate) = &mut this.candidate {
if let Some(data) = frame.data_ref() {
let needed = candidate.data.len().saturating_add(data.len());
if needed <= candidate.settings.max_object_bytes {
let before = candidate.data.capacity();
let required_growth = needed.saturating_sub(before);
if required_growth > 0
&& !candidate
.fill_gauge
.grow(required_growth, &candidate.settings)
{
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"hot_memory_pressure",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} else if required_growth > 0
&& candidate
.data
.try_reserve_exact(needed.saturating_sub(candidate.data.len()))
.is_err()
{
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"fill_allocation_failed",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} else {
let after = candidate.data.capacity();
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"fill_allocator_overshoot",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} else {
candidate.data.extend_from_slice(data);
}
}
} else {
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"object_too_large",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
}
} else {
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"fill_incomplete",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} // Do not cache responses with trailers.
}
if this.inner.is_end_stream() {
this.done = true;
if let Some(candidate) = this.candidate.take() {
candidate.finish();
}
}
Poll::Ready(Some(Ok(frame)))
}
Poll::Ready(None) => {
this.done = true;
if let Some(candidate) = this.candidate.take() {
candidate.finish();
}
Poll::Ready(None)
}
Poll::Ready(Some(Err(error))) => {
if let Some(candidate) = &this.candidate {
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"fill_incomplete",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
}
this.candidate = None;
this.done = true;
Poll::Ready(Some(Err(error)))
}
Poll::Pending => Poll::Pending,
}
}
fn is_end_stream(&self) -> bool {
self.done
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
impl Drop for FillBody {
fn drop(&mut self) {
if self.done {
return;
}
if let Some(candidate) = self.candidate.take() {
candidate.cache.record_reason(
&candidate.ticket.scope,
&candidate.ticket.domain,
"fill_incomplete",
);
candidate
.cache
.counters
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
}
}
}
+584
View File
@@ -0,0 +1,584 @@
use anyhow::Context as _;
const MAX_BAN_RULES: usize = 1024;
const DEFAULT_BAN_TTL_SECONDS: u64 = 3600;
const MAX_BAN_TTL_SECONDS: u64 = 604_800;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvalidationOperation {
Purge,
Ban,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InvalidationSelector {
Exact,
Prefix,
Tag,
Regex,
CacheKey,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvalidationRequest {
pub operation: InvalidationOperation,
pub selector: InvalidationSelector,
pub value: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub vhost: Option<String>,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub confirm_wide: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct InvalidationResult {
pub operation: InvalidationOperation,
pub selector: InvalidationSelector,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vhost: Option<String>,
pub affected_entries: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub ban_id: Option<u64>,
pub elapsed_ms: u64,
pub status: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct BanView {
pub id: u64,
pub selector: InvalidationSelector,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vhost: Option<String>,
pub created_at_ms: u64,
pub expires_at_ms: u64,
}
#[derive(Clone)]
struct SelectorMatcher {
selector: InvalidationSelector,
value: String,
scheme: Option<String>,
host: Option<String>,
vhost: Option<String>,
regex: Option<regex::Regex>,
}
#[derive(Clone)]
struct BanRule {
id: u64,
matcher: SelectorMatcher,
created_at: SystemTime,
expires_at: SystemTime,
}
fn canonical_url(scheme: &str, authority: &str, target: &str) -> String {
format!("{scheme}://{authority}{target}")
}
fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher> {
ensure!(!request.value.is_empty(), "invalidation value is required");
ensure!(request.value.len() <= 8192, "invalidation value is too long");
if let Some(vhost) = &request.vhost {
ensure!(!vhost.is_empty() && vhost.len() <= 64, "invalid vhost selector");
}
if let Some(host) = &request.host {
ensure!(!host.is_empty() && host.len() <= 253, "invalid host selector");
}
let mut value = request.value.trim().to_owned();
let mut scheme = None;
let mut host = request.host.as_ref().map(|value| value.to_ascii_lowercase());
let regex = match request.selector {
InvalidationSelector::Exact | InvalidationSelector::Prefix => {
if value.starts_with("http://") || value.starts_with("https://") {
let url = url::Url::parse(&value).context("invalid purge URL")?;
ensure!(url.username().is_empty() && url.password().is_none(), "purge URL must not contain credentials");
ensure!(url.fragment().is_none(), "purge URL must not contain a fragment");
scheme = Some(url.scheme().to_ascii_lowercase());
let authority = match url.port() {
Some(port) => format!("{}:{port}", url.host_str().context("purge URL host missing")?),
None => url.host_str().context("purge URL host missing")?.to_owned(),
};
host = Some(authority.to_ascii_lowercase());
value = url.path().to_owned();
if let Some(query) = url.query() {
value.push('?');
value.push_str(query);
}
}
ensure!(value.starts_with('/'), "exact/prefix invalidation must use an absolute URL or /path target");
None
}
InvalidationSelector::Tag => {
ensure!(value.len() <= 128, "cache tag is too long");
ensure!(value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')), "invalid cache tag");
None
}
InvalidationSelector::Regex => {
ensure!(value.len() <= 512, "regex is too long");
Some(
regex::RegexBuilder::new(&value)
.size_limit(1 << 20)
.build()
.context("invalid or too expensive regex")?,
)
}
InvalidationSelector::CacheKey => {
ensure!(parse_key_hex(&value).is_some(), "cache_key must be 64 hexadecimal characters");
value.make_ascii_lowercase();
None
}
};
Ok(SelectorMatcher {
selector: request.selector,
value,
scheme,
host,
vhost: request.vhost.clone(),
regex,
})
}
fn requires_wide_confirmation(matcher: &SelectorMatcher) -> bool {
match matcher.selector {
InvalidationSelector::Prefix => matcher.value == "/",
InvalidationSelector::Regex => true,
_ => false,
}
}
fn host_matches(matcher: &SelectorMatcher, scheme: &str, authority: &str, domain: &str) -> bool {
if matcher
.scheme
.as_ref()
.is_some_and(|wanted| !wanted.eq_ignore_ascii_case(scheme))
{
return false;
}
matcher.host.as_ref().is_none_or(|wanted| {
wanted.eq_ignore_ascii_case(authority) || wanted.eq_ignore_ascii_case(domain)
})
}
fn matcher_matches(
matcher: &SelectorMatcher,
storage_key: &Key,
base_key: &Key,
scope: &str,
scheme: &str,
authority: &str,
domain: &str,
target: &str,
tags: &[String],
) -> bool {
if matcher.vhost.as_ref().is_some_and(|wanted| wanted != scope) {
return false;
}
match matcher.selector {
InvalidationSelector::Exact => {
host_matches(matcher, scheme, authority, domain) && target == matcher.value
}
InvalidationSelector::Prefix => {
host_matches(matcher, scheme, authority, domain) && target.starts_with(&matcher.value)
}
InvalidationSelector::Tag => {
host_matches(matcher, scheme, authority, domain)
&& tags.iter().any(|tag| tag == &matcher.value)
},
InvalidationSelector::Regex => {
if !host_matches(matcher, scheme, authority, domain) {
return false;
}
matcher
.regex
.as_ref()
.is_some_and(|regex| regex.is_match(&canonical_url(scheme, authority, target)))
}
InvalidationSelector::CacheKey => {
host_matches(matcher, scheme, authority, domain)
&& (key_hex(storage_key) == matcher.value || key_hex(base_key) == matcher.value)
}
}
}
impl BanRule {
fn expired(&self, now: SystemTime) -> bool {
now >= self.expires_at
}
fn matches_hot(&self, key: &Key, entry: &Entry, now: SystemTime) -> bool {
!self.expired(now)
&& entry.created_at <= self.created_at
&& matcher_matches(
&self.matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
)
}
fn matches_cold(&self, key: &Key, entry: &ColdEntry, now: SystemTime) -> bool {
!self.expired(now)
&& entry.created_at <= self.created_at
&& matcher_matches(
&self.matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
)
}
fn view(&self) -> BanView {
BanView {
id: self.id,
selector: self.matcher.selector,
value: self.matcher.value.clone(),
scheme: self.matcher.scheme.clone(),
host: self.matcher.host.clone(),
vhost: self.matcher.vhost.clone(),
created_at_ms: self
.created_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
expires_at_ms: self
.expires_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
}
}
}
impl Cache {
fn active_bans(&self) -> Vec<BanRule> {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
bans.clone()
}
fn hot_entry_banned(&self, key: &Key, entry: &Entry) -> bool {
let now = SystemTime::now();
let banned = self
.active_bans()
.iter()
.any(|rule| rule.matches_hot(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
}
banned
}
fn cold_entry_banned(&self, key: &Key, entry: &ColdEntry) -> bool {
let now = SystemTime::now();
let banned = self
.active_bans()
.iter()
.any(|rule| rule.matches_cold(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
}
banned
}
fn entry_banned_before_insert(&self, key: &Key, entry: &Entry) -> bool {
let now = SystemTime::now();
self.active_bans()
.iter()
.any(|rule| rule.matches_hot(key, entry, now))
}
pub fn bans(&self) -> Vec<BanView> {
self.active_bans().iter().map(BanRule::view).collect()
}
pub fn remove_ban(&self, id: u64) -> bool {
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
let before = bans.len();
bans.retain(|rule| rule.id != id);
let removed = bans.len() != before;
if removed {
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
}
removed
}
pub fn invalidate(&self, request: InvalidationRequest) -> Result<InvalidationResult> {
let started = Instant::now();
let matcher = normalized_matcher(&request)?;
if requires_wide_confirmation(&matcher) {
ensure!(request.confirm_wide, "wide invalidation requires confirm_wide=true");
}
match request.operation {
InvalidationOperation::Ban => {
let ttl = request.ttl_seconds.unwrap_or(DEFAULT_BAN_TTL_SECONDS);
ensure!((1..=MAX_BAN_TTL_SECONDS).contains(&ttl), "BAN ttl_seconds must be 1..604800");
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
ensure!(bans.len() < MAX_BAN_RULES, "active BAN rule limit reached");
let id = self.ban_sequence.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
bans.push(BanRule {
id,
matcher: matcher.clone(),
created_at: now,
expires_at: now
.checked_add(Duration::from_secs(ttl))
.unwrap_or(now),
});
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
selector: request.selector,
value: matcher.value,
host: matcher.host,
vhost: matcher.vhost,
affected_entries: 0,
ban_id: Some(id),
elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64,
status: "active",
})
}
InvalidationOperation::Purge => {
// A selective purge is also a publication barrier for every fill that
// started before this operation. Existing unrelated objects remain
// reusable, but an old in-flight response cannot repopulate a target
// that was not yet identifiable (for example Cache-Tag before headers).
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1);
}
let affected = self.purge_matcher(&matcher);
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(affected as u64, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
selector: request.selector,
value: matcher.value,
host: matcher.host,
vhost: matcher.vhost,
affected_entries: affected,
ban_id: None,
elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64,
status: "completed",
})
}
}
}
fn purge_matcher(&self, matcher: &SelectorMatcher) -> usize {
let mut base_keys = HashSet::new();
{
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
for (key, entry) in &store.entries {
if matcher_matches(
matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
) {
base_keys.insert(entry.base_key);
}
}
}
{
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for (key, entry) in &cold.entries {
if matcher_matches(
matcher,
key,
&entry.base_key,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.target,
&entry.tags,
) {
base_keys.insert(entry.base_key);
}
}
}
let matching_flights = {
let flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
.iter()
.filter_map(|(key, flight)| {
let empty_tags: &[String] = &[];
matcher_matches(
matcher,
key,
key,
&flight.scope,
&flight.scheme,
&flight.authority,
&crate::config::request_host(&flight.authority)
.unwrap_or_else(|_| flight.authority.clone()),
&flight.target,
empty_tags,
)
.then_some((*key, flight.id))
})
.collect::<Vec<_>>()
};
for (key, _) in &matching_flights {
base_keys.insert(*key);
}
if !base_keys.is_empty() {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
for key in &base_keys {
let generation = store.key_generations.entry(*key).or_default();
*generation = generation.wrapping_add(1);
}
}
let hot_removed = {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys = store
.entries
.iter()
.filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key))
.collect::<Vec<_>>();
for key in &keys {
store.remove(key);
}
keys.len()
};
let mut delete = Vec::new();
let cold_removed = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let keys = cold
.entries
.iter()
.filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key))
.collect::<Vec<_>>();
for key in &keys {
if let Some(entry) = cold.remove(key) {
delete.extend([entry.meta_path, entry.body_path]);
}
}
keys.len()
};
if !delete.is_empty() {
if let Some(tx) = &self.cold_tx {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
}
let flights = {
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
matching_flights
.into_iter()
.filter_map(|(key, id)| {
if flights.get(&key).is_some_and(|flight| flight.id == id) {
flights.remove(&key)
} else {
None
}
})
.collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
}
if hot_removed > 0 {
release_allocator_memory();
}
hot_removed + cold_removed
}
pub fn gc_banned(&self, limit: usize) -> usize {
if limit == 0 {
return 0;
}
let rules = self.active_bans();
if rules.is_empty() {
return 0;
}
let now = SystemTime::now();
let mut removed = 0usize;
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys = store
.entries
.iter()
.filter_map(|(key, entry)| {
(removed < limit && rules.iter().any(|rule| rule.matches_hot(key, entry, now)))
.then_some(*key)
})
.take(limit)
.collect::<Vec<_>>();
for key in keys {
if store.remove(&key).is_some() {
removed += 1;
}
}
}
let mut delete = Vec::new();
if removed < limit {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let keys = cold
.entries
.iter()
.filter_map(|(key, entry)| {
rules
.iter()
.any(|rule| rule.matches_cold(key, entry, now))
.then_some(*key)
})
.take(limit - removed)
.collect::<Vec<_>>();
for key in keys {
if let Some(entry) = cold.remove(&key) {
delete.extend([entry.meta_path, entry.body_path]);
removed += 1;
}
}
}
if !delete.is_empty() {
if let Some(tx) = &self.cold_tx {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
}
if removed > 0 {
self.counters
.ban_matches
.fetch_add(removed as u64, Ordering::Relaxed);
release_allocator_memory();
}
removed
}
}
+281
View File
@@ -0,0 +1,281 @@
impl Cache {
pub fn new(revision: u64) -> Arc<Self> {
Arc::new(Self {
store: Arc::new(Mutex::new(Store {
revision,
..Default::default()
})),
revision: AtomicU64::new(revision),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(Settings::default()),
data_root: None,
cold_tx: None,
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
fill_bytes_active: AtomicU64::new(0),
transient_bytes_active: Arc::new(AtomicU64::new(0)),
cold_queue_items: AtomicU64::new(0),
cold_queue_bytes: AtomicU64::new(0),
cold_write_bytes_active: AtomicU64::new(0),
cold_read_bytes_active: AtomicU64::new(0),
revalidating: Mutex::new(HashSet::new()),
fill_flights: Mutex::new(HashMap::new()),
fill_flight_sequence: AtomicU64::new(0),
collapse_waiters: AtomicU64::new(0),
bans: Mutex::new(Vec::new()),
ban_sequence: AtomicU64::new(0),
proactive_scheduled_keys: Mutex::new(HashSet::new()),
proactive_inflight: AtomicU64::new(0),
cleanup_jobs: Mutex::new(HashMap::new()),
cleanup_sequence: AtomicU64::new(0),
counters: Counters::default(),
})
}
pub fn start(
revision: u64,
data_root: Option<PathBuf>,
settings: Settings,
shutdown: CancellationToken,
) -> Arc<Self> {
let (tx, rx) = mpsc::channel(COLD_QUEUE);
let cache = Arc::new(Self {
store: Arc::new(Mutex::new(Store {
revision,
..Default::default()
})),
revision: AtomicU64::new(revision),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(settings),
data_root,
cold_tx: Some(tx),
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
fill_bytes_active: AtomicU64::new(0),
transient_bytes_active: Arc::new(AtomicU64::new(0)),
cold_queue_items: AtomicU64::new(0),
cold_queue_bytes: AtomicU64::new(0),
cold_write_bytes_active: AtomicU64::new(0),
cold_read_bytes_active: AtomicU64::new(0),
revalidating: Mutex::new(HashSet::new()),
fill_flights: Mutex::new(HashMap::new()),
fill_flight_sequence: AtomicU64::new(0),
collapse_waiters: AtomicU64::new(0),
bans: Mutex::new(Vec::new()),
ban_sequence: AtomicU64::new(0),
proactive_scheduled_keys: Mutex::new(HashSet::new()),
proactive_inflight: AtomicU64::new(0),
cleanup_jobs: Mutex::new(HashMap::new()),
cleanup_sequence: AtomicU64::new(0),
counters: Counters::default(),
});
cache.load_cold_startup();
let weak = Arc::downgrade(&cache);
let worker_shutdown = shutdown.clone();
tokio::spawn(async move { Self::cold_worker(weak, rx, worker_shutdown).await });
let weak = Arc::downgrade(&cache);
let maintenance_shutdown = shutdown.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(5));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = maintenance_shutdown.cancelled() => break,
_ = tick.tick() => {
let Some(cache) = weak.upgrade() else { break; };
cache.demote_idle();
cache.sweep_cold().await;
cache.gc_banned(256);
}
}
}
});
let weak = Arc::downgrade(&cache);
tokio::spawn(async move {
loop {
let Some(cache) = weak.upgrade() else { break; };
let interval = cache
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.cold_gc_interval_seconds;
drop(cache);
tokio::select! {
_ = shutdown.cancelled() => break,
_ = tokio::time::sleep(Duration::from_secs(interval)) => {
let Some(cache) = weak.upgrade() else { break; };
cache.cold_gc_pass().await;
}
}
}
});
cache
}
fn fill_headroom_bytes(max_bytes: usize) -> usize {
// Allocation rounding is allowed a small global cushion, never a whole
// max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and
// never let the cushion itself exceed 1/8 of a very small HOT budget.
let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024);
proportional.min((max_bytes / 8).max(1))
}
fn try_reserve_transient(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> {
let bytes = u64::try_from(bytes).ok()?;
let limit = u64::try_from(settings.transient_max_bytes).unwrap_or(u64::MAX);
loop {
let current = self.transient_bytes_active.load(Ordering::Relaxed);
let next = current.checked_add(bytes)?;
if next > limit {
self.counters
.memory_pressure_rejections
.fetch_add(1, Ordering::Relaxed);
return None;
}
if self
.transient_bytes_active
.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
return Some(TransientLease(Arc::new(TransientLeaseInner {
counter: self.transient_bytes_active.clone(),
bytes,
})));
}
}
}
/// Reserve real allocation for an in-progress fill before growing its buffer.
/// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill
/// allocation cannot race arbitrarily above the configured HOT budget.
fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> {
if bytes == 0 {
return self.try_reserve_transient(0, settings);
}
let lease = self.try_reserve_transient(bytes, settings)?;
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed))
.unwrap_or(usize::MAX);
let allowed = settings
.max_bytes
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
let revision = store.revision;
let generation = store.generation;
let mut removed = 0usize;
let mut released_hot = false;
while store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
> allowed
{
if removed >= settings.hot_eviction_max_objects {
self.counters
.hot_eviction_limited
.fetch_add(1, Ordering::Relaxed);
break;
}
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
let Some(old) = store.remove(&key) else {
continue;
};
removed = removed.saturating_add(1);
released_hot = true;
let scope_generation = store
.scope_generations
.get(&old.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&old.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key,
old,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
let fits = store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
<= allowed;
if fits {
self.fill_bytes_active
.fetch_add(bytes as u64, Ordering::Relaxed);
}
drop(store);
if released_hot {
release_allocator_memory();
}
if fits {
Some(lease)
} else {
self.counters
.memory_pressure_rejections
.fetch_add(1, Ordering::Relaxed);
drop(lease);
None
}
}
pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
// objects. Demotion is best effort and remains bounded by transient/COLD
// queue budgets; otherwise the LRU object is dropped.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let generation = store.generation;
let mut released_hot = false;
while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
let Some(old) = store.remove(&key) else {
continue;
};
released_hot = true;
let scope_generation = store
.scope_generations
.get(&old.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&old.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key,
old,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
drop(store);
if released_hot {
release_allocator_memory();
}
}
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Relaxed)
}
}
+644
View File
@@ -0,0 +1,644 @@
impl Cache {
fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option<Hit> {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let expired = store.sweep();
self.counters
.expired
.fetch_add(expired as u64, Ordering::Relaxed);
if store.revision != ticket.revision
|| store.generation != ticket.generation
|| store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
{
return None;
}
if let Some(storage_key) = matching_hot_key(&store, ticket) {
let Some(mut entry) = store.remove(&storage_key) else {
return None;
};
if self.hot_entry_banned(&storage_key, &entry) {
if count_miss {
self.counters.misses.fetch_add(1, Ordering::Relaxed);
}
drop(store);
release_allocator_memory();
return None;
}
let resident_age_seconds = hot_resident_age(&entry).as_secs();
let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs();
let state = cache_state(
age,
entry.ttl,
entry.stale_while_revalidate,
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
);
if state.servable() {
entry.hits = entry.hits.saturating_add(1);
entry.last_access = Instant::now();
entry.sequence = store.next_sequence();
let mut response = Response::new(Body::from(entry.body.clone()));
*response.headers_mut() = entry.headers.clone();
if entry.transformed {
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if entry.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
let result = Hit {
response,
body: entry.body.clone(),
hits: entry.hits,
transformed: entry.transformed,
remaining_ttl: entry.ttl.saturating_sub(age),
resident_age_seconds,
stale: state.stale(),
background_revalidate: state.background_revalidate(),
};
if let Some(c) = store.domain(&entry.scope, &entry.domain) {
c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body.len() as u64);
}
store.bytes = store.bytes.saturating_add(entry.cost);
store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len());
store.lru.insert(entry.sequence, storage_key);
store.index_variant(entry.base_key, storage_key);
store.entries.insert(storage_key, entry);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
return Some(result);
}
store.bytes = store.bytes.saturating_add(entry.cost);
store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len());
store.lru.insert(entry.sequence, storage_key);
store.index_variant(entry.base_key, storage_key);
store.entries.insert(storage_key, entry);
}
if count_miss {
self.counters.misses.fetch_add(1, Ordering::Relaxed);
}
None
}
pub fn get(&self, ticket: &Ticket) -> Option<Hit> {
self.get_hot(ticket, true)
}
async fn get_cold(&self, ticket: &Ticket, settings: &Settings) -> Option<Hit> {
if !self.ticket_current(ticket) {
return None;
}
let banned = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
matching_cold_key(&cold, ticket).and_then(|storage_key| {
let banned = cold
.entries
.get(&storage_key)
.is_some_and(|entry| self.cold_entry_banned(&storage_key, entry));
banned.then(|| cold.remove(&storage_key)).flatten()
})
};
if let Some(entry) = banned {
self.delete_cold_entry(entry);
return None;
}
let (storage_key, entry) = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let storage_key = matching_cold_key(&cold, ticket)?;
let old_sequence = {
let entry = cold.entries.get(&storage_key)?;
if entry.revision != ticket.revision || entry.scope != ticket.scope {
return None;
}
let state = cache_state(
cold_age(entry),
entry.ttl,
entry.stale_while_revalidate,
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
);
if !state.servable() {
return None;
}
entry.sequence
};
let sequence = cold.next_sequence();
cold.lru.remove(&old_sequence);
cold.lru.insert(sequence, storage_key);
let entry = cold.entries.get_mut(&storage_key)?;
entry.sequence = sequence;
entry.last_access = SystemTime::now();
entry.pinned_reads = entry.pinned_reads.saturating_add(1);
(storage_key, entry.clone())
};
let _pin = ColdReadGuard {
cache: self,
key: storage_key,
generation: entry.generation,
};
if entry.body_bytes > settings.max_object_bytes {
self.counters
.memory_pressure_rejections
.fetch_add(1, Ordering::Relaxed);
return None;
}
let Some(read_lease) = self.try_reserve_transient(entry.body_bytes, settings) else {
return None;
};
let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64);
let body_vec = match tokio::fs::read(&entry.body_path).await {
Ok(body) if cold_body_matches(&entry, &body) => body,
_ => {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
if let Some(entry) = self.take_cold(&storage_key) {
self.delete_cold_entry(entry);
}
return None;
}
};
let body_allocated = body_vec.capacity().max(body_vec.len());
let extra = body_allocated.saturating_sub(entry.body_bytes);
let extra_lease = if extra > 0 {
self.try_reserve_transient(extra, settings)
} else {
None
};
if extra > 0 && extra_lease.is_none() {
return None;
}
let _cold_read_extra = extra_lease
.as_ref()
.map(|_| ByteGaugeGuard::new(&self.cold_read_bytes_active, extra as u64));
let body = Bytes::from(body_vec);
if !self.ticket_current(ticket) {
return None;
}
let age = cold_age(&entry);
let state = cache_state(
age,
entry.ttl,
entry.stale_while_revalidate,
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
);
if !state.servable() {
return None;
}
let hits = entry.hits.saturating_add(1);
let cost = hot_entry_cost(
body_allocated,
&entry.headers,
&entry.vary,
&entry.scope,
&entry.scheme,
&entry.authority,
&entry.domain,
&entry.path,
&entry.target,
&entry.tags,
entry.origin_etag.as_ref(),
entry.origin_last_modified.as_ref(),
);
let resident_age_seconds = cold_resident_age(&entry);
let hot_entry = Entry {
base_key: entry.base_key,
vary: entry.vary.clone(),
scope: entry.scope.clone(),
scheme: entry.scheme.clone(),
authority: entry.authority.clone(),
domain: entry.domain.clone(),
path: entry.path.clone(),
target: entry.target.clone(),
tags: entry.tags.clone(),
created_at: entry.created_at,
headers: entry.headers.clone(),
body: body.clone(),
body_allocated,
received: Instant::now(),
last_access: Instant::now(),
resident_since: Instant::now(),
resident_age_base: Duration::from_secs(resident_age_seconds),
initial_age: Duration::from_secs(age),
ttl: entry.ttl,
stale_while_revalidate: entry.stale_while_revalidate,
stale_if_error: entry.stale_if_error,
must_revalidate: entry.must_revalidate,
proxy_revalidate: entry.proxy_revalidate,
origin_etag: entry.origin_etag.clone(),
origin_last_modified: entry.origin_last_modified.clone(),
cost,
sequence: 0,
hits,
transformed: entry.transformed,
skip_compression: entry.skip_compression,
requires_revalidation: entry.requires_revalidation,
};
if !self.insert_inner(ticket.clone(), hot_entry, settings, false) {
return None;
}
let mut response = Response::new(Body::from(body.clone()));
*response.headers_mut() = entry.headers;
if entry.transformed {
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if entry.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(&ticket.scope, &ticket.domain) {
c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body_bytes as u64);
}
}
self.counters.hits.fetch_add(1, Ordering::Relaxed);
self.counters.cold_hits.fetch_add(1, Ordering::Relaxed);
self.counters
.cold_promotions
.fetch_add(1, Ordering::Relaxed);
Some(Hit {
response,
body,
hits,
transformed: entry.transformed,
remaining_ttl: entry.ttl.saturating_sub(age),
resident_age_seconds,
stale: state.stale(),
background_revalidate: state.background_revalidate(),
})
}
pub async fn get_tiered(&self, ticket: &Ticket, settings: &Settings) -> Option<Hit> {
if let Some(hit) = self.get_hot(ticket, false) {
return Some(hit);
}
if let Some(hit) = self.get_cold(ticket, settings).await {
return Some(hit);
}
self.counters.misses.fetch_add(1, Ordering::Relaxed);
None
}
pub fn publish_revalidated(
&self,
ticket: Ticket,
cached: &Revalidation,
mut headers: HeaderMap,
validation_headers: &HeaderMap,
settings: &Settings,
response_delay: Duration,
) -> Option<Hit> {
let plan = plan(StatusCode::OK, &headers, settings, response_delay)?;
if cached.body.len() > settings.max_object_bytes {
return None;
}
if !headers.contains_key("date") {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
headers.insert("date", value);
}
}
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
headers.remove(name);
}
let hits = cached.hits.saturating_add(1);
let age = plan.initial_age.as_secs();
let remaining_ttl = plan.ttl.saturating_sub(age);
let vary = plan.vary.clone();
let tags = cache_tags(&headers);
let origin_etag = validation_headers
.get("etag")
.cloned()
.or_else(|| cached.origin_etag.clone());
let origin_last_modified = validation_headers
.get("last-modified")
.cloned()
.or_else(|| cached.origin_last_modified.clone());
let cost = hot_entry_cost(
cached.body_allocated,
&headers,
&vary,
&ticket.scope,
&ticket.scheme,
&ticket.authority,
&ticket.domain,
&ticket.path,
&ticket.target,
&tags,
origin_etag.as_ref(),
origin_last_modified.as_ref(),
);
let storage_key = variant_key(&ticket.key, &vary, &ticket.request_headers);
let resident_age = cached.resident_age_at_lookup.saturating_add(response_delay);
let entry = Entry {
base_key: ticket.key,
vary,
scope: ticket.scope.clone(),
scheme: ticket.scheme.clone(),
authority: ticket.authority.clone(),
domain: ticket.domain.clone(),
path: ticket.path.clone(),
target: ticket.target.clone(),
tags,
created_at: ticket.created_at,
headers: headers.clone(),
body: cached.body.clone(),
body_allocated: cached.body_allocated,
received: plan.received,
last_access: Instant::now(),
resident_since: Instant::now(),
resident_age_base: resident_age,
initial_age: plan.initial_age,
ttl: plan.ttl,
stale_while_revalidate: plan.stale_while_revalidate,
stale_if_error: plan.stale_if_error,
must_revalidate: plan.must_revalidate,
proxy_revalidate: plan.proxy_revalidate,
origin_etag,
origin_last_modified,
cost,
sequence: 0,
hits,
transformed: cached.transformed,
skip_compression: cached.skip_compression,
requires_revalidation: plan.requires_revalidation,
};
if !self.insert(ticket, entry, settings) {
return None;
}
if cached.storage_key != storage_key {
self.discard_storage_key(&cached.storage_key);
}
self.counters.hits.fetch_add(1, Ordering::Relaxed);
let mut response = cached.response(headers);
response.headers_mut().insert(
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
Some(Hit {
response,
body: cached.body.clone(),
hits,
transformed: cached.transformed,
remaining_ttl,
resident_age_seconds: resident_age.as_secs(),
stale: false,
background_revalidate: false,
})
}
pub fn wrap(
self: &Arc<Self>,
response: Response,
ticket: Ticket,
plan: Plan,
settings: &Settings,
) -> Response {
self.wrap_with_fill(response, ticket, plan, settings, None)
}
pub fn wrap_with_fill(
self: &Arc<Self>,
response: Response,
ticket: Ticket,
plan: Plan,
settings: &Settings,
fill_leader: Option<FillLeaderGuard>,
) -> Response {
let fill_size = plan.expected_length.unwrap_or(settings.max_object_bytes);
let permits = if fill_size > 64 * 1024 * 1024 {
FILL_SLOTS as u32
} else if fill_size > 16 * 1024 * 1024 {
4
} else if fill_size > 4 * 1024 * 1024 {
2
} else {
1
};
let permit = self.fills.clone().try_acquire_many_owned(permits);
let Ok(permit) = permit else {
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
return response;
};
let (parts, body) = response.into_parts();
let headers = parts.headers.clone();
let origin_validators = parts
.extensions
.get::<crate::transform::OriginValidators>()
.cloned();
let origin_etag = match origin_validators.as_ref() {
Some(validators) => validators.etag.clone(),
None => headers.get("etag").cloned(),
};
let origin_last_modified = match origin_validators.as_ref() {
Some(validators) => validators.last_modified.clone(),
None => headers.get("last-modified").cloned(),
};
let initial_capacity = fill_size.min(64 * 1024);
let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else {
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
return Response::from_parts(parts, body);
};
let data = Vec::with_capacity(initial_capacity);
let actual_capacity = data.capacity();
if actual_capacity > initial_capacity
&& !fill_gauge.grow(actual_capacity - initial_capacity, settings)
{
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
self.record_reason(&ticket.scope, &ticket.domain, "fill_allocator_overshoot");
return Response::from_parts(parts, body);
}
let candidate = Candidate {
cache: self.clone(),
ticket,
plan,
headers,
data,
fill_gauge,
settings: settings.clone(),
skip_compression: parts.extensions.get::<crate::compression::Skip>().is_some(),
origin_etag,
origin_last_modified,
transformed: parts.extensions.get::<crate::transform::Applied>().is_some(),
_permit: permit,
_fill_leader: fill_leader,
};
Response::from_parts(
parts,
Body::new(FillBody {
inner: body,
candidate: Some(candidate),
done: false,
}),
)
}
fn insert(&self, ticket: Ticket, entry: Entry, settings: &Settings) -> bool {
self.insert_inner(ticket, entry, settings, true)
}
fn insert_inner(
&self,
ticket: Ticket,
mut entry: Entry,
settings: &Settings,
count_stored: bool,
) -> bool {
entry.base_key = ticket.key;
let storage_key = variant_key(&ticket.key, &entry.vary, &ticket.request_headers);
let vary_schema = entry.vary.clone();
if self.entry_banned_before_insert(&storage_key, &entry) {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
return false;
}
if entry.cost > settings.max_bytes
|| (!entry.requires_revalidation
&& entry.initial_age.saturating_add(entry.received.elapsed()).as_secs()
>= retention_deadline(
entry.ttl,
entry.stale_while_revalidate,
entry.stale_if_error,
entry.origin_etag.is_some() || entry.origin_last_modified.is_some(),
))
{
return false;
}
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if store.revision != ticket.revision
|| store.generation != ticket.generation
|| store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
{
return false;
}
// Do not keep objects for incompatible Vary schemas under the same
// Varnish-style host+URL hash. A newer representation policy replaces
// the older schema while preserving sibling values of the same Vary set.
for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) {
store.remove(&key);
}
store.remove(&storage_key);
self.counters
.expired
.fetch_add(store.sweep() as u64, Ordering::Relaxed);
let revision = store.revision;
let generation = store.generation;
let mut released_hot = false;
let mut removed = 0usize;
while store.entries.len() >= settings.max_entries
|| store.bytes.saturating_add(entry.cost) > settings.max_bytes
{
if removed >= settings.hot_eviction_max_objects {
self.counters
.hot_eviction_limited
.fetch_add(1, Ordering::Relaxed);
break;
}
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
if let Some(old) = store.remove(&key) {
removed = removed.saturating_add(1);
released_hot = true;
let scope_generation = store
.scope_generations
.get(&old.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&old.base_key).copied().unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(key, old, revision, generation, scope_generation, key_generation))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
}
if store.entries.len() >= settings.max_entries
|| store.bytes.saturating_add(entry.cost) > settings.max_bytes
{
self.counters
.memory_pressure_rejections
.fetch_add(1, Ordering::Relaxed);
drop(store);
if released_hot {
release_allocator_memory();
}
return false;
}
if count_stored {
if let Some(c) = store.domain(&entry.scope, &entry.domain) {
c.stored += 1;
}
}
entry.last_access = Instant::now();
entry.sequence = store.next_sequence();
store.bytes = store.bytes.saturating_add(entry.cost);
store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len());
store.lru.insert(entry.sequence, storage_key);
store.index_variant(entry.base_key, storage_key);
store.entries.insert(storage_key, entry);
drop(store);
if released_hot {
release_allocator_memory();
}
let mut cold_delete = Vec::new();
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for key in incompatible_cold_variants(&cold, &ticket.key, &vary_schema) {
if let Some(old) = cold.remove(&key) {
cold_delete.push(old);
}
}
if let Some(old) = cold.remove(&storage_key) {
cold_delete.push(old);
}
}
for old in cold_delete {
self.delete_cold_entry(old);
}
if count_stored {
self.counters.stored.fetch_add(1, Ordering::Relaxed);
}
true
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM
//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier.
//! Bodies are streamed while a bounded copy is collected; only complete successful
//! bodies are published. The implementation is split by responsibility so storage,
//! policy and streaming-fill code can be reviewed independently.
include!("model.rs");
include!("lifecycle.rs");
include!("cold.rs");
include!("dashboard.rs");
include!("tickets.rs");
include!("collapse.rs");
include!("range.rs");
include!("invalidation.rs");
include!("lookup.rs");
include!("policy.rs");
include!("fill.rs");
include!("tests.rs");
+1668
View File
File diff suppressed because it is too large. Load diff
+570
View File
@@ -0,0 +1,570 @@
/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
for raw in header.to_str().ok()?.split(',') {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (key, value) = match raw.split_once('=') {
Some((k, v)) => {
let v = v.trim();
let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) {
return None;
}
inner
} else {
if v.is_empty() || v.bytes().any(|b| b <= 32 || b == b'"' || b == b'\\') {
return None;
}
v
};
(k.trim().to_ascii_lowercase(), Some(value.to_owned()))
}
None => (raw.to_ascii_lowercase(), None),
};
if key.parse::<http::HeaderName>().is_err() {
return None;
}
if matches!(
key.as_str(),
"public" | "no-store" | "must-revalidate" | "proxy-revalidate" | "only-if-cached"
) && value.is_some()
{
return None;
}
if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() {
return None;
}
}
}
Some(result)
}
pub fn only_if_cached(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(',')
.any(|v| v.trim().eq_ignore_ascii_case("only-if-cached"))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestPolicy {
Use,
Revalidate(&'static str),
Bypass(&'static str),
}
impl RequestPolicy {
pub fn bypass_reason(self) -> Option<&'static str> {
match self {
Self::Bypass(reason) => Some(reason),
_ => None,
}
}
pub fn revalidation_reason(self) -> Option<&'static str> {
match self {
Self::Revalidate(reason) => Some(reason),
_ => None,
}
}
}
fn pragma_no_cache(headers: &HeaderMap) -> bool {
headers
.get_all("pragma")
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| {
value
.split(',')
.any(|token| token.trim().eq_ignore_ascii_case("no-cache"))
})
}
pub fn request_policy(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> RequestPolicy {
if method != Method::GET && method != Method::HEAD {
return RequestPolicy::Bypass("request_method");
}
if !empty_body {
return RequestPolicy::Bypass("request_body");
}
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
return RequestPolicy::Bypass("request_precondition");
}
if settings.bypass_query && has_query {
return RequestPolicy::Bypass("query_excluded");
}
if settings
.bypass_prefixes
.iter()
.any(|prefix| crate::config::prefix_matches(prefix, path))
{
return RequestPolicy::Bypass("path_excluded");
}
for (key, reason) in [
("authorization", "request_authorization"),
("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"),
] {
if headers.contains_key(key) {
if key == "cookie" && settings.ignore_cookie_for_path(path) {
continue;
}
return RequestPolicy::Bypass(reason);
}
}
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store");
}
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache");
}
if let Some(value) = control.get("max-age") {
let Some(value) = value.as_deref().and_then(|value| value.parse::<u64>().ok()) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
if value == 0 {
return RequestPolicy::Revalidate("request_max_age");
}
}
if let Some(value) = control.get("min-fresh") {
if value
.as_deref()
.and_then(|value| value.parse::<u64>().ok())
.is_none()
{
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
if let Some(Some(value)) = control.get("max-stale") {
if value.parse::<u64>().is_err() {
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
RequestPolicy::Use
}
pub fn request_reason(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> Option<&'static str> {
request_policy(method, headers, empty_body, path, has_query, settings).bypass_reason()
}
pub fn request_cacheable(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> bool {
!matches!(
request_policy(method, headers, empty_body, path, has_query, settings),
RequestPolicy::Bypass(_)
)
}
fn weak_etag(value: &str) -> Option<&str> {
let value = value.trim();
let value = value
.strip_prefix("W/")
.or_else(|| value.strip_prefix("w/"))
.unwrap_or(value)
.trim();
(value.len() >= 2 && value.starts_with('"') && value.ends_with('"')).then_some(value)
}
fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> Option<bool> {
let target = weak_etag(etag)?;
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
if values.is_empty() {
return None;
}
for value in values {
let Ok(value) = value.to_str() else {
return Some(false);
};
let mut start = 0usize;
let mut quoted = false;
for (index, ch) in value.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == ',' && !quoted {
let token = value[start..index].trim();
if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) {
return Some(true);
}
start = index + 1;
}
}
if quoted {
return Some(false);
}
let token = value[start..].trim();
if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) {
return Some(true);
}
}
Some(false)
}
fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
let Some(target) = weak_etag(etag) else {
return false;
};
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
if values.len() != 1 {
return false;
}
let Ok(value) = values[0].to_str() else {
return false;
};
let mut tokens = value
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty());
let Some(token) = tokens.next() else {
return false;
};
tokens.next().is_none()
&& token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
return response
.get("etag")
.and_then(|etag| etag.to_str().ok())
.and_then(|etag| if_none_match_matches(request, etag))
.unwrap_or(false);
}
let Some(since) = request
.get("if-modified-since")
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
else {
return false;
};
response
.get("last-modified")
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
.is_some_and(|modified| modified <= since)
}
pub fn make_not_modified(mut response: Response) -> Response {
*response.status_mut() = StatusCode::NOT_MODIFIED;
*response.body_mut() = Body::empty();
for name in ["content-length", "content-range", "transfer-encoding"] {
response.headers_mut().remove(name);
}
response
}
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") {
merged.remove("age");
}
for name in validation.keys() {
if matches!(
name.as_str(),
"content-length" | "content-range" | "transfer-encoding"
) {
continue;
}
merged.remove(name);
for value in validation.get_all(name).iter() {
merged.append(name.clone(), value.clone());
}
}
if !validation.contains_key("date") {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
merged.insert("date", value);
}
}
merged
}
pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
headers.get_all("vary").iter().any(|value| {
value.to_str().ok().is_some_and(|value| {
value
.split(',')
.any(|name| name.trim().eq_ignore_ascii_case(wanted))
})
})
}
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK {
return "response_status";
}
if headers.contains_key("set-cookie") {
return "response_set_cookie";
}
if headers.contains_key("content-range") {
return "response_range";
}
if headers.contains_key("trailer") {
return "response_trailers";
}
let Some(control) = directives(headers) else {
return "response_cache_control_invalid";
};
for key in ["no-store", "private", "must-understand"] {
if control.contains_key(key) {
return "response_cache_control";
}
}
for value in headers.get_all("vary") {
if value.to_str().ok().is_none_or(|value| {
value.split(',').any(|name| {
let name = name.trim();
name == "*" || name.parse::<http::HeaderName>().is_err()
})
}) {
return "response_vary";
}
}
"response_ttl_size_or_metadata"
}
pub struct Plan {
received: Instant,
initial_age: Duration,
ttl: u64,
stale_while_revalidate: u64,
stale_if_error: u64,
must_revalidate: bool,
proxy_revalidate: bool,
expected_length: Option<usize>,
requires_revalidation: bool,
vary: Vec<String>,
}
impl Plan {
pub fn remaining_ttl(&self) -> u64 {
self.ttl.saturating_sub(self.initial_age.as_secs())
}
pub fn without_vary(mut self, name: &str) -> Self {
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
self
}
pub fn restrict(mut self, transformed: Self) -> Self {
self.ttl = self.ttl.min(transformed.ttl);
self.stale_while_revalidate = self
.stale_while_revalidate
.min(transformed.stale_while_revalidate);
self.stale_if_error = self.stale_if_error.min(transformed.stale_if_error);
self.must_revalidate |= transformed.must_revalidate;
self.proxy_revalidate |= transformed.proxy_revalidate;
self.requires_revalidation |= transformed.requires_revalidation;
self.vary.extend(transformed.vary);
self.vary.sort();
self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received {
let carried = self
.initial_age
.saturating_add(transformed.received.duration_since(self.received));
self.received = transformed.received;
self.initial_age = carried.max(transformed.initial_age);
} else {
let carried = transformed
.initial_age
.saturating_add(self.received.duration_since(transformed.received));
self.initial_age = self.initial_age.max(carried);
}
self
}
}
pub fn plan(
status: StatusCode,
headers: &HeaderMap,
settings: &Settings,
response_delay: Duration,
) -> Option<Plan> {
if ["age", "date", "expires", "content-length"]
.iter()
.any(|name| headers.get_all(*name).iter().count() > 1)
{
return None;
}
if status != StatusCode::OK
|| headers.contains_key("set-cookie")
|| headers.contains_key("content-range")
|| headers.contains_key("trailer")
{
return None;
}
let bytes: usize = headers
.iter()
.map(|(k, v)| k.as_str().len() + v.as_bytes().len() + 32)
.sum();
if bytes > MAX_HEADER_BYTES {
return None;
}
if headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
let v = v.trim_start().to_ascii_lowercase();
v.starts_with("text/event-stream") || v.starts_with("application/grpc")
})
{
return None;
}
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?;
let control = directives(headers)?;
if ["no-store", "private", "must-understand"]
.iter()
.any(|name| control.contains_key(*name))
{
return None;
}
let requires_revalidation = control.contains_key("no-cache");
let must_revalidate = control.contains_key("must-revalidate");
let proxy_revalidate = control.contains_key("proxy-revalidate");
let parse_window = |name: &str| -> Option<u64> {
match control.get(name) {
None => Some(0),
Some(Some(value)) => value.parse::<u64>().ok().map(|v| v.min(604_800)),
Some(None) => None,
}
};
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
let stale_if_error = parse_window("stale-if-error")?;
if requires_revalidation
&& !headers.contains_key("etag")
&& !headers.contains_key("last-modified")
{
return None;
}
let now = SystemTime::now();
let date = match headers.get("date") {
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
None => now,
};
let declared_ttl =
if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
Some(
httpdate::parse_http_date(expires.to_str().ok()?)
.ok()?
.duration_since(date)
.ok()?
.as_secs(),
)
} else if control.contains_key("public") {
Some(settings.default_ttl_seconds)
} else {
None
};
// Keep the freshness lifetime separate from validator/stale retention.
// `no-cache` may be stored with a zero freshness lifetime but must validate
// every reuse; it is retained by the bounded revalidation horizon.
let ttl = match declared_ttl {
Some(value) => value.min(settings.max_ttl_seconds),
None if requires_revalidation => 0,
None => return None,
};
let age = match headers.get("age") {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// Keep sub-second precision internally and floor only when serializing the
// Age header. RFC 9111 adds response delay after correcting received age.
let apparent_age = now.duration_since(date).unwrap_or_default();
// RFC 9111 current_age: corrected_received_age is the greater of Date-based
// apparent age and the received Age value; response delay is then added to
// that result (not only to the Age branch).
let corrected_received_age = apparent_age.max(Duration::from_secs(age));
let initial_age = corrected_received_age.saturating_add(response_delay);
if !requires_revalidation
&& initial_age.as_secs()
>= retention_deadline(
ttl,
stale_while_revalidate,
stale_if_error,
has_validator(headers),
)
{
return None;
}
let expected_length = match headers.get("content-length") {
Some(v) => Some(v.to_str().ok()?.parse::<usize>().ok()?),
None => None,
};
if expected_length.is_some_and(|n| n > settings.max_object_bytes) {
return None;
}
Some(Plan {
received: Instant::now(),
initial_age,
ttl,
stale_while_revalidate,
stale_if_error,
must_revalidate,
proxy_revalidate,
expected_length,
requires_revalidation,
vary,
})
}
+184
View File
@@ -0,0 +1,184 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ByteRange {
start: Option<u64>,
end: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeSelection {
None,
Single(ByteRange),
Unsupported,
}
pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
let mut values = headers.get_all("range").iter();
let Some(value) = values.next() else {
return RangeSelection::None;
};
// Multiple field lines are equivalent to a combined range-set; RustProxy
// intentionally delegates those to the origin with multipart ranges.
if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 {
return RangeSelection::Unsupported;
}
// RustProxy serves one byte range itself. HEAD, unknown units, malformed
// syntax and multipart requests remain transparent origin pass-through.
if method != Method::GET {
return RangeSelection::Unsupported;
}
let Ok(value) = value.to_str() else {
return RangeSelection::Unsupported;
};
let Some(spec) = value.trim().strip_prefix("bytes=") else {
return RangeSelection::Unsupported;
};
if spec.is_empty() || spec.contains(',') {
return RangeSelection::Unsupported;
}
let Some((left, right)) = spec.split_once('-') else {
return RangeSelection::Unsupported;
};
let start = if left.trim().is_empty() {
None
} else {
match left.trim().parse::<u64>() {
Ok(value) => Some(value),
Err(_) => return RangeSelection::Unsupported,
}
};
let end = if right.trim().is_empty() {
None
} else {
match right.trim().parse::<u64>() {
Ok(value) => Some(value),
Err(_) => return RangeSelection::Unsupported,
}
};
if start.is_none() && end.is_none() {
return RangeSelection::Unsupported;
}
if let (Some(start), Some(end)) = (start, end) {
if start > end {
return RangeSelection::Unsupported;
}
}
RangeSelection::Single(ByteRange { start, end })
}
fn if_range_matches(headers: &HeaderMap, if_range: Option<&HeaderValue>) -> bool {
let Some(if_range) = if_range else {
return true;
};
let Ok(candidate) = if_range.to_str() else {
return false;
};
let candidate = candidate.trim();
if candidate.starts_with('"') || candidate.starts_with("W/\"") {
if candidate.starts_with("W/") {
return false;
}
return headers
.get("etag")
.and_then(|value| value.to_str().ok())
.is_some_and(|etag| !etag.trim().starts_with("W/") && etag.trim() == candidate);
}
let Some(candidate_date) = httpdate::parse_http_date(candidate).ok() else {
return false;
};
headers
.get("last-modified")
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
.is_some_and(|modified| modified <= candidate_date)
}
fn resolve_byte_range(range: ByteRange, len: usize) -> Option<(usize, usize)> {
if len == 0 {
return None;
}
let len_u64 = len as u64;
match (range.start, range.end) {
(Some(start), Some(end)) => {
if start >= len_u64 {
return None;
}
let end = end.min(len_u64.saturating_sub(1));
Some((start as usize, end as usize))
}
(Some(start), None) => {
if start >= len_u64 {
None
} else {
Some((start as usize, len - 1))
}
}
(None, Some(suffix)) => {
if suffix == 0 {
return None;
}
let take = suffix.min(len_u64) as usize;
Some((len - take, len - 1))
}
(None, None) => None,
}
}
impl Hit {
pub fn apply_range(mut self, range: ByteRange, if_range: Option<&HeaderValue>) -> Self {
if !if_range_matches(self.response.headers(), if_range) {
return self;
}
let full_len = self.body.len();
self.response
.headers_mut()
.insert("accept-ranges", HeaderValue::from_static("bytes"));
self.response.extensions_mut().insert(crate::compression::Skip);
let Some((start, end)) = resolve_byte_range(range, full_len) else {
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*self.response.body_mut() = Body::empty();
self.response.headers_mut().insert(
"content-range",
HeaderValue::from_str(&format!("bytes */{full_len}")).expect("generated range"),
);
self.response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
return self;
};
let slice = self.body.slice(start..=end);
*self.response.status_mut() = StatusCode::PARTIAL_CONTENT;
*self.response.body_mut() = Body::from(slice.clone());
self.response.headers_mut().insert(
"content-range",
HeaderValue::from_str(&format!("bytes {start}-{end}/{full_len}"))
.expect("generated range"),
);
self.response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&slice.len().to_string()).expect("generated length"),
);
self.body = slice;
self
}
}
impl Revalidation {
pub fn response_with_range(
&self,
headers: HeaderMap,
range: ByteRange,
if_range: Option<&HeaderValue>,
) -> Response {
let hit = Hit {
response: self.response(headers),
body: self.body.clone(),
hits: self.hits,
transformed: self.transformed,
remaining_ttl: self.ttl.saturating_sub(self.age),
stale: false,
background_revalidate: false,
resident_age_seconds: self.resident_age_at_lookup.as_secs(),
};
hit.apply_range(range, if_range).response
}
}
+3153
View File
File diff suppressed because it is too large. Load diff
+366
View File
@@ -0,0 +1,366 @@
impl Cache {
/// Varnish-style primary hash: public URL/host identity first, with response
/// `Vary` handled as a secondary variant key. Irrelevant request headers and
/// load-balanced backend members therefore do not fragment the cache.
pub fn ticket(
&self,
revision: u64,
scheme: &str,
authority: &str,
route: &str,
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped(revision, "", scheme, authority, route, target, headers)
}
pub fn ticket_scoped(
&self,
revision: u64,
scope: &str,
scheme: &str,
authority: &str,
_route: &str,
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
if target.len() > 8192 || scope.len() > 64 {
return None;
}
let domain = crate::config::request_host(authority)
.unwrap_or_else(|_| authority.chars().take(253).collect());
// Varnish built-in hashing lowercases Host before hashing, but otherwise
// keeps the authority (including an explicit port) distinct. RustProxy has
// already validated the authority before this point.
let hash_scheme = scheme.to_ascii_lowercase();
let hash_authority = authority.to_ascii_lowercase();
let mut digest = Sha256::new();
let mut field = |value: &[u8]| {
digest.update((value.len() as u64).to_be_bytes());
digest.update(value);
};
let revision_text = revision.to_string();
for value in [
revision_text.as_bytes(),
scope.as_bytes(),
hash_scheme.as_bytes(),
hash_authority.as_bytes(),
target.as_bytes(),
] {
field(value);
}
let key: Key = digest.finalize().into();
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
(store.revision == revision).then_some(Ticket {
scheme: hash_scheme,
authority: hash_authority,
domain,
path: target
.split('?')
.next()
.unwrap_or("/")
.chars()
.take(512)
.collect(),
target: target.to_owned(),
key,
request_headers: headers.clone(),
generation: store.generation,
revision,
scope: scope.to_owned(),
scope_generation: store.scope_generations.get(scope).copied().unwrap_or(0),
key_generation: store.key_generations.get(&key).copied().unwrap_or(0),
created_at: SystemTime::now(),
})
}
pub fn revalidation(&self, ticket: &Ticket) -> Option<Revalidation> {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let expired = store.sweep();
self.counters
.expired
.fetch_add(expired as u64, Ordering::Relaxed);
if store.revision != ticket.revision
|| store.generation != ticket.generation
|| store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
{
return None;
}
let storage_key = matching_hot_key(&store, ticket)?;
if store
.entries
.get(&storage_key)
.is_some_and(|entry| self.hot_entry_banned(&storage_key, entry))
{
store.remove(&storage_key);
drop(store);
release_allocator_memory();
return None;
}
let entry = store.entries.get(&storage_key)?;
let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs();
Some(Revalidation {
headers: entry.headers.clone(),
body: entry.body.clone(),
body_allocated: entry.body_allocated,
_transient_leases: Vec::new(),
hits: entry.hits,
transformed: entry.transformed,
storage_key,
resident_age_at_lookup: hot_resident_age(entry),
skip_compression: entry.skip_compression,
reusable_without_validation: age < entry.ttl && !entry.requires_revalidation,
age,
ttl: entry.ttl,
stale_if_error: entry.stale_if_error,
must_revalidate: entry.must_revalidate,
proxy_revalidate: entry.proxy_revalidate,
origin_etag: entry.origin_etag.clone(),
origin_last_modified: entry.origin_last_modified.clone(),
})
}
async fn cold_revalidation(&self, ticket: &Ticket) -> Option<Revalidation> {
if !self.ticket_current(ticket) {
return None;
}
let banned = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
matching_cold_key(&cold, ticket).and_then(|storage_key| {
let banned = cold
.entries
.get(&storage_key)
.is_some_and(|entry| self.cold_entry_banned(&storage_key, entry));
banned.then(|| cold.remove(&storage_key)).flatten()
})
};
if let Some(entry) = banned {
self.delete_cold_entry(entry);
return None;
}
let (storage_key, entry) = {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
let storage_key = matching_cold_key(&cold, ticket)?;
let old_sequence = {
let entry = cold.entries.get(&storage_key)?;
if entry.revision != ticket.revision || entry.scope != ticket.scope {
return None;
}
let age = cold_age(entry);
if age
>= retention_deadline(
entry.ttl,
entry.stale_while_revalidate,
entry.stale_if_error,
entry.origin_etag.is_some() || entry.origin_last_modified.is_some(),
)
{
return None;
}
entry.sequence
};
let sequence = cold.next_sequence();
cold.lru.remove(&old_sequence);
cold.lru.insert(sequence, storage_key);
let entry = cold.entries.get_mut(&storage_key)?;
entry.sequence = sequence;
entry.last_access = SystemTime::now();
entry.pinned_reads = entry.pinned_reads.saturating_add(1);
(storage_key, entry.clone())
};
let _pin = ColdReadGuard {
cache: self,
key: storage_key,
generation: entry.generation,
};
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if entry.body_bytes > settings.max_object_bytes {
self.counters
.memory_pressure_rejections
.fetch_add(1, Ordering::Relaxed);
return None;
}
let read_lease = self.try_reserve_transient(entry.body_bytes, &settings)?;
let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64);
let body_vec = match tokio::fs::read(&entry.body_path).await {
Ok(body) if cold_body_matches(&entry, &body) => body,
_ => {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
if let Some(entry) = self.take_cold(&storage_key) {
self.delete_cold_entry(entry);
}
return None;
}
};
let body_allocated = body_vec.capacity().max(body_vec.len());
let extra = body_allocated.saturating_sub(entry.body_bytes);
let extra_lease = if extra > 0 {
Some(self.try_reserve_transient(extra, &settings)?)
} else {
None
};
let _cold_read_extra = extra_lease
.as_ref()
.map(|_| ByteGaugeGuard::new(&self.cold_read_bytes_active, extra as u64));
let body = Bytes::from(body_vec);
if !self.ticket_current(ticket) {
return None;
}
let age = cold_age(&entry);
let resident_age_at_lookup = Duration::from_secs(cold_resident_age(&entry));
Some(Revalidation {
headers: entry.headers,
body,
body_allocated,
_transient_leases: extra_lease
.into_iter()
.chain(std::iter::once(read_lease))
.collect(),
hits: entry.hits,
transformed: entry.transformed,
storage_key,
resident_age_at_lookup,
skip_compression: entry.skip_compression,
reusable_without_validation: age < entry.ttl && !entry.requires_revalidation,
age,
ttl: entry.ttl,
stale_if_error: entry.stale_if_error,
must_revalidate: entry.must_revalidate,
proxy_revalidate: entry.proxy_revalidate,
origin_etag: entry.origin_etag,
origin_last_modified: entry.origin_last_modified,
})
}
pub async fn revalidation_tiered(&self, ticket: &Ticket) -> Option<Revalidation> {
if let Some(cached) = self.revalidation(ticket) {
return Some(cached);
}
self.cold_revalidation(ticket).await
}
pub fn stale_if_error_hit(&self, cached: &Revalidation) -> Option<Hit> {
if !cached.can_stale_if_error() {
return None;
}
let hits = cached.hits.saturating_add(1);
let mut headers = cached.headers.clone();
headers.insert(
"age",
HeaderValue::from_str(&cached.age.to_string()).ok()?,
);
headers.append(
"warning",
HeaderValue::from_static("110 RustProxy \"Response is stale\""),
);
let response = cached.response(headers);
let touched_hot = {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = store.entries.get_mut(&cached.storage_key) {
entry.hits = hits;
entry.last_access = Instant::now();
true
} else {
false
}
};
if !touched_hot {
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut entry) = cold.remove(&cached.storage_key) {
entry.hits = hits;
entry.last_access = SystemTime::now();
entry.sequence = cold.next_sequence();
cold.bytes = cold.bytes.saturating_add(entry.disk_bytes);
cold.lru.insert(entry.sequence, cached.storage_key);
cold.index_variant(entry.base_key, cached.storage_key);
cold.entries.insert(cached.storage_key, entry);
}
}
self.counters.hits.fetch_add(1, Ordering::Relaxed);
Some(Hit {
response,
body: cached.body.clone(),
hits,
transformed: cached.transformed,
remaining_ttl: 0,
resident_age_seconds: cached.resident_age_at_lookup.as_secs(),
stale: true,
background_revalidate: false,
})
}
pub fn begin_background_revalidation(
self: &Arc<Self>,
cached: &Revalidation,
) -> Option<BackgroundRevalidationGuard> {
// Vary variants share a base Ticket key. Deduplicate only the exact cached
// representation so an SWR refresh for one variant cannot suppress another.
let key = cached.storage_key;
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
if !active.insert(key) {
return None;
}
Some(BackgroundRevalidationGuard {
cache: Arc::downgrade(self),
key,
})
}
pub fn ticket_current(&self, ticket: &Ticket) -> bool {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.revision == ticket.revision
&& store.generation == ticket.generation
&& store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
== ticket.scope_generation
&& store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
== ticket.key_generation
}
fn discard_storage_key(&self, key: &Key) {
self.store
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key);
if let Some(entry) = self.take_cold(key) {
self.delete_cold_entry(entry);
}
}
pub fn discard(&self, ticket: &Ticket) {
let hot_key = {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
(store.revision == ticket.revision)
.then(|| matching_hot_key(&store, ticket))
.flatten()
};
if let Some(key) = hot_key {
self.discard_storage_key(&key);
return;
}
let cold_key = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
matching_cold_key(&cold, ticket)
};
if let Some(key) = cold_key {
self.discard_storage_key(&key);
}
}
}