This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+4 -1062
View File
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
impl Cache {
fn enqueue_cold_delete(&self, mut paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
if let Some(tx) = &self.cold_tx {
match tx.try_send(ColdCommand::Delete(paths)) {
Ok(()) => return,
Err(error) => {
paths = match error.into_inner() {
ColdCommand::Delete(paths) => paths,
_ => unreachable!("enqueue_cold_delete only sends delete commands"),
};
}
}
}
// Do not create an unbounded number of detached delete tasks when the
// writer queue is saturated. A tiny fallback pool absorbs short bursts;
// sustained saturation falls back to synchronous removal so stale files
// cannot be resurrected on restart at the cost of bounded backpressure.
if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
tokio::spawn(async move {
let _permit = permit;
for path in paths {
let _ = tokio::fs::remove_file(path).await;
}
});
} else {
for path in paths {
let _ = std::fs::remove_file(path);
}
}
}
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 requested_limit = request
.and_then(|value| value.oldest)
.unwrap_or(max_delete)
.min(max_delete);
let manual = request.is_some();
let now = SystemTime::now();
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
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 {
let priority = if manual {
CleanupPriority::Manual(stored_at, last_access, hits)
} else {
CleanupPriority::Automatic(hits, last_access, stored_at)
};
let candidate = CleanupCandidate {
priority,
sequence,
key,
};
if candidates.len() < requested_limit {
candidates.push(candidate);
} else if candidates
.peek()
.is_some_and(|worst| candidate.cmp(worst).is_lt())
{
candidates.pop();
candidates.push(candidate);
}
}
}
tokio::task::yield_now().await;
}
let mut candidates = candidates.into_vec();
candidates.sort_unstable();
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 candidate 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(&candidate.key)
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
{
continue;
}
if let Some(entry) = cold.remove(&candidate.key) {
result.removed_entries += 1;
result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes);
delete.extend([entry.meta_path, entry.body_path]);
}
}
}
// Keep pending deletion metadata bounded to one selection batch; a
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
self.enqueue_cold_delete(std::mem::take(&mut delete));
tokio::task::yield_now().await;
}
if result.removed_entries > 0 {
self.counters
.cold_evictions
.fetch_add(result.removed_entries as u64, Ordering::Relaxed);
}
result
}
async fn cold_gc_pass(&self) -> ColdCleanupResult {
let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else {
return ColdCleanupResult::default();
};
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 cleanup_slot = self
.cleanup_slots
.clone()
.try_acquire_owned()
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
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 _cleanup_slot = cleanup_slot;
{
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]));
}
}
}
+247
View File
@@ -0,0 +1,247 @@
impl Cache {
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 {
_inner: 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;
}
}
}
+211
View File
@@ -0,0 +1,211 @@
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);
}
}
}
+270
View File
@@ -0,0 +1,270 @@
impl Cache {
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;
}
}
}
+28 -10
View File
@@ -36,7 +36,10 @@ impl Drop for CollapseWaiterGuard {
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 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);
@@ -50,12 +53,19 @@ impl Drop for FillLeaderGuard {
}
impl Cache {
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
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());
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 {
@@ -121,11 +131,11 @@ impl Cache {
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<_>>()
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);
@@ -134,7 +144,10 @@ impl Cache {
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 mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let keys = flights
.iter()
.filter(|(_, flight)| flight.scope == scope)
@@ -237,7 +250,12 @@ impl Cache {
}
if self
.proactive_inflight
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.is_ok()
{
self.counters
+5 -21
View File
@@ -102,22 +102,14 @@ impl Cache {
("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_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_allocated_bytes", "gauge", view.hot_allocated_bytes as u64),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
(
"transient_limit_bytes",
@@ -132,11 +124,7 @@ impl Cache {
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
(
"cold_limit_entries",
"gauge",
view.cold_limit_entries as u64,
),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
@@ -213,9 +201,7 @@ impl Cache {
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);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
count
}
/// Purge one vhost without evicting other hosts. Generation bumps also stop
@@ -256,9 +242,7 @@ impl Cache {
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);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
+2 -2
View File
@@ -233,8 +233,7 @@ impl HttpBody for FillBody {
this.candidate = None;
} else {
let after = candidate.data.capacity();
let unbudgeted =
after.saturating_sub(before.saturating_add(required_growth));
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{
@@ -341,3 +340,4 @@ impl Drop for FillBody {
}
}
}
+2 -584
View File
@@ -1,584 +1,2 @@
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
}
}
include!("invalidation/ban.rs");
include!("invalidation/invalidate.rs");
+277
View File
@@ -0,0 +1,277 @@
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,
}
}
}
+306
View File
@@ -0,0 +1,306 @@
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
}
}
+23 -28
View File
@@ -95,9 +95,7 @@ impl Cache {
let weak = Arc::downgrade(&cache);
tokio::spawn(async move {
loop {
let Some(cache) = weak.upgrade() else {
break;
};
let Some(cache) = weak.upgrade() else { break; };
let interval = cache
.tier_settings
.lock()
@@ -159,8 +157,8 @@ impl Cache {
}
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 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));
@@ -168,14 +166,20 @@ impl Cache {
let generation = store.generation;
let mut removed = 0usize;
let mut released_hot = false;
while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
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 {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
let Some(old) = store.remove(&key) else {
@@ -207,7 +211,11 @@ impl Cache {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
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);
@@ -227,6 +235,7 @@ impl Cache {
}
}
/// Bound long-lived HOT RSS without flushing the working set. Only entries
/// idle for the configured age are considered, and only while HOT usage is
/// above the configured target waterline.
@@ -263,29 +272,14 @@ impl Cache {
// LRU order is also last-access order, so newer entries cannot qualify.
break;
}
let Some(entry) = store.remove(&key) else {
continue;
};
let Some(entry) = store.remove(&key) else { continue; };
removed += 1;
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 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 !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key,
entry,
revision,
generation,
scope_generation,
key_generation,
key, entry, revision, generation, scope_generation, key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
@@ -307,7 +301,8 @@ impl Cache {
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 {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
break;
};
let Some(old) = store.remove(&key) else {
+3 -651
View File
@@ -1,651 +1,3 @@
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
}
}
include!("lookup/hot.rs");
include!("lookup/tiered.rs");
include!("lookup/insert.rs");
+296
View File
@@ -0,0 +1,296 @@
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(),
})
}
}
+140
View File
@@ -0,0 +1,140 @@
impl Cache {
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
}
}
+212
View File
@@ -0,0 +1,212 @@
impl Cache {
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,
}),
)
}
}
+5 -1739
View File
File diff suppressed because it is too large. Load diff
+396
View File
@@ -0,0 +1,396 @@
#[derive(Debug, Clone, Serialize)]
pub struct DomainView {
pub vhost: String,
pub domain: String,
pub entries: usize,
pub logical_bytes: usize,
pub resident_bytes: usize,
pub hot_entries: usize,
pub cold_entries: usize,
pub cold_bytes: u64,
pub hit_ratio: f64,
#[serde(flatten)]
pub counters: DomainCounters,
}
#[derive(Debug, Clone, Serialize)]
pub struct ObjectView {
pub vhost: String,
pub scheme: String,
pub authority: String,
pub domain: String,
pub path: String,
pub target: String,
pub cache_key: String,
pub tags: Vec<String>,
pub transformed: bool,
pub body_bytes: usize,
pub resident_bytes: usize,
pub storage_bytes: u64,
pub tier: &'static str,
pub hits: u64,
pub age_seconds: u64,
pub remaining_ttl_seconds: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Dashboard {
pub domains: Vec<DomainView>,
pub objects: Vec<ObjectView>,
pub matching_objects: usize,
pub matching_body_bytes: usize,
pub matching_resident_bytes: usize,
pub matching_cold_bytes: u64,
pub offset: usize,
pub limit: usize,
pub statistics_revision: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ColdCleanupRequest {
/// Remove objects not used for at least this many seconds.
pub unused_for_seconds: Option<u64>,
/// Additionally remove up to N oldest matching objects.
pub oldest: Option<usize>,
/// Limit the selection to objects which were never served.
pub hits_zero: bool,
}
impl Default for ColdCleanupRequest {
fn default() -> Self {
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
}
}
impl ColdCleanupRequest {
fn validate(&self) -> Result<()> {
ensure!(
self.unused_for_seconds.is_some() || self.oldest.is_some() || self.hits_zero,
"at least one cold-cache cleanup criterion is required"
);
if let Some(value) = self.unused_for_seconds {
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
}
if let Some(value) = self.oldest {
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ColdCleanupResult {
pub removed_entries: usize,
pub freed_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ColdCleanupJob {
pub id: u64,
pub status: &'static str,
pub removed_entries: usize,
pub freed_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
struct Entry {
base_key: Key,
vary: Vec<String>,
scope: String,
scheme: String,
authority: String,
domain: String,
path: String,
target: String,
tags: Vec<String>,
created_at: SystemTime,
headers: HeaderMap,
body: Bytes,
/// Backing allocation retained by `body`; can exceed `body.len()`.
body_allocated: usize,
received: Instant,
last_access: Instant,
resident_since: Instant,
resident_age_base: Duration,
initial_age: Duration,
ttl: u64,
stale_while_revalidate: u64,
stale_if_error: u64,
must_revalidate: bool,
proxy_revalidate: bool,
origin_etag: Option<HeaderValue>,
origin_last_modified: Option<HeaderValue>,
cost: usize,
sequence: u64,
hits: u64,
transformed: bool,
skip_compression: bool,
requires_revalidation: bool,
}
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
fn hot_entry_cost(
body_allocated: usize,
headers: &HeaderMap,
vary: &Vec<String>,
scope: &String,
scheme: &String,
authority: &String,
domain: &String,
path: &String,
target: &String,
tags: &Vec<String>,
origin_etag: Option<&HeaderValue>,
origin_last_modified: Option<&HeaderValue>,
) -> usize {
// The public containers do not expose an exact per-entry allocator charge.
// Account their retained index memory conservatively: HOT HashMap key/control,
// the LRU BTreeMap item/node share and the variants index (including spare
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
// allocation instead of pretending Entry/body are the only HOT owners.
let index_heap = std::mem::size_of::<Key>()
.saturating_add(std::mem::size_of::<(u64, Key)>())
.saturating_add(32)
.saturating_add(2usize.saturating_mul(std::mem::size_of::<Key>()))
.saturating_add(32)
.saturating_add(32);
let string_heap = |value: &String| value.capacity();
let string_vec_heap = |values: &Vec<String>| {
values
.capacity()
.saturating_mul(std::mem::size_of::<String>())
.saturating_add(values.iter().map(String::capacity).sum::<usize>())
};
let header_heap = headers
.iter()
.map(|(name, value)| {
// HeaderMap does not expose its bucket capacity. Charge a conservative
// per-field bucket/node allowance in addition to visible name/value bytes.
name.as_str()
.len()
.saturating_add(value.as_bytes().len())
.saturating_add(64)
})
.sum::<usize>();
body_allocated
.saturating_add(std::mem::size_of::<Entry>())
.saturating_add(index_heap)
.saturating_add(header_heap)
.saturating_add(string_vec_heap(vary))
.saturating_add(string_heap(scope))
.saturating_add(string_heap(scheme))
.saturating_add(string_heap(authority))
.saturating_add(string_heap(domain))
.saturating_add(string_heap(path))
.saturating_add(string_heap(target))
.saturating_add(string_vec_heap(tags))
.saturating_add(origin_etag.map_or(0, |value| value.as_bytes().len()))
.saturating_add(origin_last_modified.map_or(0, |value| value.as_bytes().len()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ColdFile {
format: u8,
key: String,
#[serde(default)]
base_key: String,
#[serde(default)]
vary: Vec<String>,
revision: u64,
scope: String,
#[serde(default = "default_cache_scheme")]
scheme: String,
#[serde(default)]
authority: String,
domain: String,
path: String,
#[serde(default)]
target: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
created_at_ms: u64,
headers: Vec<(String, String)>,
body_file: String,
body_bytes: usize,
body_sha256: String,
stored_at_ms: u64,
age_at_store_seconds: u64,
#[serde(default)]
resident_age_at_store_seconds: u64,
ttl: u64,
#[serde(default)]
stale_while_revalidate: u64,
#[serde(default)]
stale_if_error: u64,
#[serde(default)]
must_revalidate: bool,
#[serde(default)]
proxy_revalidate: bool,
#[serde(default)]
origin_etag: Option<String>,
#[serde(default)]
origin_last_modified: Option<String>,
#[serde(default)]
last_access_ms: u64,
hits: u64,
#[serde(default)]
transformed: bool,
skip_compression: bool,
requires_revalidation: bool,
}
#[derive(Clone)]
struct ColdEntry {
key: Key,
base_key: Key,
vary: Vec<String>,
revision: u64,
scope: String,
scheme: String,
authority: String,
domain: String,
path: String,
target: String,
tags: Vec<String>,
created_at: SystemTime,
headers: HeaderMap,
body_path: PathBuf,
meta_path: PathBuf,
body_bytes: usize,
body_sha256: String,
disk_bytes: u64,
stored_at: SystemTime,
last_access: SystemTime,
age_at_store_seconds: u64,
resident_age_at_store_seconds: u64,
ttl: u64,
stale_while_revalidate: u64,
stale_if_error: u64,
must_revalidate: bool,
proxy_revalidate: bool,
origin_etag: Option<HeaderValue>,
origin_last_modified: Option<HeaderValue>,
hits: u64,
transformed: bool,
sequence: u64,
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
// never changes on access, so an old async reader cannot unpin a newer
// replacement stored under the same cache key.
generation: u64,
skip_compression: bool,
requires_revalidation: bool,
pinned_reads: usize,
}
#[derive(Default)]
struct ColdStore {
entries: HashMap<Key, ColdEntry>,
variants: HashMap<Key, Vec<Key>>,
lru: BTreeMap<u64, Key>,
bytes: u64,
sequence: u64,
}
struct ColdDraft {
key: Key,
revision: u64,
generation: u64,
scope_generation: u64,
key_generation: u64,
entry: Entry,
root: PathBuf,
/// Keeps this full in-memory object charged to the bounded transient pool
/// until the COLD write completes and the payload is dropped.
_memory_lease: TransientLease,
}
enum ColdCommand {
Store(ColdDraft),
Delete(Vec<PathBuf>),
PurgeDirectory(PathBuf),
}
#[derive(Default)]
struct Store {
entries: HashMap<Key, Entry>,
variants: HashMap<Key, Vec<Key>>,
lru: BTreeMap<u64, Key>,
bytes: usize,
logical_bytes: usize,
sequence: u64,
generation: u64,
revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
domains: BTreeMap<(String, String), DomainCounters>,
last_sweep: Option<Instant>,
}
impl Store {
fn index_variant(&mut self, base_key: Key, key: Key) {
let keys = self.variants.entry(base_key).or_default();
if !keys.contains(&key) {
keys.push(key);
}
}
fn remove(&mut self, key: &Key) -> Option<Entry> {
let entry = self.entries.remove(key)?;
self.lru.remove(&entry.sequence);
self.bytes = self.bytes.saturating_sub(entry.cost);
self.logical_bytes = self.logical_bytes.saturating_sub(entry.body.len());
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
keys.retain(|candidate| candidate != key);
keys.is_empty()
} else {
false
};
if empty {
self.variants.remove(&entry.base_key);
}
Some(entry)
}
fn domain(&mut self, scope: &str, domain: &str) -> Option<&mut DomainCounters> {
let key = (scope.to_owned(), domain.to_owned());
// Cardinality is bounded independently of resident cache capacity.
if self.domains.len() >= 4096 && !self.domains.contains_key(&key) {
return None;
}
Some(self.domains.entry(key).or_default())
}
fn sweep(&mut self) -> usize {
if self
.last_sweep
.is_some_and(|v| v.elapsed() < Duration::from_secs(1))
{
return 0;
}
self.last_sweep = Some(Instant::now());
let stale: Vec<_> = self
.entries
.iter()
.filter(|(_, e)| {
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
age >= retention_deadline(
e.ttl,
e.stale_while_revalidate,
e.stale_if_error,
e.origin_etag.is_some() || e.origin_last_modified.is_some(),
)
})
.map(|(k, _)| *k)
.collect();
for key in &stale {
self.remove(key);
}
stale.len()
}
fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX {
self.entries.clear();
self.variants.clear();
self.lru.clear();
self.bytes = 0;
self.logical_bytes = 0;
self.sequence = 0;
}
self.sequence += 1;
self.sequence
}
}
+428
View File
@@ -0,0 +1,428 @@
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.max_bytes >= 65_536,
"cache.max_bytes must be at least 64 KiB"
);
ensure!(
self.max_object_bytes >= 1024 && self.max_object_bytes <= self.max_bytes,
"cache.max_object_bytes must be at least 1 KiB and no larger than max_bytes"
);
ensure!(
(1..=16_384).contains(&self.max_entries),
"cache.max_entries must be 1..16384"
);
ensure!(
self.transient_max_bytes >= 65_536,
"cache.transient_max_bytes must be at least 64 KiB"
);
ensure!(
self.cold_queue_max_bytes >= 65_536
&& self.cold_queue_max_bytes <= self.transient_max_bytes,
"cache.cold_queue_max_bytes must be at least 64 KiB and no larger than transient_max_bytes"
);
ensure!(
(1..=4096).contains(&self.hot_eviction_max_objects),
"cache.hot_eviction_max_objects must be 1..4096"
);
ensure!(
(5..=86_400).contains(&self.hot_auto_trim_after_seconds),
"cache.hot_auto_trim_after_seconds must be 5..86400"
);
ensure!(
(10..=95).contains(&self.hot_auto_trim_target_percent),
"cache.hot_auto_trim_target_percent must be 10..95"
);
ensure!(
(65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes),
"cache.cold_max_bytes must be 64 KiB..1 TiB"
);
ensure!(
(1..=1_000_000).contains(&self.cold_max_entries),
"cache.cold_max_entries must be 1..1000000"
);
ensure!(
(5..=86_400).contains(&self.cold_gc_interval_seconds),
"cache.cold_gc_interval_seconds must be 5..86400"
);
ensure!(
(1..=10_000).contains(&self.cold_gc_max_delete),
"cache.cold_gc_max_delete must be 1..10000"
);
ensure!(
(5..=604_800).contains(&self.cold_after_seconds),
"cache.cold_after_seconds must be 5..604800"
);
ensure!(
(5..=2_592_000).contains(&self.cold_max_hot_seconds),
"cache.cold_max_hot_seconds must be 5..2592000"
);
ensure!(
valid_relative_cache_path(&self.cold_path),
"cache.cold_path must be a simple relative path inside the data directory"
);
ensure!(
(1..=604_800).contains(&self.max_ttl_seconds)
&& self.default_ttl_seconds <= self.max_ttl_seconds,
"cache TTL must be at most seven days; default TTL cannot exceed maximum"
);
ensure!(
self.bypass_prefixes.len() <= 64,
"at most 64 cache bypass prefixes"
);
for prefix in &self.bypass_prefixes {
ensure!(
prefix.len() <= 512
&& prefix.starts_with('/')
&& crate::acl::routing_path(prefix)? == *prefix,
"cache bypass prefixes must be normalized absolute paths"
);
}
ensure!(
self.static_extensions.len() <= 64,
"at most 64 static cache extensions"
);
let mut extensions = std::collections::HashSet::new();
for extension in &self.static_extensions {
ensure!(
!extension.is_empty()
&& extension.len() <= 16
&& extension.bytes().all(|b| b.is_ascii_alphanumeric()),
"static cache extensions must be 1..16 ASCII letters or numbers without a dot"
);
ensure!(
extensions.insert(extension.to_ascii_lowercase()),
"duplicate static cache extension"
);
}
ensure!(
(10..=60_000).contains(&self.collapse_wait_timeout_ms),
"cache.collapse_wait_timeout_ms must be 10..60000"
);
ensure!(
self.stale_if_error_statuses.len() <= 32,
"cache.stale_if_error_statuses supports at most 32 statuses"
);
let mut statuses = std::collections::HashSet::new();
for status in &self.stale_if_error_statuses {
ensure!(
(500..=599).contains(status),
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
);
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
}
ensure!(
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
"cache.proactive_refresh_before_expiry_seconds must be 1..604800"
);
ensure!(
self.proactive_refresh_percent <= 100,
"cache.proactive_refresh_percent must be 0..100"
);
ensure!(
(1..=64).contains(&self.proactive_refresh_max_inflight),
"cache.proactive_refresh_max_inflight must be 1..64"
);
Ok(())
}
pub fn active_for(&self, route: Option<bool>) -> bool {
self.enabled && route != Some(false)
}
pub fn allows_stale_error_class(&self, class: StaleErrorClass) -> bool {
match class {
StaleErrorClass::Timeout => self.stale_if_error_timeout,
StaleErrorClass::Connection => self.stale_if_error_connection,
StaleErrorClass::Dns => self.stale_if_error_dns,
StaleErrorClass::Tls => self.stale_if_error_tls,
}
}
pub fn allows_stale_error_status(&self, status: StatusCode) -> bool {
self.stale_if_error_statuses.contains(&status.as_u16())
}
pub fn ignore_cookie_for_path(&self, path: &str) -> bool {
if !self.static_ignore_cookies {
return false;
}
let segment = path.rsplit('/').next().unwrap_or(path);
let Some((_, extension)) = segment.rsplit_once('.') else {
return false;
};
!extension.is_empty()
&& self
.static_extensions
.iter()
.any(|item| item.eq_ignore_ascii_case(extension))
}
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
// Origin values must never masquerade as this proxy's diagnostics.
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
headers.remove(name);
}
// A vhost that explicitly disables cache must look like a normal reverse proxy.
// Keep counters internally, but never expose cache diagnostics for that response.
if !self.enabled {
return;
}
if self.expose_status {
headers.insert("x-cache", HeaderValue::from_static(outcome));
}
if self.expose_hits {
headers.insert(
"x-cache-hits",
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
);
}
if self.expose_cache_status {
let value = match outcome {
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
"MISS" => "RustProxy; fwd=uri-miss".into(),
_ => "RustProxy; fwd=bypass".into(),
};
headers.insert(
"cache-status",
HeaderValue::from_str(&value).expect("generated cache status"),
);
}
}
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
headers.remove("x-cache-resident-age");
if !self.enabled {
return;
}
if self.expose_resident_age {
headers.insert(
"x-cache-resident-age",
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
);
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled || !self.expose_cache_status || !transformed {
return;
}
let base = headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.unwrap_or("RustProxy");
if base
.split(';')
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
{
return;
}
if let Ok(value) = format!("{base}; transformed").parse() {
headers.insert("cache-status", value);
}
}
/// Reasons are fixed internal tokens: never echo request values or secrets.
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
) {
self.annotate(headers, outcome, hits, ttl);
if !self.enabled {
return;
}
if self.expose_status {
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
}
if self.expose_cache_status {
let base = headers
.get("cache-status")
.and_then(|v| v.to_str().ok())
.unwrap_or("RustProxy");
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
headers.insert("cache-status", value);
}
}
}
}
fn valid_relative_cache_path(value: &str) -> bool {
if value.is_empty() || value.len() > 256 {
return false;
}
let path = Path::new(value);
!path.is_absolute()
&& path
.components()
.all(|part| matches!(part, Component::Normal(_)))
}
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Overrides {
pub enabled: Option<bool>,
pub default_ttl_seconds: Option<u64>,
pub max_ttl_seconds: Option<u64>,
pub max_object_bytes: Option<usize>,
pub bypass_query: Option<bool>,
pub bypass_prefixes: Option<Vec<String>>,
pub static_ignore_cookies: Option<bool>,
pub static_extensions: Option<Vec<String>>,
pub expose_status: Option<bool>,
pub expose_hits: Option<bool>,
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
}
impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
result.enabled = global.enabled && self.enabled.unwrap_or(true);
if let Some(value) = self.default_ttl_seconds {
result.default_ttl_seconds = value;
}
if let Some(value) = self.max_ttl_seconds {
result.max_ttl_seconds = value;
}
if let Some(value) = self.max_object_bytes {
ensure!(
value <= global.max_object_bytes,
"vhost object limit cannot exceed the global ceiling"
);
result.max_object_bytes = value;
}
if let Some(value) = self.bypass_query {
result.bypass_query = value;
}
if let Some(value) = &self.bypass_prefixes {
result.bypass_prefixes = value.clone();
}
if let Some(value) = self.static_ignore_cookies {
result.static_ignore_cookies = value;
}
if let Some(value) = &self.static_extensions {
result.static_extensions = value.clone();
}
if let Some(value) = self.expose_status {
result.expose_status = value;
}
if let Some(value) = self.expose_hits {
result.expose_hits = value;
}
if let Some(value) = self.expose_cache_status {
result.expose_cache_status = value;
}
if let Some(value) = self.expose_resident_age {
result.expose_resident_age = value;
}
result.validate()?;
Ok(result)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ScopeView {
pub entries: usize,
pub resident_bytes: usize,
pub cold_entries: usize,
pub cold_bytes: u64,
}
#[derive(Default)]
struct Counters {
hits: AtomicU64,
misses: AtomicU64,
bypasses: AtomicU64,
stored: AtomicU64,
evictions: AtomicU64,
expired: AtomicU64,
rejected_fills: AtomicU64,
memory_pressure_rejections: AtomicU64,
hot_eviction_limited: AtomicU64,
purges: AtomicU64,
cold_hits: AtomicU64,
cold_promotions: AtomicU64,
hot_demotions: AtomicU64,
cold_evictions: AtomicU64,
cold_errors: AtomicU64,
cold_queue_dropped: AtomicU64,
collapsed_fills: AtomicU64,
collapse_timeouts: AtomicU64,
purge_entries: AtomicU64,
bans_created: AtomicU64,
bans_removed: AtomicU64,
ban_matches: AtomicU64,
proactive_scheduled: AtomicU64,
proactive_started: AtomicU64,
proactive_skipped: AtomicU64,
}
#[derive(Debug, Clone, Serialize)]
pub struct View {
pub hits: u64,
pub misses: u64,
pub bypasses: u64,
pub stored: u64,
pub evictions: u64,
pub expired: u64,
pub rejected_fills: u64,
pub memory_pressure_rejections: u64,
pub hot_eviction_limited: u64,
pub purges: u64,
pub entries: usize,
/// Compatibility alias for HOT allocated/charged bytes.
pub resident_bytes: usize,
pub hot_logical_bytes: usize,
pub hot_allocated_bytes: usize,
pub hot_limit_bytes: usize,
pub transient_limit_bytes: usize,
pub cold_queue_limit_bytes: usize,
pub cold_entries: usize,
pub cold_bytes: u64,
pub cold_limit_bytes: u64,
pub cold_limit_entries: usize,
pub cold_hits: u64,
pub cold_promotions: u64,
pub hot_demotions: u64,
pub cold_evictions: u64,
pub cold_errors: u64,
pub cold_queue_dropped: u64,
pub collapsed_fills: u64,
pub collapse_timeouts: u64,
pub collapse_waiters: u64,
pub purge_entries: u64,
pub bans_created: u64,
pub bans_removed: u64,
pub ban_matches: u64,
pub active_bans: usize,
pub proactive_scheduled: u64,
pub proactive_started: u64,
pub proactive_skipped: u64,
pub proactive_inflight: u64,
pub fills_active: usize,
/// Bytes currently allocated by in-progress response cache fills.
pub fill_bytes_active: u64,
/// Demoted objects accepted by the bounded cold-writer queue.
pub cold_queue_items: u64,
pub cold_queue_bytes: u64,
/// Body bytes currently owned by the cold writer after leaving the queue.
pub cold_write_bytes_active: u64,
/// Full cold bodies currently read into RAM for promotion.
pub cold_read_bytes_active: u64,
/// RustProxy-accounted cache RAM outside the published hot store.
pub transient_bytes: u64,
pub hit_ratio: f64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DomainCounters {
pub hits: u64,
pub misses: u64,
pub bypasses: u64,
pub stored: u64,
pub saved_body_bytes: u64,
pub reasons: BTreeMap<String, u64>,
}
+426
View File
@@ -0,0 +1,426 @@
impl ColdStore {
fn index_variant(&mut self, base_key: Key, key: Key) {
let keys = self.variants.entry(base_key).or_default();
if !keys.contains(&key) {
keys.push(key);
}
}
fn next_sequence(&mut self) -> u64 {
if self.sequence == u64::MAX {
// Practically unreachable. Prefer dropping the in-memory index over
// risking ambiguous LRU ordering after wrap.
self.lru.clear();
self.entries.clear();
self.variants.clear();
self.bytes = 0;
self.sequence = 0;
}
self.sequence += 1;
self.sequence
}
fn remove(&mut self, key: &Key) -> Option<ColdEntry> {
let entry = self.entries.remove(key)?;
self.lru.remove(&entry.sequence);
self.bytes = self.bytes.saturating_sub(entry.disk_bytes);
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
keys.retain(|candidate| candidate != key);
keys.is_empty()
} else {
false
};
if empty {
self.variants.remove(&entry.base_key);
}
Some(entry)
}
}
#[derive(Clone)]
struct TransientLease {
_inner: Arc<TransientLeaseInner>,
}
struct TransientLeaseInner {
counter: Arc<AtomicU64>,
bytes: u64,
}
impl Drop for TransientLeaseInner {
fn drop(&mut self) {
self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
}
}
pub struct Cache {
store: Arc<Mutex<Store>>,
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
cold_delete_fallbacks: Arc<Semaphore>,
cleanup_slots: Arc<Semaphore>,
fills: Arc<Semaphore>,
fill_bytes_active: AtomicU64,
transient_bytes_active: Arc<AtomicU64>,
cold_queue_items: AtomicU64,
cold_queue_bytes: AtomicU64,
cold_write_bytes_active: AtomicU64,
cold_read_bytes_active: AtomicU64,
revalidating: Mutex<HashSet<Key>>,
fill_flights: Mutex<HashMap<Key, Arc<FillFlight>>>,
fill_flight_sequence: AtomicU64,
collapse_waiters: AtomicU64,
bans: Mutex<Vec<BanRule>>,
ban_sequence: AtomicU64,
proactive_scheduled_keys: Mutex<HashSet<Key>>,
proactive_inflight: AtomicU64,
cleanup_jobs: Mutex<HashMap<u64, ColdCleanupJob>>,
cleanup_sequence: AtomicU64,
counters: Counters,
}
#[derive(Clone)]
pub struct Ticket {
scheme: String,
authority: String,
domain: String,
path: String,
target: String,
key: Key,
request_headers: HeaderMap,
generation: u64,
revision: u64,
scope: String,
scope_generation: u64,
key_generation: u64,
created_at: SystemTime,
}
pub struct BackgroundRevalidationGuard {
cache: std::sync::Weak<Cache>,
key: Key,
}
impl Drop for BackgroundRevalidationGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
cache
.revalidating
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.key);
}
}
}
struct ColdReadGuard<'a> {
cache: &'a Cache,
key: Key,
generation: u64,
}
impl Drop for ColdReadGuard<'_> {
fn drop(&mut self) {
let mut cold = self.cache.cold.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = cold.entries.get_mut(&self.key) {
if entry.generation == self.generation {
entry.pinned_reads = entry.pinned_reads.saturating_sub(1);
}
}
}
}
pub struct Hit {
pub response: Response,
body: Bytes,
pub hits: u64,
pub transformed: bool,
pub remaining_ttl: u64,
pub resident_age_seconds: u64,
pub stale: bool,
pub background_revalidate: bool,
}
#[derive(Clone)]
pub struct Revalidation {
headers: HeaderMap,
body: Bytes,
body_allocated: usize,
// COLD revalidation bodies remain charged to transient RAM until every
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
_transient_leases: Vec<TransientLease>,
hits: u64,
transformed: bool,
storage_key: Key,
resident_age_at_lookup: Duration,
skip_compression: bool,
reusable_without_validation: bool,
age: u64,
ttl: u64,
stale_if_error: u64,
must_revalidate: bool,
proxy_revalidate: bool,
origin_etag: Option<HeaderValue>,
origin_last_modified: Option<HeaderValue>,
}
impl Revalidation {
pub fn transformed(&self) -> bool {
self.transformed
}
pub fn reusable_without_validation(&self) -> bool {
self.reusable_without_validation
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
// Client validators refer to the representation RustProxy served. If that
// representation was transformed, translate a matching validator back to
// the origin validator rather than leaking the generated proxy ETag upstream.
if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
return false;
};
if !single_if_none_match_matches(headers, public_etag) {
return false;
}
if let Some(origin) = self.origin_etag.clone() {
headers.insert("if-none-match", origin);
headers.remove("if-modified-since");
return true;
}
if let Some(modified) = self.origin_last_modified.clone() {
headers.remove("if-none-match");
headers.insert("if-modified-since", modified);
return true;
}
return false;
}
if headers.contains_key("if-modified-since") {
let Some(cached) = self
.headers
.get("last-modified")
.and_then(|v| v.to_str().ok())
else {
return false;
};
let Some(client) = headers
.get("if-modified-since")
.and_then(|v| v.to_str().ok())
else {
return false;
};
let same = httpdate::parse_http_date(cached)
.ok()
.zip(httpdate::parse_http_date(client).ok())
.is_some_and(|(cached, client)| cached == client);
if !same {
return false;
}
let Some(origin) = self.origin_last_modified.clone() else {
return false;
};
headers.insert("if-modified-since", origin);
return true;
}
if let Some(etag) = self.origin_etag.clone() {
headers.insert("if-none-match", etag);
headers.remove("if-modified-since");
true
} else if let Some(modified) = self.origin_last_modified.clone() {
headers.insert("if-modified-since", modified);
headers.remove("if-none-match");
true
} else {
false
}
}
pub fn reusable_for_request(&self, request_headers: &HeaderMap) -> bool {
self.reusable_without_validation
&& request_freshness_allows(self.age, self.ttl, request_headers)
}
pub fn request_freshness_revalidation_reason(
&self,
request_headers: &HeaderMap,
) -> Option<&'static str> {
let control = directives(request_headers)?;
if let Some(Some(value)) = control.get("max-age") {
if value
.parse::<u64>()
.ok()
.is_some_and(|limit| self.age > limit)
{
return Some("request_max_age");
}
}
if let Some(Some(value)) = control.get("min-fresh") {
if value.parse::<u64>().ok().is_some_and(|required| {
self.ttl.saturating_sub(self.age) < required
}) {
return Some("request_min_fresh");
}
}
None
}
pub fn can_stale_if_error(&self) -> bool {
self.age >= self.ttl
&& self.stale_if_error > 0
&& self.age.saturating_sub(self.ttl) <= self.stale_if_error
&& !stale_forbidden(self.must_revalidate, self.proxy_revalidate)
&& !self.reusable_without_validation
}
pub fn merge_validation_headers(&self, validation: &HeaderMap) -> HeaderMap {
let mut merged = merge_revalidated_headers(&self.headers, validation);
let public_etag = self.headers.get("etag").cloned();
let representation_etag_differs = public_etag.as_ref().is_some_and(|public| {
self.origin_etag
.as_ref()
.is_none_or(|origin| public != origin)
});
if representation_etag_differs {
if let Some(public) = public_etag {
merged.insert("etag", public);
}
}
merged
}
pub fn age(&self) -> u64 {
self.age
}
pub fn remaining_ttl(&self) -> u64 {
self.ttl.saturating_sub(self.age)
}
pub fn response(&self, headers: HeaderMap) -> Response {
let mut response = Response::new(Body::from(self.body.clone()));
*response.headers_mut() = headers;
if self.transformed {
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if self.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response
}
}
fn vary_names(headers: &HeaderMap) -> Option<Vec<String>> {
let mut names = Vec::new();
for value in headers.get_all("vary") {
for raw in value.to_str().ok()?.split(',') {
let raw = raw.trim();
if raw.is_empty() || raw == "*" {
return None;
}
let name = raw.parse::<http::HeaderName>().ok()?;
names.push(name.as_str().to_owned());
}
}
names.sort();
names.dedup();
(names.len() <= 64).then_some(names)
}
fn variant_key(base_key: &Key, vary: &[String], headers: &HeaderMap) -> Key {
if vary.is_empty() {
return *base_key;
}
let mut digest = Sha256::new();
digest.update(b"RustProxy-Vary-v1");
digest.update(base_key);
for name in vary {
digest.update((name.len() as u64).to_be_bytes());
digest.update(name.as_bytes());
let values = headers.get_all(name.as_str());
digest.update((values.iter().count() as u64).to_be_bytes());
for value in values.iter() {
digest.update((value.as_bytes().len() as u64).to_be_bytes());
digest.update(value.as_bytes());
}
}
digest.finalize().into()
}
fn hot_resident_age(entry: &Entry) -> Duration {
entry
.resident_age_base
.saturating_add(entry.resident_since.elapsed())
}
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
store
.variants
.get(&ticket.key)?
.iter()
.filter_map(|key| {
let entry = store.entries.get(key)?;
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
.then_some((*key, entry.sequence))
})
.max_by_key(|(_, sequence)| *sequence)
.map(|(key, _)| key)
}
fn matching_cold_key(store: &ColdStore, ticket: &Ticket) -> Option<Key> {
store
.variants
.get(&ticket.key)?
.iter()
.filter_map(|key| {
let entry = store.entries.get(key)?;
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
.then_some((*key, entry.sequence))
})
.max_by_key(|(_, sequence)| *sequence)
.map(|(key, _)| key)
}
fn incompatible_hot_variants(store: &Store, base_key: &Key, vary: &[String]) -> Vec<Key> {
store
.variants
.get(base_key)
.into_iter()
.flatten()
.filter_map(|key| {
store
.entries
.get(key)
.filter(|entry| entry.vary.as_slice() != vary)
.map(|_| *key)
})
.collect()
}
fn incompatible_cold_variants(store: &ColdStore, base_key: &Key, vary: &[String]) -> Vec<Key> {
store
.variants
.get(base_key)
.into_iter()
.flatten()
.filter_map(|key| {
store
.entries
.get(key)
.filter(|entry| entry.vary.as_slice() != vary)
.map(|_| *key)
})
.collect()
}
fn body_sha256(body: &[u8]) -> String {
let digest = Sha256::digest(body);
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
fn cold_body_matches(entry: &ColdEntry, body: &[u8]) -> bool {
body.len() == entry.body_bytes && body_sha256(body).as_str() == entry.body_sha256.as_str()
}
fn key_hex(key: &Key) -> String {
let mut out = String::with_capacity(64);
for byte in key {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
+245
View File
@@ -0,0 +1,245 @@
use anyhow::{ensure, Result};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Method, StatusCode};
use http_body::{Body as HttpBody, Frame, SizeHint};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
path::{Component, Path, PathBuf},
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
task::{Context, Poll},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
use tokio_util::sync::CancellationToken;
const MAX_HEADER_BYTES: usize = 32 * 1024;
const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
// Cold writes can contain complete response bodies. Keep the queue deliberately
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
const COLD_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32];
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
enum CleanupPriority {
Automatic(u64, SystemTime, SystemTime),
Manual(SystemTime, SystemTime, u64),
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct CleanupCandidate {
priority: CleanupPriority,
sequence: u64,
key: Key,
}
/// `Bytes` releases its allocation when the last reference is dropped, but
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
/// return releasable pages to the OS instead of letting RSS stay artificially
/// high. This is intentionally a no-op on other allocators/platforms.
fn release_allocator_memory() {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
libc::malloc_trim(0);
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CollapseTimeoutFallback {
Origin,
StaleIfAvailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleErrorClass {
Timeout,
Connection,
Dns,
Tls,
}
impl Default for CollapseTimeoutFallback {
fn default() -> Self {
Self::Origin
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryPreset {
Conservative,
Recommended,
Performance,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ColdPreset {
Recommended,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// UI sizing profile last explicitly selected by the administrator. The
/// actual numeric limits remain authoritative; this only preserves intent
/// across reloads so the dashboard can show which preset was applied.
pub memory_preset: Option<MemoryPreset>,
/// Hard RAM budget for published HOT entries, charged by backing allocation
/// plus directly owned metadata rather than only logical body length.
pub max_bytes: usize,
/// Maximum logical size of a single cacheable object body.
pub max_object_bytes: usize,
/// Hot tier object count.
pub max_entries: usize,
/// Bounded Varnish-style transient RAM used by in-progress fills and full
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
pub transient_max_bytes: usize,
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
pub cold_queue_max_bytes: usize,
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
/// configuration for the same reason as `memory_preset`.
pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String,
pub cold_max_bytes: u64,
pub cold_max_entries: usize,
/// Interval between asynchronous cold-cache GC checks.
pub cold_gc_interval_seconds: u64,
/// Maximum number of objects removed by one GC pass.
pub cold_gc_max_delete: usize,
/// Hot objects not touched for this long are demoted to disk.
pub cold_after_seconds: u64,
/// Demote objects which have been idle for `cold_after_seconds`.
pub cold_on_idle: bool,
/// Demote objects after this much continuous residency in the hot tier
/// when `cold_on_age` is enabled, even when they are still being read.
pub cold_max_hot_seconds: u64,
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
pub cold_on_age: bool,
/// Preserve objects evicted from the hot LRU in the cold tier.
pub cold_on_lru: bool,
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
pub cold_on_shutdown: bool,
/// Only used for responses explicitly marked public and without origin TTL.
pub default_ttl_seconds: u64,
pub max_ttl_seconds: u64,
pub bypass_query: bool,
pub bypass_prefixes: Vec<String>,
/// For explicitly selected static extensions, ignore request Cookie when
/// deciding cache eligibility and when building the shared cache key.
pub static_ignore_cookies: bool,
pub static_extensions: Vec<String>,
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
pub collapse_enabled: bool,
/// Maximum time a follower waits for the current fill before applying the configured fallback.
pub collapse_wait_timeout_ms: u64,
pub collapse_timeout_fallback: CollapseTimeoutFallback,
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
pub stale_if_error_timeout: bool,
pub stale_if_error_connection: bool,
pub stale_if_error_dns: bool,
pub stale_if_error_tls: bool,
pub stale_if_error_statuses: Vec<u16>,
/// Schedule bounded validator refresh before normal freshness expires.
pub proactive_refresh_enabled: bool,
pub proactive_refresh_before_expiry_seconds: u64,
pub proactive_refresh_percent: u8,
pub proactive_refresh_max_inflight: usize,
pub expose_status: bool,
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
memory_preset: None,
max_bytes: 256 * 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
max_entries: 4096,
transient_max_bytes: 64 * 1024 * 1024,
cold_queue_max_bytes: 32 * 1024 * 1024,
hot_eviction_max_objects: 64,
hot_auto_trim_enabled: true,
hot_auto_trim_after_seconds: 300,
hot_auto_trim_target_percent: 80,
cold_enabled: false,
cold_preset: None,
cold_path: "cache/cold".into(),
cold_max_bytes: 4 * 1024 * 1024 * 1024,
cold_max_entries: 50_000,
cold_gc_interval_seconds: 60,
cold_gc_max_delete: 256,
cold_after_seconds: 300,
cold_on_idle: true,
cold_max_hot_seconds: 3600,
cold_on_age: false,
cold_on_lru: true,
cold_on_shutdown: true,
default_ttl_seconds: 0,
max_ttl_seconds: 86_400,
bypass_query: false,
bypass_prefixes: Vec::new(),
// Static assets are normally representation-identical for logged-in and
// anonymous visitors. Keep this enabled by default once the shared cache
// itself is enabled; response-side privacy guards still reject Set-Cookie,
// private/no-store and Vary: Cookie before anything is stored.
static_ignore_cookies: true,
static_extensions: vec![
// styles / scripts
"css", "js", "mjs", "cjs", "wasm", // images
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
"woff", "woff2", "ttf", "otf", "eot", // video
"mp4", "webm", "ogv", "mov", "m4v", // audio
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
// common static downloads
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
]
.into_iter()
.map(str::to_owned)
.collect(),
collapse_enabled: true,
collapse_wait_timeout_ms: 1500,
collapse_timeout_fallback: CollapseTimeoutFallback::Origin,
stale_if_error_timeout: true,
stale_if_error_connection: true,
stale_if_error_dns: true,
stale_if_error_tls: true,
stale_if_error_statuses: vec![500, 502, 503, 504],
proactive_refresh_enabled: false,
proactive_refresh_before_expiry_seconds: 15,
proactive_refresh_percent: 10,
proactive_refresh_max_inflight: 4,
expose_status: false,
expose_hits: false,
expose_cache_status: false,
expose_resident_age: false,
}
}
}
+232
View File
@@ -0,0 +1,232 @@
fn parse_key_hex(value: &str) -> Option<Key> {
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut key = [0_u8; 32];
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
let text = std::str::from_utf8(chunk).ok()?;
key[index] = u8::from_str_radix(text, 16).ok()?;
}
Some(key)
}
fn default_cache_scheme() -> String {
"http".to_owned()
}
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
let mut tags = Vec::new();
for value in headers.get_all("cache-tag") {
let Ok(value) = value.to_str() else { continue; };
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
let tag = raw.trim();
if tag.is_empty()
|| tag.len() > 128
|| !tag
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
{
continue;
}
if !tags.iter().any(|existing| existing == tag) {
tags.push(tag.to_owned());
if tags.len() >= 64 {
return tags;
}
}
}
}
tags
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64
}
fn system_time_ms(value: u64) -> SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_millis(value))
.unwrap_or(UNIX_EPOCH)
}
fn cold_age(entry: &ColdEntry) -> u64 {
entry
.age_at_store_seconds
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
}
fn cold_resident_age(entry: &ColdEntry) -> u64 {
entry
.resident_age_at_store_seconds
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
}
fn retention_deadline(
_ttl: u64,
_stale_while_revalidate: u64,
_stale_if_error: u64,
_has_validator: bool,
) -> u64 {
// Freshness and storage retention are deliberately separate. Expiry only
// changes whether an object can be served without validation; it must not
// physically evict the representation because a later request may carry
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
// mechanisms that decide how long an otherwise valid object is retained.
u64::MAX
}
fn has_validator(headers: &HeaderMap) -> bool {
headers.contains_key("etag") || headers.contains_key("last-modified")
}
fn stale_forbidden(must_revalidate: bool, proxy_revalidate: bool) -> bool {
must_revalidate || proxy_revalidate
}
fn client_max_stale(headers: &HeaderMap) -> Option<Option<u64>> {
let control = directives(headers)?;
let value = control.get("max-stale")?;
match value {
None => Some(None),
Some(value) => value.parse::<u64>().ok().map(Some),
}
}
fn request_freshness_allows(age: u64, ttl: u64, headers: &HeaderMap) -> bool {
let Some(control) = directives(headers) else {
return false;
};
if let Some(Some(value)) = control.get("max-age") {
let Ok(limit) = value.parse::<u64>() else {
return false;
};
if age > limit {
return false;
}
}
if let Some(Some(value)) = control.get("min-fresh") {
let Ok(required) = value.parse::<u64>() else {
return false;
};
if ttl.saturating_sub(age) < required {
return false;
}
}
true
}
fn stale_mode(
age: u64,
ttl: u64,
stale_while_revalidate: u64,
must_revalidate: bool,
proxy_revalidate: bool,
requires_revalidation: bool,
request_headers: &HeaderMap,
) -> Option<bool> {
if age < ttl || requires_revalidation || stale_forbidden(must_revalidate, proxy_revalidate) {
return None;
}
if !request_freshness_allows(age, ttl, request_headers) {
return None;
}
let stale_for = age.saturating_sub(ttl);
if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate {
return Some(true);
}
match client_max_stale(request_headers) {
Some(None) => Some(false),
Some(Some(limit)) if stale_for <= limit => Some(false),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CacheState {
Fresh,
StaleButServable { background_revalidate: bool },
Expired,
}
impl CacheState {
fn servable(self) -> bool {
!matches!(self, Self::Expired)
}
fn stale(self) -> bool {
matches!(self, Self::StaleButServable { .. })
}
fn background_revalidate(self) -> bool {
matches!(
self,
Self::StaleButServable {
background_revalidate: true
}
)
}
}
fn cache_state(
age: u64,
ttl: u64,
stale_while_revalidate: u64,
must_revalidate: bool,
proxy_revalidate: bool,
requires_revalidation: bool,
request_headers: &HeaderMap,
) -> CacheState {
if age < ttl
&& !requires_revalidation
&& request_freshness_allows(age, ttl, request_headers)
{
return CacheState::Fresh;
}
match stale_mode(
age,
ttl,
stale_while_revalidate,
must_revalidate,
proxy_revalidate,
requires_revalidation,
request_headers,
) {
Some(background_revalidate) => CacheState::StaleButServable {
background_revalidate,
},
None => CacheState::Expired,
}
}
fn cold_paths(root: &Path, key: &Key) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
let key = key_hex(key);
let shard = root.join(&key[..2]);
(
shard.join(format!("{key}.body")),
shard.join(format!("{key}.json")),
shard.join(format!(".{key}.body.tmp")),
shard.join(format!(".{key}.json.tmp")),
)
}
fn cold_header_pairs(headers: &HeaderMap) -> Option<Vec<(String, String)>> {
let mut result = Vec::new();
for (name, value) in headers {
result.push((name.as_str().to_owned(), value.to_str().ok()?.to_owned()));
}
Some(result)
}
fn pairs_to_headers(values: &[(String, String)]) -> Option<HeaderMap> {
let mut headers = HeaderMap::new();
for (name, value) in values {
let name = name.parse::<http::HeaderName>().ok()?;
let value = HeaderValue::from_str(value).ok()?;
headers.append(name, value);
}
Some(headers)
}
+2 -570
View File
@@ -1,570 +1,2 @@
/// 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,
})
}
include!("policy/request.rs");
include!("policy/response.rs");
+388
View File
@@ -0,0 +1,388 @@
/// 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>,
}
+182
View File
@@ -0,0 +1,182 @@
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,
})
}
+6 -3441
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
mod cold_tier_tests {
include!("cold_tier_tests/part_01.rs");
include!("cold_tier_tests/part_02.rs");
}
+412
View File
@@ -0,0 +1,412 @@
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(
cache: &Arc<Cache>,
settings: &Settings,
path: &str,
body: &'static str,
) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed
.headers_mut()
.insert("etag", HeaderValue::from_static("\"rustproxy-representation\""));
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
});
transformed.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
{
let cold = restarted.cold.lock().unwrap();
let transformed = cold
.entries
.values()
.find(|entry| entry.path == "/transformed.json")
.expect("transformed cold entry");
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert_eq!(transformed.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
transformed.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(transformed.headers["etag"], "\"rustproxy-representation\"");
assert!(transformed.transformed);
}
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(protected_hit.response.headers()));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
+293
View File
@@ -0,0 +1,293 @@
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(view.transient_bytes, 0, "persisted ColdEntry must not retain the body allocation");
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(item.path().is_dir(), "cold root must contain shard directories only");
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard).unwrap().filter_map(|entry| entry.ok()).any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache.start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false }).unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache.cold.lock().unwrap().entries.get_mut(&key).unwrap().pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
key,
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
headers: HeaderMap::new(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: "00".repeat(32),
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
origin_etag: None,
origin_last_modified: None,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
+173
View File
@@ -0,0 +1,173 @@
mod dashboard_tests {
use super::*;
use http_body_util::BodyExt;
async fn fill(cache: &Arc<Cache>, scope: &str, host: &str, path: &str) -> Ticket {
let t = cache
.ticket_scoped(
7,
scope,
"http",
host,
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let settings = Settings {
enabled: true,
..Settings::default()
};
let mut response = Response::new(Body::from("public representation"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=30"),
);
response
.headers_mut()
.insert("vary", HeaderValue::from_static("Accept-Language"));
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, t.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
t
}
#[tokio::test]
async fn dashboard_is_scoped_paginated_and_exposes_invalidation_identity_not_bodies() {
let cache = Cache::new(7);
let a = fill(&cache, "alpha", "a.example", "/data?private-query=secret").await;
fill(&cache, "beta", "b.example", "/other").await;
cache.record("alpha", "a.example", "MISS", "cache_miss");
assert!(cache.get(&a).is_some());
cache.record("alpha", "a.example", "HIT", "cache_hit");
cache.record("alpha", "a.example", "BYPASS", "request_cookie");
let d = cache.dashboard(Some("alpha"), 0, 50);
assert_eq!(d.matching_objects, 1);
assert_eq!(d.domains.len(), 1);
assert_eq!(d.domains[0].counters.hits, 1);
assert_eq!(d.domains[0].counters.stored, 1);
assert_eq!(d.domains[0].counters.saved_body_bytes, 21);
assert_eq!(d.objects[0].path, "/data");
assert_eq!(d.objects[0].target, "/data?private-query=secret");
assert_eq!(d.objects[0].cache_key.len(), 64);
assert_eq!(d.objects[0].hits, 1);
let encoded = serde_json::to_string(&d).unwrap();
assert!(encoded.contains("private-query=secret"));
assert!(!encoded.contains("public representation"));
assert_eq!(cache.dashboard(None, 1, 1).objects.len(), 1);
assert_eq!(cache.dashboard(None, 99, 999).limit, 200);
assert_eq!(cache.purge_scope("alpha"), 1);
assert_eq!(cache.dashboard(Some("beta"), 0, 50).matching_objects, 1);
assert_eq!(
cache.dashboard(Some("alpha"), 0, 50).domains[0]
.counters
.hits,
1
);
cache.purge(Some(8));
assert!(cache.dashboard(None, 0, 50).domains.is_empty());
}
#[tokio::test]
async fn dashboard_retains_expired_entries_for_client_max_stale() {
let cache = Cache::new(7);
fill(&cache, "a", "a.example", "/").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(2 * 60 * 60);
}
store.last_sweep = None;
}
assert_eq!(cache.dashboard(None, 0, 50).matching_objects, 1);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().expired, 0);
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
let settings = Settings::default();
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
assert_eq!(
request_reason(&Method::GET, &headers, true, "/", false, &settings),
Some("request_cookie")
);
headers.remove("cookie");
headers.insert("cache-control", HeaderValue::from_static("no-cache"));
assert_eq!(
request_reason(&Method::GET, &headers, true, "/", false, &settings),
None
);
assert_eq!(
request_policy(&Method::GET, &headers, true, "/", false, &settings),
RequestPolicy::Revalidate("request_no_cache")
);
let diagnostics = Settings {
expose_status: true,
expose_cache_status: true,
..settings
};
diagnostics.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert_eq!(headers["x-cache-reason"], "request_cookie");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\""
);
diagnostics.annotate_transformed(&mut headers, true);
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\"; transformed"
);
}
#[test]
fn disabled_cache_strips_diagnostics_without_exposing_bypass_headers() {
let settings = Settings {
enabled: false,
expose_status: true,
expose_hits: true,
expose_cache_status: true,
expose_resident_age: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("origin"));
headers.insert("x-cache-hits", HeaderValue::from_static("99"));
headers.insert("x-cache-status", HeaderValue::from_static("origin"));
headers.insert("cache-status", HeaderValue::from_static("Origin; hit"));
headers.insert("x-cache-reason", HeaderValue::from_static("origin"));
headers.insert("x-cache-resident-age", HeaderValue::from_static("5"));
settings.annotate_reason(&mut headers, "BYPASS", 0, 0, "cache_disabled");
settings.annotate_resident_age(&mut headers, 9);
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name}");
}
}
#[test]
fn per_domain_counter_cardinality_is_bounded() {
let cache = Cache::new(7);
for i in 0..5000 {
cache.record(
"legacy",
&format!("{i}.example"),
"BYPASS",
"cache_disabled",
);
}
assert_eq!(cache.dashboard(None, 0, 1).domains.len(), 4096);
}
}
+99
View File
@@ -0,0 +1,99 @@
mod scope_tests {
use super::*;
use http_body_util::BodyExt;
async fn fill(cache: &Arc<Cache>, ticket: Ticket) {
let settings = Settings {
enabled: true,
..Settings::default()
};
let mut response = Response::new(Body::from("scope-payload"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=30"),
);
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket, plan, &settings)
.into_body()
.collect()
.await
.unwrap();
}
#[tokio::test]
async fn selective_purge_preserves_other_hosts_and_cancels_old_fills() {
let cache = Cache::new(1);
let ticket = |scope| {
cache
.ticket_scoped(
1,
scope,
"http",
"same.test",
"root",
"/",
&HeaderMap::new(),
)
.unwrap()
};
let a = ticket("a");
let b = ticket("b");
fill(&cache, a.clone()).await;
fill(&cache, b.clone()).await;
assert_eq!(cache.scope_view("a").entries, 1);
assert_eq!(cache.purge_scope("a"), 1);
assert!(cache.get(&a).is_none());
assert!(cache.get(&b).is_some());
fill(&cache, a).await;
assert_eq!(cache.scope_view("a").entries, 0);
fill(&cache, ticket("a")).await;
assert_eq!(cache.scope_view("a").entries, 1);
}
#[test]
fn per_vhost_settings_inherit_and_cannot_expand_shared_capacity() {
let global = Settings::default();
let effective = Overrides {
enabled: Some(true),
max_ttl_seconds: Some(30),
expose_status: Some(true),
..Overrides::default()
}
.resolve(&global)
.unwrap();
assert!(effective.enabled && effective.expose_status);
let mut globally_disabled = global.clone();
globally_disabled.enabled = false;
assert!(!Overrides { enabled: Some(true), ..Overrides::default() }
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert!(!Overrides { enabled: Some(false), ..Overrides::default() }
.resolve(&global)
.unwrap()
.enabled);
assert!(!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert_eq!(effective.max_bytes, global.max_bytes);
assert_eq!(effective.max_ttl_seconds, 30);
assert!(Overrides {
max_object_bytes: Some(global.max_object_bytes + 1),
..Overrides::default()
}
.resolve(&global)
.is_err());
assert!(Overrides {
max_ttl_seconds: Some(1),
default_ttl_seconds: Some(2),
..Overrides::default()
}
.resolve(&global)
.is_err());
}
}
+6
View File
@@ -0,0 +1,6 @@
mod tests {
include!("tests/part_01.rs");
include!("tests/part_02.rs");
include!("tests/part_03.rs");
include!("tests/part_04.rs");
}
+404
View File
@@ -0,0 +1,404 @@
use super::*;
use http_body_util::BodyExt;
fn headers(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("cache-control", value.parse().unwrap());
h
}
fn settings() -> Settings {
Settings {
enabled: true,
..Default::default()
}
}
fn ticket(cache: &Cache, headers: &HeaderMap) -> Ticket {
cache
.ticket(1, "http", "example.test", "app", "/", headers)
.unwrap()
}
#[test]
fn ttl_safety_and_shared_directives() {
let s = settings();
for control in [
"private, max-age=60",
"no-store, max-age=60",
"max-age=0",
"max-age=invalid",
"max-age=60,max-age=90",
"public",
"must-understand, max-age=60",
"max-age=\"60",
"max-age=60\"",
"public=invalid,max-age=60",
] {
assert!(
plan(StatusCode::OK, &headers(control), &s, Duration::ZERO).is_none(),
"{control}"
);
}
assert!(plan(
StatusCode::OK,
&headers("public,max-age=60"),
&s,
Duration::ZERO
)
.is_some());
let mut revalidate_headers = headers("no-cache, max-age=60");
revalidate_headers.insert("etag", HeaderValue::from_static("\"validator\""));
let revalidate = plan(StatusCode::OK, &revalidate_headers, &s, Duration::ZERO)
.expect("no-cache with a validator permits storage for validation");
assert!(revalidate.requires_revalidation);
assert!(plan(StatusCode::OK, &headers("no-cache"), &s, Duration::ZERO).is_none());
let mut validator_only = headers("no-cache");
validator_only.insert("etag", HeaderValue::from_static("\"validator\""));
assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some());
assert!(plan(
StatusCode::OK,
&headers("max-age=60,s-maxage=0"),
&s,
Duration::ZERO
)
.is_none());
let shared = plan(
StatusCode::OK,
&headers("public, max-age=600, s-maxage=30"),
&s,
Duration::ZERO,
)
.unwrap();
assert_eq!(shared.ttl, 30);
let mut h = headers("max-age=60");
h.insert("age", "61".parse().unwrap());
assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some());
h.remove("age");
h.insert("set-cookie", "session=secret".parse().unwrap());
assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none());
}
#[test]
fn request_policy_uses_conditional_cache_and_revalidation() {
for name in [
"authorization",
"cookie",
"if-match",
"if-unmodified-since",
"upgrade",
] {
let mut h = HeaderMap::new();
h.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
"value".parse().unwrap(),
);
assert!(
!request_cacheable(&Method::GET, &h, true, "/", false, &settings()),
"{name}"
);
}
for name in ["if-none-match", "if-modified-since"] {
let mut h = HeaderMap::new();
h.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
"value".parse().unwrap(),
);
assert!(
request_cacheable(&Method::GET, &h, true, "/", false, &settings()),
"{name}"
);
}
for (value, reason) in [
("no-cache", "request_no_cache"),
("max-age=0", "request_max_age"),
] {
let h = headers(value);
assert_eq!(
request_policy(&Method::GET, &h, true, "/", false, &settings()),
RequestPolicy::Revalidate(reason)
);
}
let min_fresh = headers("min-fresh=1");
assert_eq!(
request_policy(&Method::GET, &min_fresh, true, "/", false, &settings()),
RequestPolicy::Use
);
let immutable = headers("immutable");
assert_eq!(
request_policy(&Method::GET, &immutable, true, "/", false, &settings()),
RequestPolicy::Use
);
let h = headers("no-store");
assert_eq!(
request_policy(&Method::GET, &h, true, "/", false, &settings()),
RequestPolicy::Bypass("request_no_store")
);
let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
assert_eq!(
request_policy(&Method::GET, &pragma, true, "/", false, &settings()),
RequestPolicy::Revalidate("request_pragma_no_cache")
);
assert!(!request_cacheable(
&Method::POST,
&HeaderMap::new(),
true,
"/",
false,
&settings()
));
assert!(!request_cacheable(
&Method::GET,
&HeaderMap::new(),
false,
"/",
false,
&settings()
));
assert!(request_cacheable(
&Method::GET,
&HeaderMap::new(),
true,
"/",
false,
&settings()
));
assert!(request_cacheable(
&Method::HEAD,
&HeaderMap::new(),
true,
"/",
false,
&settings()
));
let mut single_range = HeaderMap::new();
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
RequestPolicy::Use
);
assert!(request_cacheable(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
));
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
RequestPolicy::Bypass("request_range_unsupported")
);
}
#[test]
fn rfc5861_windows_and_revalidation_flags_are_separate_metadata() {
let s = settings();
let plan = plan(
StatusCode::OK,
&headers("public, max-age=60, stale-while-revalidate=30, stale-if-error=90, must-revalidate, proxy-revalidate"),
&s,
Duration::ZERO,
)
.expect("revalidation flags must not prevent storage");
assert_eq!(plan.ttl, 60);
assert_eq!(plan.stale_while_revalidate, 30);
assert_eq!(plan.stale_if_error, 90);
assert!(plan.must_revalidate);
assert!(plan.proxy_revalidate);
}
#[test]
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
let unbounded = headers("max-stale");
let bounded = headers("max-stale=15");
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false));
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false));
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true));
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None);
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
}
#[test]
fn cache_state_distinguishes_fresh_stale_and_expired() {
let none = HeaderMap::new();
assert_eq!(
cache_state(30, 60, 20, false, false, false, &none),
CacheState::Fresh
);
assert_eq!(
cache_state(70, 60, 20, false, false, false, &none),
CacheState::StaleButServable {
background_revalidate: true
}
);
assert_eq!(
cache_state(90, 60, 20, false, false, false, &none),
CacheState::Expired
);
}
#[test]
fn stale_if_error_is_only_available_inside_its_window() {
let base = Revalidation {
headers: HeaderMap::new(),
body: Bytes::from_static(b"body"),
body_allocated: 4,
_transient_leases: Vec::new(),
hits: 0,
transformed: false,
storage_key: [0; 32],
resident_age_at_lookup: Duration::ZERO,
skip_compression: false,
reusable_without_validation: false,
age: 75,
ttl: 60,
stale_if_error: 30,
must_revalidate: false,
proxy_revalidate: false,
origin_etag: None,
origin_last_modified: None,
};
assert!(base.can_stale_if_error());
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error());
}
#[test]
fn freshness_expiry_does_not_force_physical_eviction() {
assert_eq!(retention_deadline(60, 0, 0, false), u64::MAX);
assert_eq!(retention_deadline(60, 30, 0, false), u64::MAX);
assert_eq!(retention_deadline(60, 0, 45, false), u64::MAX);
assert_eq!(retention_deadline(60, 0, 0, true), u64::MAX);
}
#[test]
fn request_max_age_only_revalidates_when_cached_age_exceeds_limit() {
let fresh = headers("max-age=30");
assert!(request_freshness_allows(10, 60, &fresh));
assert!(!request_freshness_allows(31, 60, &fresh));
assert_eq!(
request_policy(&Method::GET, &fresh, true, "/", false, &settings()),
RequestPolicy::Use
);
let zero = headers("max-age=0");
assert_eq!(
request_policy(&Method::GET, &zero, true, "/", false, &settings()),
RequestPolicy::Revalidate("request_max_age")
);
let constrained_stale = headers("max-age=30, max-stale");
assert_eq!(
stale_mode(70, 60, 0, false, false, false, &constrained_stale),
None
);
}
#[test]
fn duplicate_and_argument_immutable_do_not_invalidate_cache_control() {
let duplicate = headers("public, max-age=60, immutable, immutable");
assert!(plan(StatusCode::OK, &duplicate, &settings(), Duration::ZERO).is_some());
let argument = headers("public, max-age=60, immutable=ignored");
assert!(plan(StatusCode::OK, &argument, &settings(), Duration::ZERO).is_some());
}
#[test]
fn no_transform_does_not_disable_shared_storage() {
let protected = headers("public, max-age=60, no-transform");
assert!(plan(StatusCode::OK, &protected, &settings(), Duration::ZERO).is_some());
}
#[test]
fn transformed_plan_uses_transformed_content_length() {
let s = settings();
let origin = plan(
StatusCode::OK,
&{
let mut h = headers("public, max-age=60");
h.insert("content-length", HeaderValue::from_static("95"));
h
},
&s,
Duration::ZERO,
)
.unwrap();
let transformed = plan(
StatusCode::OK,
&{
let mut h = headers("public, max-age=60");
h.insert("content-length", HeaderValue::from_static("76"));
h
},
&s,
Duration::ZERO,
)
.unwrap();
assert_eq!(origin.restrict(transformed).expected_length, Some(76));
}
#[tokio::test]
async fn transformed_representation_is_stored_and_reused() {
let cache = Cache::new(1);
let cache_settings = settings();
let request_headers = HeaderMap::new();
let ticket = ticket(&cache, &request_headers);
let origin_body = br#"{ "message": "hello" }"#;
let mut response = Response::new(Body::from(origin_body.as_slice()));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(
StatusCode::OK,
response.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let transform_settings = crate::transform::Settings {
enabled: true,
json: true,
..Default::default()
};
let transformed = crate::transform::apply(
response,
"/data.json",
&transform_settings,
false,
)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let expected_etag = transformed.headers()["etag"].clone();
let wrapped = cache.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&cache_settings,
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&first[..], br#"{"message":"hello"}"#);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag);
assert_eq!(
hit.response.headers()["content-length"].to_str().unwrap(),
first.len().to_string()
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(second, first);
}
+411
View File
@@ -0,0 +1,411 @@
#[tokio::test]
async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() {
let cases: [(&str, &str, &[u8], &str); 5] = [
(
"/app.js",
"application/javascript",
&b"const value = 1;\n"[..],
"javascript",
),
(
"/app.css",
"text/css",
&b"body { color: red; }\n"[..],
"css",
),
(
"/page.html",
"text/html",
&b"<div><!--drop--><b>x</b></div>"[..],
"html",
),
(
"/icon.svg",
"image/svg+xml",
&b"<svg><!--drop--><path d=\"M0 0\"/></svg>"[..],
"svg",
),
(
"/data.json",
"application/json",
&br#"{ "a": 1 }"#[..],
"json",
),
];
for (path, content_type, origin_body, kind) in cases {
let cache = Cache::new(1);
let cache_settings = settings();
let request_headers = HeaderMap::new();
let ticket = cache
.ticket(1, "http", "example.test", "app", path, &request_headers)
.unwrap();
let mut response = Response::new(Body::from(origin_body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response.headers_mut().insert(
"content-type",
HeaderValue::from_str(content_type).unwrap(),
);
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(
StatusCode::OK,
response.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let mut transform_settings = crate::transform::Settings {
enabled: true,
javascript: false,
css: false,
html: false,
svg: false,
json: false,
..Default::default()
};
match kind {
"javascript" => transform_settings.javascript = true,
"css" => transform_settings.css = true,
"html" => transform_settings.html = true,
"svg" => transform_settings.svg = true,
"json" => transform_settings.json = true,
_ => unreachable!(),
}
let transformed = crate::transform::apply(
response,
path,
&transform_settings,
false,
)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let expected_etag = transformed.headers()["etag"].clone();
let expected_length = transformed.headers()["content-length"].clone();
let wrapped = cache.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&cache_settings,
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}");
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture");
let hit = cache.get(&ticket).expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!(
hit.response.headers()["content-length"],
expected_length,
"{kind}"
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(second, first, "{kind}");
}
}
#[test]
fn conditional_hit_uses_weak_etag_and_last_modified() {
let mut response = HeaderMap::new();
response.insert("etag", HeaderValue::from_static("W/\"abc\""));
response.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
);
let mut request = HeaderMap::new();
request.insert("if-none-match", HeaderValue::from_static("\"abc\""));
assert!(conditional_not_modified(&request, &response));
request.insert("if-none-match", HeaderValue::from_static("\"other\""));
assert!(!conditional_not_modified(&request, &response));
request.remove("if-none-match");
request.insert(
"if-modified-since",
HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"),
);
assert!(conditional_not_modified(&request, &response));
}
#[test]
fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() {
let cache = Cache::new(1);
let headers = HeaderMap::new();
let v1 = cache
.ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers)
.unwrap();
let v2 = cache
.ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers)
.unwrap();
let flag = cache
.ticket(1, "http", "example.test", "app", "/file.css?F", &headers)
.unwrap();
assert_ne!(v1.key, v2.key);
assert_ne!(v1.key, flag.key);
assert_ne!(v2.key, flag.key);
let mut cache_settings = settings();
assert_eq!(
request_policy(
&Method::GET,
&headers,
true,
"/file.css",
true,
&cache_settings,
),
RequestPolicy::Use
);
cache_settings.bypass_query = true;
assert_eq!(
request_policy(
&Method::GET,
&headers,
true,
"/file.css",
true,
&cache_settings,
),
RequestPolicy::Bypass("query_excluded")
);
}
#[tokio::test]
async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() {
let transform_settings = crate::transform::Settings {
enabled: true,
json: true,
..Default::default()
};
let body = br#"{ "a": 1 }"#;
let mut response = Response::new(Body::from(body.as_slice()));
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let response = crate::transform::apply(response, "/data.json", &transform_settings, false)
.await
.unwrap();
let representation_etag = response.headers()["etag"].clone();
assert_ne!(representation_etag, HeaderValue::from_static("\"origin\""));
let mut request = HeaderMap::new();
request.insert("if-none-match", representation_etag.clone());
assert!(conditional_not_modified(&request, response.headers()));
request.insert("if-none-match", HeaderValue::from_static("\"origin\""));
assert!(!conditional_not_modified(&request, response.headers()));
let validators = response
.extensions()
.get::<crate::transform::OriginValidators>()
.unwrap();
assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\"");
}
#[test]
fn cache_key_separates_frontend_scheme() {
let cache = Cache::new(1);
let headers = HeaderMap::new();
let http = cache
.ticket(1, "http", "example.test", "app", "/asset", &headers)
.unwrap();
let https = cache
.ticket(1, "https", "example.test", "app", "/asset", &headers)
.unwrap();
assert_ne!(http.key, https.key);
}
#[test]
fn cache_key_ignores_revalidation_headers() {
let cache = Cache::new(1);
let base = ticket(&cache, &HeaderMap::new());
for (name, value) in [
("cache-control", "no-cache"),
("pragma", "no-cache"),
("if-none-match", "\"origin\""),
("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"),
] {
let mut headers = HeaderMap::new();
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
assert_eq!(base.key, ticket(&cache, &headers).key, "{name}");
}
}
#[test]
fn cache_memory_defaults_are_bounded_and_sane() {
let s = Settings::default();
assert_eq!(s.memory_preset, None);
assert_eq!(s.cold_preset, None);
assert_eq!(s.max_bytes, 256 * 1024 * 1024);
assert_eq!(s.max_object_bytes, 16 * 1024 * 1024);
assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024);
assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024);
assert_eq!(s.hot_eviction_max_objects, 64);
assert!(s.validate().is_ok());
assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024);
assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024);
}
#[test]
fn cache_preset_intent_round_trips_with_configuration() {
let mut settings = Settings::default();
settings.memory_preset = Some(MemoryPreset::Recommended);
settings.cold_preset = Some(ColdPreset::Recommended);
let encoded = serde_json::to_value(&settings).unwrap();
assert_eq!(encoded["memory_preset"], "recommended");
assert_eq!(encoded["cold_preset"], "recommended");
let decoded: Settings = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended));
assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended));
}
#[test]
fn transient_budget_is_hard_and_released_with_lease() {
let cache = Cache::new(1);
let mut s = Settings::default();
s.transient_max_bytes = 128 * 1024;
s.cold_queue_max_bytes = 64 * 1024;
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation");
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
assert_eq!(cache.view().transient_bytes, 96 * 1024);
drop(first);
assert_eq!(cache.view().transient_bytes, 0);
assert!(cache.try_reserve_transient(128 * 1024, &s).is_some());
}
#[test]
fn cold_queue_budget_cannot_exceed_transient_budget() {
let mut s = Settings::default();
s.transient_max_bytes = 8 * 1024 * 1024;
s.cold_queue_max_bytes = 16 * 1024 * 1024;
assert!(s.validate().is_err());
}
#[cfg(target_pointer_width = "64")]
#[test]
fn cache_limits_allow_explicit_multi_gibibyte_budgets() {
let mut s = Settings::default();
s.max_bytes = 4usize * 1024 * 1024 * 1024;
s.max_object_bytes = 2usize * 1024 * 1024 * 1024;
s.transient_max_bytes = 512usize * 1024 * 1024;
s.cold_queue_max_bytes = 256usize * 1024 * 1024;
assert!(s.validate().is_ok());
s.max_object_bytes = s.max_bytes.saturating_add(1);
assert!(s.validate().is_err());
}
#[test]
fn revalidation_only_reuses_matching_client_validator() {
let cached = Revalidation {
headers: {
let mut h = HeaderMap::new();
h.insert("etag", HeaderValue::from_static("W/\"origin\""));
h.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
);
h
},
body: Bytes::from_static(b"body"),
body_allocated: 4,
_transient_leases: Vec::new(),
hits: 0,
transformed: false,
storage_key: [0; 32],
resident_age_at_lookup: Duration::ZERO,
skip_compression: false,
reusable_without_validation: false,
age: 61,
ttl: 60,
stale_if_error: 30,
must_revalidate: false,
proxy_revalidate: false,
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
origin_last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
};
let mut matching = HeaderMap::new();
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
assert!(cached.apply_validators(&mut matching));
let mut other = HeaderMap::new();
other.insert("if-none-match", HeaderValue::from_static("\"other\""));
assert!(!cached.apply_validators(&mut other));
assert_eq!(other["if-none-match"], "\"other\"");
let mut empty = HeaderMap::new();
assert!(cached.apply_validators(&mut empty));
assert_eq!(empty["if-none-match"], "W/\"origin\"");
}
#[tokio::test]
async fn transformed_etag_is_never_used_as_an_origin_validator() {
let cache = Cache::new(1);
let ticket = ticket(&cache, &HeaderMap::new());
let settings = settings();
let modified = HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT");
let public_etag = HeaderValue::from_static("\"rustproxy-deadbeef\"");
let mut response_headers = headers("public, max-age=60");
response_headers.insert("etag", public_etag.clone());
response_headers.insert("last-modified", modified.clone());
let plan = plan(
StatusCode::OK,
&response_headers,
&settings,
Duration::ZERO,
)
.unwrap();
let mut response = Response::new(Body::from("transformed"));
*response.headers_mut() = response_headers;
response
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: None,
last_modified: Some(modified.clone()),
});
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
let cached = cache.revalidation(&ticket).expect("stored response");
assert!(cached.origin_etag.is_none());
assert_eq!(cached.origin_last_modified.as_ref(), Some(&modified));
let mut outbound = HeaderMap::new();
assert!(cached.apply_validators(&mut outbound));
assert!(!outbound.contains_key("if-none-match"));
assert_eq!(outbound["if-modified-since"], modified);
let mut client_conditional = HeaderMap::new();
client_conditional.insert("if-none-match", public_etag.clone());
assert!(cached.apply_validators(&mut client_conditional));
assert!(!client_conditional.contains_key("if-none-match"));
assert_eq!(client_conditional["if-modified-since"], modified);
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60"));
validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"));
let merged = cached.merge_validation_headers(&validation);
assert_eq!(merged["etag"], public_etag);
}
+417
View File
@@ -0,0 +1,417 @@
#[tokio::test]
async fn vary_selects_variants_without_fragmenting_the_primary_hash() {
let cache = Cache::new(1);
let s = settings();
let mut en = HeaderMap::new();
en.insert("accept-language", "en".parse().unwrap());
let mut pl = en.clone();
pl.insert("accept-language", "pl".parse().unwrap());
let en_ticket = ticket(&cache, &en);
let pl_ticket = ticket(&cache, &pl);
assert_eq!(en_ticket.key, pl_ticket.key);
let mut h = headers("max-age=60");
h.insert("vary", HeaderValue::from_static("Accept-Language"));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("hello"));
*response.headers_mut() = h.clone();
cache
.wrap(response, en_ticket.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&en_ticket).is_some());
assert!(cache.get(&pl_ticket).is_none());
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("czesc"));
*response.headers_mut() = h;
cache
.wrap(response, pl_ticket.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
let en_hit = cache.get(&en_ticket).expect("English Vary variant");
let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant");
assert_eq!(
en_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"hello")
);
assert_eq!(
pl_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"czesc")
);
let mut star = headers("max-age=60");
star.insert("vary", HeaderValue::from_static("*"));
assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none());
for vary in [
"x-request-id",
"cache-control",
"pragma",
"if-none-match",
"if-modified-since",
] {
let mut h = headers("max-age=60");
h.insert("vary", vary.parse().unwrap());
assert!(
plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(),
"valid Vary field must remain cacheable: {vary}"
);
}
}
#[tokio::test]
async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() {
let cache = Cache::new(1);
let s = settings();
let mut gzip = HeaderMap::new();
gzip.insert("accept-encoding", HeaderValue::from_static("gzip"));
let mut brotli = HeaderMap::new();
brotli.insert("accept-encoding", HeaderValue::from_static("br"));
let gzip_ticket = ticket(&cache, &gzip);
let brotli_ticket = ticket(&cache, &brotli);
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
assert!(cache_plan.vary.is_empty());
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = transformed_headers;
cache
.wrap(response, gzip_ticket.clone(), cache_plan, &s)
.into_body()
.collect()
.await
.unwrap();
let hit = cache
.get(&brotli_ticket)
.expect("canonical identity must be shared across client encodings");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
}
#[tokio::test]
async fn canonical_identity_preserves_non_encoding_vary_dimensions() {
let cache = Cache::new(1);
let s = settings();
let mut light = HeaderMap::new();
light.insert("accept-encoding", HeaderValue::from_static("gzip"));
light.insert("x-theme", HeaderValue::from_static("light"));
let mut dark = HeaderMap::new();
dark.insert("accept-encoding", HeaderValue::from_static("br"));
dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert(
"vary",
HeaderValue::from_static("Accept-Encoding, X-Theme"),
);
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]);
let mut response = Response::new(Body::from("light"));
*response.headers_mut() = transformed_headers;
cache
.wrap(response, ticket(&cache, &light), cache_plan, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&ticket(&cache, &light)).is_some());
assert!(cache.get(&ticket(&cache, &dark)).is_none());
}
#[tokio::test]
async fn vary_schema_change_removes_incompatible_fallbacks() {
let cache = Cache::new(1);
let s = settings();
let mut en = HeaderMap::new();
en.insert("accept-language", HeaderValue::from_static("en"));
let mut pl = HeaderMap::new();
pl.insert("accept-language", HeaderValue::from_static("pl"));
let en_ticket = ticket(&cache, &en);
let pl_ticket = ticket(&cache, &pl);
fill(&cache, en_ticket.clone(), "shared-old", &s).await;
assert!(cache.get(&pl_ticket).is_some());
let mut varied_headers = headers("max-age=60");
varied_headers.insert("vary", HeaderValue::from_static("Accept-Language"));
let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap();
let mut varied = Response::new(Body::from("english-new"));
*varied.headers_mut() = varied_headers;
cache
.wrap(varied, en_ticket.clone(), varied_plan, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&en_ticket).is_some());
assert!(
cache.get(&pl_ticket).is_none(),
"an older no-Vary object must not shadow a newer Vary schema"
);
fill(&cache, pl_ticket.clone(), "shared-new", &s).await;
let hit = cache
.get(&en_ticket)
.expect("new no-Vary schema should replace old variants");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"shared-new")
);
}
async fn fill(cache: &Arc<Cache>, ticket: Ticket, data: &'static str, settings: &Settings) {
let h = headers("max-age=60");
let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap();
let mut r = Response::new(Body::from(data));
*r.headers_mut() = h;
cache
.wrap(r, ticket, p, settings)
.into_body()
.collect()
.await
.unwrap();
}
#[tokio::test]
async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() {
let cache = Cache::new(1);
let s = settings();
let initial = ticket(&cache, &HeaderMap::new());
let response_headers = headers("public, max-age=1");
let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("stale-payload"));
*response.headers_mut() = response_headers;
cache
.wrap(response, initial.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2);
}
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data");
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object");
let stale = ticket(&cache, &headers("max-stale"));
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"stale-payload")
);
}
#[tokio::test]
async fn response_no_cache_is_stored_only_for_validation() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
let s = settings();
let mut h = headers("no-cache");
h.insert("etag", HeaderValue::from_static("\"origin\""));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
assert!(p.requires_revalidation);
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = h;
cache
.wrap(response, t.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&t).is_none(),
"no-cache must not be reused without validation"
);
let candidate = cache.revalidation(&t).expect("stored validator/body");
assert!(!candidate.reusable_without_validation());
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("no-cache"));
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO)
.expect("validated body can be delivered");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
assert!(
cache.get(&t).is_none(),
"stored no-cache response still requires validation next time"
);
}
#[tokio::test]
async fn resident_age_survives_successful_revalidation() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
let s = settings();
let mut h = headers("no-cache");
h.insert("etag", HeaderValue::from_static("\"origin\""));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = h;
cache
.wrap(response, t.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
let entry = store.entries.values_mut().next().expect("stored object");
entry.resident_age_base = Duration::from_secs(30);
entry.resident_since = Instant::now();
}
let candidate = cache.revalidation(&t).expect("revalidation candidate");
assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30));
let mut validation = HeaderMap::new();
validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2))
.expect("validated object");
assert!(hit.resident_age_seconds >= 32);
}
#[tokio::test]
async fn complete_body_hit_age_and_purge() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
assert!(cache.get(&t).is_none());
fill(&cache, t.clone(), "payload", &settings()).await;
let view = cache.view();
assert_eq!(view.hot_logical_bytes, b"payload".len());
assert_eq!(view.hot_allocated_bytes, view.resident_bytes);
assert!(view.hot_allocated_bytes > view.hot_logical_bytes);
assert!(view.hot_allocated_bytes <= view.hot_limit_bytes);
let hit = cache.get(&t).unwrap();
assert_eq!(hit.hits, 1);
assert!(hit.response.headers().contains_key("age"));
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
assert_eq!(cache.purge(None), 1);
assert!(cache.get(&t).is_none());
}
#[test]
fn defaults_enable_static_cookie_boost_for_common_assets() {
let settings = Settings::default();
assert!(settings.enabled);
assert!(settings.static_ignore_cookies);
for path in [
"/asset.jpg",
"/asset.jpeg",
"/asset.png",
"/app.css",
"/app.js",
"/font.woff2",
"/audio.mp3",
"/note.txt",
] {
assert!(settings.ignore_cookie_for_path(path), "{path}");
}
assert!(!settings.ignore_cookie_for_path("/account"));
assert!(!settings.ignore_cookie_for_path("/api/profile.json"));
}
#[test]
fn static_cookie_bypass_is_limited_to_configured_extensions() {
let mut settings = settings();
settings.static_ignore_cookies = true;
let mut h = HeaderMap::new();
h.insert("cookie", HeaderValue::from_static("session=secret"));
assert_eq!(
request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings),
None
);
assert_eq!(
request_reason(&Method::GET, &h, true, "/account", false, &settings),
Some("request_cookie")
);
}
#[test]
fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() {
let settings = settings();
let mut h = headers("max-age=120");
h.insert(
"date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3"));
let age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap();
assert!(age_plan.initial_age >= Duration::from_secs(10));
assert!(age_plan.initial_age < Duration::from_secs(12));
let fresh = plan(
StatusCode::OK,
&headers("max-age=60"),
&settings,
Duration::from_millis(250),
)
.unwrap();
assert_eq!(fresh.initial_age.as_secs(), 0);
}
#[tokio::test]
async fn purged_inflight_fill_cannot_repopulate() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
cache.purge(None);
fill(&cache, t, "old", &settings()).await;
assert_eq!(cache.view().entries, 0);
}
+221
View File
@@ -0,0 +1,221 @@
#[tokio::test]
async fn revision_isolation_and_capacity() {
let cache = Cache::new(1);
let s = Settings {
max_entries: 1,
..settings()
};
let a = ticket(&cache, &HeaderMap::new());
fill(&cache, a.clone(), "a", &s).await;
let h = HeaderMap::new();
let b = cache
.ticket(1, "http", "example.test", "app", "/other", &h)
.unwrap();
fill(&cache, b.clone(), "b", &s).await;
assert!(cache.get(&a).is_none());
assert!(cache.get(&b).is_some());
assert_eq!(cache.view().evictions, 1);
cache.purge(Some(2));
assert!(cache
.ticket(1, "http", "example.test", "app", "/", &h)
.is_none());
}
#[tokio::test]
async fn hot_byte_budget_evicts_before_another_fill_grows() {
let cache = Cache::new(1);
let s = Settings {
max_bytes: 96 * 1024,
max_object_bytes: 64 * 1024,
max_entries: 16,
transient_max_bytes: 128 * 1024,
cold_queue_max_bytes: 64 * 1024,
..settings()
};
let headers = HeaderMap::new();
let first = cache
.ticket(1, "http", "example.test", "app", "/first", &headers)
.unwrap();
let second = cache
.ticket(1, "http", "example.test", "app", "/second", &headers)
.unwrap();
fill(&cache, first.clone(), "first", &s).await;
assert!(cache.get(&first).is_some());
let before = cache.view();
assert!(before.hot_allocated_bytes <= s.max_bytes);
fill(&cache, second.clone(), "second", &s).await;
let after = cache.view();
assert!(after.hot_allocated_bytes <= s.max_bytes);
assert_eq!(after.fill_bytes_active, 0);
assert_eq!(after.transient_bytes, 0);
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation");
assert!(cache.get(&second).is_some());
assert!(after.evictions >= 1);
}
#[tokio::test]
async fn dropped_partial_bodies_and_oversize_are_never_cached() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
let s = settings();
let h = headers("max-age=60");
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let r = Response::new(Body::from("not-consumed"));
drop(cache.wrap(r, t.clone(), p, &s));
assert!(cache.get(&t).is_none());
assert_eq!(cache.view().fills_active, 0);
let small = Settings {
max_object_bytes: 2,
..s
};
let h = headers("max-age=60");
let p = plan(StatusCode::OK, &h, &small, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("oversize"));
*response.headers_mut() = h;
let streamed = cache
.wrap(response, t.clone(), p, &small)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(streamed, Bytes::from_static(b"oversize"));
assert!(cache.get(&t).is_none());
assert_eq!(cache.view().fill_bytes_active, 0);
assert_eq!(cache.view().transient_bytes, 0);
}
#[test]
fn varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers() {
let cache = Cache::new(1);
let mut a = HeaderMap::new();
a.append("user-agent", "browser-a".parse().unwrap());
a.append("sec-ch-ua", "brand-a".parse().unwrap());
let mut b = HeaderMap::new();
b.append("user-agent", "browser-b".parse().unwrap());
b.append("x-forwarded-for", "192.0.2.8".parse().unwrap());
let base = cache
.ticket(
1,
"http",
"Example.Test",
"pool/member-a",
"/asset.js?x=1",
&a,
)
.unwrap();
let other_transport = cache
.ticket(
1,
"https",
"example.test",
"pool/member-b",
"/asset.js?x=1",
&b,
)
.unwrap();
assert_eq!(base.key, other_transport.key);
let other_url = cache
.ticket(
1,
"https",
"example.test",
"pool/member-b",
"/asset.js?x=2",
&b,
)
.unwrap();
assert_ne!(base.key, other_url.key);
let explicit_port = cache
.ticket(
1,
"http",
"example.test:8080",
"pool/member-a",
"/asset.js?x=1",
&a,
)
.unwrap();
assert_ne!(base.key, explicit_port.key);
}
#[tokio::test]
async fn only_if_cached_can_reuse_a_normal_get() {
let cache = Cache::new(1);
let plain = HeaderMap::new();
fill(&cache, ticket(&cache, &plain), "payload", &settings()).await;
let only = headers("only-if-cached");
assert!(request_cacheable(
&Method::GET,
&only,
true,
"/",
false,
&settings()
));
assert!(only_if_cached(&only));
assert!(cache.get(&ticket(&cache, &only)).is_some());
}
#[tokio::test]
async fn dropped_partial_fill_is_not_published_and_a_later_get_can_fill() {
use futures_util::stream;
use http_body_util::StreamBody;
let cache = Cache::new(1);
let s = settings();
let t = ticket(&cache, &HeaderMap::new());
let frames = vec![
Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first-"))),
Ok(Frame::data(Bytes::from_static(b"second"))),
];
let mut response = Response::new(Body::new(StreamBody::new(stream::iter(frames))));
*response.headers_mut() = headers("max-age=60");
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("12"));
let p = plan(StatusCode::OK, response.headers(), &s, Duration::ZERO).unwrap();
let mut body = cache.wrap(response, t.clone(), p, &s).into_body();
let first = body
.frame()
.await
.expect("first frame")
.expect("successful first frame")
.into_data()
.expect("data frame");
assert_eq!(first, Bytes::from_static(b"first-"));
drop(body);
assert!(cache.get(&t).is_none(), "partial body must never become a HIT");
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
fill(&cache, t.clone(), "complete", &s).await;
let hit = cache.get(&t).expect("a later complete GET can fill the cache");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"complete")
);
}
#[tokio::test]
async fn failed_streams_and_trailers_cannot_publish_entries() {
use futures_util::stream;
use http_body_util::StreamBody;
for fail in [true, false] {
let cache = Cache::new(1);
let s = settings();
let t = ticket(&cache, &HeaderMap::new());
let frames = vec![
Ok(Frame::data(Bytes::from_static(b"partial"))),
if fail {
Err(std::io::Error::other("broken origin"))
} else {
Ok(Frame::trailers(HeaderMap::new()))
},
];
let mut r = Response::new(Body::new(StreamBody::new(stream::iter(frames))));
*r.headers_mut() = headers("max-age=60");
let p = plan(StatusCode::OK, r.headers(), &s, Duration::ZERO).unwrap();
let result = cache.wrap(r, t.clone(), p, &s).into_body().collect().await;
assert_eq!(result.is_err(), fail);
assert!(cache.get(&t).is_none());
assert_eq!(cache.view().fills_active, 0);
}
}
+4
View File
@@ -0,0 +1,4 @@
mod v022_cache_tests {
include!("v022_cache_tests/part_01.rs");
include!("v022_cache_tests/part_02.rs");
}
+421
View File
@@ -0,0 +1,421 @@
use super::*;
use http_body_util::BodyExt;
fn active_settings() -> Settings {
Settings {
enabled: true,
..Settings::default()
}
}
async fn store(
cache: &Arc<Cache>,
scope: &str,
host: &str,
target: &str,
request_headers: HeaderMap,
extra_response_headers: &[(&str, &str)],
body: &'static str,
) -> Ticket {
let ticket = cache
.ticket_scoped(
22,
scope,
"http",
host,
"origin@127.0.0.1:8080",
target,
&request_headers,
)
.unwrap();
let settings = active_settings();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, stale-if-error=60"),
);
for (name, value) in extra_response_headers {
response.headers_mut().insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
fn invalidation(
operation: InvalidationOperation,
selector: InvalidationSelector,
value: &str,
host: Option<&str>,
vhost: Option<&str>,
) -> InvalidationRequest {
InvalidationRequest {
operation,
selector,
value: value.to_owned(),
host: host.map(str::to_owned),
vhost: vhost.map(str::to_owned),
ttl_seconds: None,
confirm_wide: false,
}
}
#[tokio::test]
async fn cached_single_range_returns_206_and_preserves_validators() {
let cache = Cache::new(22);
let ticket = store(
&cache,
"range",
"range.example",
"/asset.bin",
HeaderMap::new(),
&[
("etag", "\"asset-v1\""),
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
],
"abcdefghij",
)
.await;
let mut request_headers = HeaderMap::new();
request_headers.insert("range", HeaderValue::from_static("bytes=2-5"));
let range = match request_range(&Method::GET, &request_headers) {
RangeSelection::Single(range) => range,
other => panic!("unexpected range selection: {other:?}"),
};
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10");
assert_eq!(hit.response.headers()["content-length"], "4");
assert_eq!(hit.response.headers()["accept-ranges"], "bytes");
assert_eq!(hit.response.headers()["etag"], "\"asset-v1\"");
assert!(hit.response.headers().contains_key("age"));
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"cdef"));
}
#[tokio::test]
async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() {
let cache = Cache::new(22);
let ticket = store(
&cache,
"range",
"range.example",
"/asset.bin",
HeaderMap::new(),
&[
("etag", "\"asset-v1\""),
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
],
"abcdefghij",
)
.await;
let cases = [
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
];
for (range, expected, content_range) in cases {
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(hit.response.headers()["content-range"], content_range);
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes()));
}
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(50), end: None },
None,
);
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
assert_eq!(
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
0
);
let mismatch = HeaderValue::from_static("\"asset-v2\"");
let full = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
Some(&mismatch),
);
assert_eq!(full.response.status(), StatusCode::OK);
assert!(!full.response.headers().contains_key("content-range"));
assert_eq!(
full.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"abcdefghij")
);
let matched = HeaderValue::from_static("\"asset-v1\"");
let partial = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
Some(&matched),
);
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
}
#[test]
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
let mut multipart = HeaderMap::new();
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
assert_eq!(
request_range(&Method::GET, &multipart),
RangeSelection::Unsupported
);
let mut duplicate = HeaderMap::new();
duplicate.append("range", HeaderValue::from_static("bytes=0-1"));
duplicate.append("range", HeaderValue::from_static("bytes=4-5"));
assert_eq!(
request_range(&Method::GET, &duplicate),
RangeSelection::Unsupported
);
let mut response_headers = HeaderMap::new();
response_headers.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
);
let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT");
assert!(if_range_matches(&response_headers, Some(&newer_if_range)));
}
#[tokio::test]
async fn concurrent_miss_single_flight_waits_until_fill_is_published() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 500;
let ticket = cache
.ticket_scoped(
22,
"collapse",
"http",
"collapse.example",
"origin@127.0.0.1:8080",
"/same",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("first request must become fill leader"),
};
let follower_cache = cache.clone();
let follower_ticket = ticket.clone();
let follower_settings = settings.clone();
let follower = tokio::spawn(async move {
match follower_cache
.claim_fill(&follower_ticket, &follower_settings)
.await
{
FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(),
_ => false,
}
});
for _ in 0..50 {
if cache.view().collapse_waiters == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
assert_eq!(cache.view().collapse_waiters, 1);
assert!(!follower.is_finished());
let mut response = Response::new(Body::from("collapsed-payload"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader))
.into_body()
.collect()
.await
.unwrap();
assert!(follower.await.unwrap());
assert_eq!(cache.view().collapsed_fills, 1);
assert_eq!(cache.view().collapse_waiters, 0);
}
#[tokio::test]
async fn single_flight_timeout_and_leader_drop_release_the_key() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 10;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"),
};
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
assert_eq!(cache.view().collapse_timeouts, 1);
drop(leader);
assert!(matches!(
cache.claim_fill(&ticket, &settings).await,
FillClaim::Leader(_)
));
}
#[tokio::test]
async fn cancelled_single_flight_waiter_releases_waiter_metric() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 5_000;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"),
};
let follower_cache = cache.clone();
let follower_ticket = ticket.clone();
let follower_settings = settings.clone();
let follower = tokio::spawn(async move {
follower_cache
.claim_fill(&follower_ticket, &follower_settings)
.await
});
for _ in 0..50 {
if cache.view().collapse_waiters == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
assert_eq!(cache.view().collapse_waiters, 1);
follower.abort();
let _ = follower.await;
tokio::task::yield_now().await;
assert_eq!(cache.view().collapse_waiters, 0);
drop(leader);
}
#[tokio::test]
async fn single_flight_retries_after_another_vary_representation_is_published() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 500;
let mut en_headers = HeaderMap::new();
en_headers.insert("accept-language", HeaderValue::from_static("en"));
let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
.unwrap();
let pl = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
.unwrap();
assert_eq!(en.key, pl.key, "Vary is learned from the response");
let leader = match cache.claim_fill(&en, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("first representation must become leader"),
};
let follower_cache = cache.clone();
let follower_ticket = pl.clone();
let follower_settings = settings.clone();
let follower = tokio::spawn(async move {
assert!(matches!(
follower_cache
.claim_fill(&follower_ticket, &follower_settings)
.await,
FillClaim::Waited
));
assert!(follower_cache.get(&follower_ticket).is_none());
matches!(
follower_cache
.claim_fill(&follower_ticket, &follower_settings)
.await,
FillClaim::Leader(_)
)
});
for _ in 0..50 {
if cache.view().collapse_waiters == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
let mut response = Response::new(Body::from("hello"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response
.headers_mut()
.insert("vary", HeaderValue::from_static("Accept-Language"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap_with_fill(response, en, plan, &settings, Some(leader))
.into_body()
.collect()
.await
.unwrap();
assert!(follower.await.unwrap());
}
#[test]
fn stale_if_error_policy_is_configurable_by_error_class_and_status() {
let mut settings = Settings::default();
settings.stale_if_error_timeout = false;
settings.stale_if_error_dns = false;
settings.stale_if_error_statuses = vec![500, 503];
assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout));
assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns));
assert!(settings.allows_stale_error_class(StaleErrorClass::Connection));
assert!(settings.allows_stale_error_class(StaleErrorClass::Tls));
assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR));
assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE));
assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY));
}
#[test]
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
let cache = Cache::new(22);
let ticket = cache
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
.unwrap();
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
settings.proactive_refresh_percent = 100;
settings.proactive_refresh_max_inflight = 1;
let scheduled = cache
.begin_proactive_schedule(&ticket, &settings)
.expect("first schedule");
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none());
let running = cache
.try_begin_proactive_refresh(&settings)
.expect("first inflight refresh");
assert!(cache.try_begin_proactive_refresh(&settings).is_none());
assert_eq!(cache.view().proactive_inflight, 1);
drop(running);
drop(scheduled);
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some());
assert_eq!(cache.view().proactive_inflight, 0);
}
+397
View File
@@ -0,0 +1,397 @@
#[test]
fn proactive_refresh_pending_tasks_are_bounded() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
settings.proactive_refresh_percent = 100;
settings.proactive_refresh_max_inflight = 1;
let mut guards = Vec::new();
for index in 0..64 {
let ticket = cache
.ticket(
22,
"http",
"refresh.example",
"origin",
&format!("/asset-{index}"),
&HeaderMap::new(),
)
.unwrap();
guards.push(
cache
.begin_proactive_schedule(&ticket, &settings)
.expect("schedule below pending limit"),
);
}
let overflow = cache
.ticket(
22,
"http",
"refresh.example",
"origin",
"/overflow",
&HeaderMap::new(),
)
.unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
assert!(cache.view().proactive_skipped > 0);
drop(guards);
}
#[test]
fn background_revalidation_set_has_a_hard_limit() {
let cache = Cache::new(22);
let base = Revalidation {
headers: HeaderMap::new(),
body: Bytes::new(),
body_allocated: 0,
_transient_leases: Vec::new(),
hits: 0,
transformed: false,
storage_key: [0; 32],
resident_age_at_lookup: Duration::ZERO,
skip_compression: false,
reusable_without_validation: false,
age: 0,
ttl: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
origin_etag: None,
origin_last_modified: None,
};
let mut guards = Vec::new();
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
let mut cached = base.clone();
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
guards.push(
cache
.begin_background_revalidation(&cached)
.expect("revalidation below hard limit"),
);
}
let mut overflow = base;
overflow.storage_key = [0xff; 32];
assert!(cache.begin_background_revalidation(&overflow).is_none());
drop(guards);
}
#[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
let cache = Cache::new(22);
let one = store(
&cache,
"app",
"purge.example",
"/asset.js?v=1",
HeaderMap::new(),
&[("cache-tag", "release:one")],
"one",
)
.await;
let two = store(
&cache,
"app",
"purge.example",
"/asset.js?v=2",
HeaderMap::new(),
&[("cache-tag", "release:two")],
"two",
)
.await;
let result = cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Exact,
"/asset.js?v=1",
Some("purge.example"),
Some("app"),
))
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(cache.get(&one).is_none());
assert!(cache.get(&two).is_some());
let three = store(
&cache,
"app",
"purge.example",
"/assets/a.css",
HeaderMap::new(),
&[("cache-tag", "release:three")],
"three",
)
.await;
let four = store(
&cache,
"app",
"purge.example",
"/assets/b.css",
HeaderMap::new(),
&[("cache-tag", "release:four")],
"four",
)
.await;
assert_eq!(
cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Prefix,
"/assets/",
Some("purge.example"),
Some("app"),
))
.unwrap()
.affected_entries,
2
);
assert!(cache.get(&three).is_none());
assert!(cache.get(&four).is_none());
let tagged = store(
&cache,
"app",
"purge.example",
"/tagged",
HeaderMap::new(),
&[("cache-tag", "release:tagged")],
"tagged",
)
.await;
assert_eq!(
cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Tag,
"release:tagged",
Some("purge.example"),
Some("app"),
))
.unwrap()
.affected_entries,
1
);
assert!(cache.get(&tagged).is_none());
let keyed = store(
&cache,
"app",
"purge.example",
"/by-key",
HeaderMap::new(),
&[],
"keyed",
)
.await;
let key = key_hex(&keyed.key);
assert_eq!(
cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::CacheKey,
&key,
Some("purge.example"),
Some("app"),
))
.unwrap()
.affected_entries,
1
);
assert!(cache.get(&keyed).is_none());
}
#[tokio::test]
async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() {
let cache = Cache::new(22);
let settings = active_settings();
let old_ticket = cache
.ticket_scoped(
22,
"app",
"http",
"purge.example",
"origin@127.0.0.1:8080",
"/late",
&HeaderMap::new(),
)
.unwrap();
let result = cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Tag,
"release:late",
Some("purge.example"),
Some("app"),
))
.unwrap();
assert_eq!(result.affected_entries, 0);
let mut response = Response::new(Body::from("old-fill"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response
.headers_mut()
.insert("cache-tag", HeaderValue::from_static("release:late"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, old_ticket, plan, &settings)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish");
let new_ticket = store(
&cache,
"app",
"purge.example",
"/late",
HeaderMap::new(),
&[("cache-tag", "release:late")],
"new-fill",
)
.await;
assert!(cache.get(&new_ticket).is_some());
}
#[tokio::test]
async fn purge_of_one_vary_variant_removes_the_whole_variant_family() {
let cache = Cache::new(22);
let mut en_headers = HeaderMap::new();
en_headers.insert("accept-language", HeaderValue::from_static("en"));
let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = store(
&cache,
"app",
"vary.example",
"/localized",
en_headers,
&[("vary", "Accept-Language"), ("cache-tag", "variant:en")],
"hello",
)
.await;
let pl = store(
&cache,
"app",
"vary.example",
"/localized",
pl_headers,
&[("vary", "Accept-Language"), ("cache-tag", "variant:pl")],
"czesc",
)
.await;
let result = cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Tag,
"variant:en",
Some("vary.example"),
Some("app"),
))
.unwrap();
assert_eq!(result.affected_entries, 2);
assert!(cache.get(&en).is_none());
assert!(cache.get(&pl).is_none());
}
#[tokio::test]
async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() {
let cache = Cache::new(22);
let old = store(
&cache,
"app",
"ban.example",
"/page",
HeaderMap::new(),
&[],
"old",
)
.await;
assert_eq!(cache.view().entries, 1);
let result = cache
.invalidate(invalidation(
InvalidationOperation::Ban,
InvalidationSelector::Exact,
"/page",
Some("ban.example"),
Some("app"),
))
.unwrap();
let ban_id = result.ban_id.unwrap();
assert_eq!(result.affected_entries, 0);
assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly");
assert!(cache.get(&old).is_none());
assert_eq!(cache.view().entries, 0);
tokio::time::sleep(Duration::from_millis(2)).await;
let fresh = store(
&cache,
"app",
"ban.example",
"/page",
HeaderMap::new(),
&[],
"fresh",
)
.await;
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
assert_eq!(cache.bans().len(), 1);
assert!(cache.remove_ban(ban_id));
assert!(cache.bans().is_empty());
assert!(!cache.remove_ban(ban_id));
}
#[tokio::test]
async fn regex_and_root_prefix_require_explicit_wide_confirmation() {
let cache = Cache::new(22);
store(
&cache,
"app",
"wide.example",
"/one",
HeaderMap::new(),
&[],
"one",
)
.await;
let regex = invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Regex,
".*",
None,
None,
);
assert!(cache.invalidate(regex.clone()).is_err());
let scoped_regex = invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Regex,
"^/one$",
Some("wide.example"),
Some("app"),
);
assert!(cache.invalidate(scoped_regex).is_err());
let mut confirmed = regex;
confirmed.confirm_wide = true;
assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1);
let prefix = invalidation(
InvalidationOperation::Ban,
InvalidationSelector::Prefix,
"/",
Some("wide.example"),
Some("app"),
);
assert!(cache.invalidate(prefix.clone()).is_err());
let mut confirmed_prefix = prefix;
confirmed_prefix.confirm_wide = true;
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
}