v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
Vendored
+4
-1062
File diff suppressed because it is too large.
Load diff
Vendored
+289
@@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+247
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+211
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+270
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+28
-10
@@ -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
|
||||
|
||||
Vendored
+5
-21
@@ -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 {
|
||||
|
||||
Vendored
+2
-2
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-584
@@ -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");
|
||||
Vendored
+277
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+306
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+23
-28
@@ -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 {
|
||||
|
||||
Vendored
+3
-651
@@ -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");
|
||||
Vendored
+296
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+140
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+212
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
Vendored
+5
-1739
File diff suppressed because it is too large.
Load diff
Vendored
+396
@@ -0,0 +1,396 @@
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DomainView {
|
||||
pub vhost: String,
|
||||
pub domain: String,
|
||||
pub entries: usize,
|
||||
pub logical_bytes: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub hot_entries: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
pub hit_ratio: f64,
|
||||
#[serde(flatten)]
|
||||
pub counters: DomainCounters,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ObjectView {
|
||||
pub vhost: String,
|
||||
pub scheme: String,
|
||||
pub authority: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
pub target: String,
|
||||
pub cache_key: String,
|
||||
pub tags: Vec<String>,
|
||||
pub transformed: bool,
|
||||
pub body_bytes: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub storage_bytes: u64,
|
||||
pub tier: &'static str,
|
||||
pub hits: u64,
|
||||
pub age_seconds: u64,
|
||||
pub remaining_ttl_seconds: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Dashboard {
|
||||
pub domains: Vec<DomainView>,
|
||||
pub objects: Vec<ObjectView>,
|
||||
pub matching_objects: usize,
|
||||
pub matching_body_bytes: usize,
|
||||
pub matching_resident_bytes: usize,
|
||||
pub matching_cold_bytes: u64,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub statistics_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ColdCleanupRequest {
|
||||
/// Remove objects not used for at least this many seconds.
|
||||
pub unused_for_seconds: Option<u64>,
|
||||
/// Additionally remove up to N oldest matching objects.
|
||||
pub oldest: Option<usize>,
|
||||
/// Limit the selection to objects which were never served.
|
||||
pub hits_zero: bool,
|
||||
}
|
||||
impl Default for ColdCleanupRequest {
|
||||
fn default() -> Self {
|
||||
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
|
||||
}
|
||||
}
|
||||
impl ColdCleanupRequest {
|
||||
fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.unused_for_seconds.is_some() || self.oldest.is_some() || self.hits_zero,
|
||||
"at least one cold-cache cleanup criterion is required"
|
||||
);
|
||||
if let Some(value) = self.unused_for_seconds {
|
||||
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
|
||||
}
|
||||
if let Some(value) = self.oldest {
|
||||
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ColdCleanupResult {
|
||||
pub removed_entries: usize,
|
||||
pub freed_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ColdCleanupJob {
|
||||
pub id: u64,
|
||||
pub status: &'static str,
|
||||
pub removed_entries: usize,
|
||||
pub freed_bytes: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
struct Entry {
|
||||
base_key: Key,
|
||||
vary: Vec<String>,
|
||||
scope: String,
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
tags: Vec<String>,
|
||||
created_at: SystemTime,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
/// Backing allocation retained by `body`; can exceed `body.len()`.
|
||||
body_allocated: usize,
|
||||
received: Instant,
|
||||
last_access: Instant,
|
||||
resident_since: Instant,
|
||||
resident_age_base: Duration,
|
||||
initial_age: Duration,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
cost: usize,
|
||||
sequence: u64,
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
|
||||
fn hot_entry_cost(
|
||||
body_allocated: usize,
|
||||
headers: &HeaderMap,
|
||||
vary: &Vec<String>,
|
||||
scope: &String,
|
||||
scheme: &String,
|
||||
authority: &String,
|
||||
domain: &String,
|
||||
path: &String,
|
||||
target: &String,
|
||||
tags: &Vec<String>,
|
||||
origin_etag: Option<&HeaderValue>,
|
||||
origin_last_modified: Option<&HeaderValue>,
|
||||
) -> usize {
|
||||
// The public containers do not expose an exact per-entry allocator charge.
|
||||
// Account their retained index memory conservatively: HOT HashMap key/control,
|
||||
// the LRU BTreeMap item/node share and the variants index (including spare
|
||||
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
|
||||
// allocation instead of pretending Entry/body are the only HOT owners.
|
||||
let index_heap = std::mem::size_of::<Key>()
|
||||
.saturating_add(std::mem::size_of::<(u64, Key)>())
|
||||
.saturating_add(32)
|
||||
.saturating_add(2usize.saturating_mul(std::mem::size_of::<Key>()))
|
||||
.saturating_add(32)
|
||||
.saturating_add(32);
|
||||
let string_heap = |value: &String| value.capacity();
|
||||
let string_vec_heap = |values: &Vec<String>| {
|
||||
values
|
||||
.capacity()
|
||||
.saturating_mul(std::mem::size_of::<String>())
|
||||
.saturating_add(values.iter().map(String::capacity).sum::<usize>())
|
||||
};
|
||||
let header_heap = headers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
// HeaderMap does not expose its bucket capacity. Charge a conservative
|
||||
// per-field bucket/node allowance in addition to visible name/value bytes.
|
||||
name.as_str()
|
||||
.len()
|
||||
.saturating_add(value.as_bytes().len())
|
||||
.saturating_add(64)
|
||||
})
|
||||
.sum::<usize>();
|
||||
body_allocated
|
||||
.saturating_add(std::mem::size_of::<Entry>())
|
||||
.saturating_add(index_heap)
|
||||
.saturating_add(header_heap)
|
||||
.saturating_add(string_vec_heap(vary))
|
||||
.saturating_add(string_heap(scope))
|
||||
.saturating_add(string_heap(scheme))
|
||||
.saturating_add(string_heap(authority))
|
||||
.saturating_add(string_heap(domain))
|
||||
.saturating_add(string_heap(path))
|
||||
.saturating_add(string_heap(target))
|
||||
.saturating_add(string_vec_heap(tags))
|
||||
.saturating_add(origin_etag.map_or(0, |value| value.as_bytes().len()))
|
||||
.saturating_add(origin_last_modified.map_or(0, |value| value.as_bytes().len()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ColdFile {
|
||||
format: u8,
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
base_key: String,
|
||||
#[serde(default)]
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
#[serde(default = "default_cache_scheme")]
|
||||
scheme: String,
|
||||
#[serde(default)]
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
target: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
created_at_ms: u64,
|
||||
headers: Vec<(String, String)>,
|
||||
body_file: String,
|
||||
body_bytes: usize,
|
||||
body_sha256: String,
|
||||
stored_at_ms: u64,
|
||||
age_at_store_seconds: u64,
|
||||
#[serde(default)]
|
||||
resident_age_at_store_seconds: u64,
|
||||
ttl: u64,
|
||||
#[serde(default)]
|
||||
stale_while_revalidate: u64,
|
||||
#[serde(default)]
|
||||
stale_if_error: u64,
|
||||
#[serde(default)]
|
||||
must_revalidate: bool,
|
||||
#[serde(default)]
|
||||
proxy_revalidate: bool,
|
||||
#[serde(default)]
|
||||
origin_etag: Option<String>,
|
||||
#[serde(default)]
|
||||
origin_last_modified: Option<String>,
|
||||
#[serde(default)]
|
||||
last_access_ms: u64,
|
||||
hits: u64,
|
||||
#[serde(default)]
|
||||
transformed: bool,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ColdEntry {
|
||||
key: Key,
|
||||
base_key: Key,
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
tags: Vec<String>,
|
||||
created_at: SystemTime,
|
||||
headers: HeaderMap,
|
||||
body_path: PathBuf,
|
||||
meta_path: PathBuf,
|
||||
body_bytes: usize,
|
||||
body_sha256: String,
|
||||
disk_bytes: u64,
|
||||
stored_at: SystemTime,
|
||||
last_access: SystemTime,
|
||||
age_at_store_seconds: u64,
|
||||
resident_age_at_store_seconds: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
sequence: u64,
|
||||
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
|
||||
// never changes on access, so an old async reader cannot unpin a newer
|
||||
// replacement stored under the same cache key.
|
||||
generation: u64,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
pinned_reads: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ColdStore {
|
||||
entries: HashMap<Key, ColdEntry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: u64,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
struct ColdDraft {
|
||||
key: Key,
|
||||
revision: u64,
|
||||
generation: u64,
|
||||
scope_generation: u64,
|
||||
key_generation: u64,
|
||||
entry: Entry,
|
||||
root: PathBuf,
|
||||
/// Keeps this full in-memory object charged to the bounded transient pool
|
||||
/// until the COLD write completes and the payload is dropped.
|
||||
_memory_lease: TransientLease,
|
||||
}
|
||||
|
||||
enum ColdCommand {
|
||||
Store(ColdDraft),
|
||||
Delete(Vec<PathBuf>),
|
||||
PurgeDirectory(PathBuf),
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Store {
|
||||
entries: HashMap<Key, Entry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: usize,
|
||||
logical_bytes: usize,
|
||||
sequence: u64,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
domains: BTreeMap<(String, String), DomainCounters>,
|
||||
last_sweep: Option<Instant>,
|
||||
}
|
||||
impl Store {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
let keys = self.variants.entry(base_key).or_default();
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, key: &Key) -> Option<Entry> {
|
||||
let entry = self.entries.remove(key)?;
|
||||
self.lru.remove(&entry.sequence);
|
||||
self.bytes = self.bytes.saturating_sub(entry.cost);
|
||||
self.logical_bytes = self.logical_bytes.saturating_sub(entry.body.len());
|
||||
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
|
||||
keys.retain(|candidate| candidate != key);
|
||||
keys.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if empty {
|
||||
self.variants.remove(&entry.base_key);
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
fn domain(&mut self, scope: &str, domain: &str) -> Option<&mut DomainCounters> {
|
||||
let key = (scope.to_owned(), domain.to_owned());
|
||||
// Cardinality is bounded independently of resident cache capacity.
|
||||
if self.domains.len() >= 4096 && !self.domains.contains_key(&key) {
|
||||
return None;
|
||||
}
|
||||
Some(self.domains.entry(key).or_default())
|
||||
}
|
||||
fn sweep(&mut self) -> usize {
|
||||
if self
|
||||
.last_sweep
|
||||
.is_some_and(|v| v.elapsed() < Duration::from_secs(1))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
self.last_sweep = Some(Instant::now());
|
||||
let stale: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
|
||||
age >= retention_deadline(
|
||||
e.ttl,
|
||||
e.stale_while_revalidate,
|
||||
e.stale_if_error,
|
||||
e.origin_etag.is_some() || e.origin_last_modified.is_some(),
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
for key in &stale {
|
||||
self.remove(key);
|
||||
}
|
||||
stale.len()
|
||||
}
|
||||
fn next_sequence(&mut self) -> u64 {
|
||||
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
|
||||
if self.sequence == u64::MAX {
|
||||
self.entries.clear();
|
||||
self.variants.clear();
|
||||
self.lru.clear();
|
||||
self.bytes = 0;
|
||||
self.logical_bytes = 0;
|
||||
self.sequence = 0;
|
||||
}
|
||||
self.sequence += 1;
|
||||
self.sequence
|
||||
}
|
||||
}
|
||||
Vendored
+428
@@ -0,0 +1,428 @@
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.max_bytes >= 65_536,
|
||||
"cache.max_bytes must be at least 64 KiB"
|
||||
);
|
||||
ensure!(
|
||||
self.max_object_bytes >= 1024 && self.max_object_bytes <= self.max_bytes,
|
||||
"cache.max_object_bytes must be at least 1 KiB and no larger than max_bytes"
|
||||
);
|
||||
ensure!(
|
||||
(1..=16_384).contains(&self.max_entries),
|
||||
"cache.max_entries must be 1..16384"
|
||||
);
|
||||
ensure!(
|
||||
self.transient_max_bytes >= 65_536,
|
||||
"cache.transient_max_bytes must be at least 64 KiB"
|
||||
);
|
||||
ensure!(
|
||||
self.cold_queue_max_bytes >= 65_536
|
||||
&& self.cold_queue_max_bytes <= self.transient_max_bytes,
|
||||
"cache.cold_queue_max_bytes must be at least 64 KiB and no larger than transient_max_bytes"
|
||||
);
|
||||
ensure!(
|
||||
(1..=4096).contains(&self.hot_eviction_max_objects),
|
||||
"cache.hot_eviction_max_objects must be 1..4096"
|
||||
);
|
||||
ensure!(
|
||||
(5..=86_400).contains(&self.hot_auto_trim_after_seconds),
|
||||
"cache.hot_auto_trim_after_seconds must be 5..86400"
|
||||
);
|
||||
ensure!(
|
||||
(10..=95).contains(&self.hot_auto_trim_target_percent),
|
||||
"cache.hot_auto_trim_target_percent must be 10..95"
|
||||
);
|
||||
ensure!(
|
||||
(65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes),
|
||||
"cache.cold_max_bytes must be 64 KiB..1 TiB"
|
||||
);
|
||||
ensure!(
|
||||
(1..=1_000_000).contains(&self.cold_max_entries),
|
||||
"cache.cold_max_entries must be 1..1000000"
|
||||
);
|
||||
ensure!(
|
||||
(5..=86_400).contains(&self.cold_gc_interval_seconds),
|
||||
"cache.cold_gc_interval_seconds must be 5..86400"
|
||||
);
|
||||
ensure!(
|
||||
(1..=10_000).contains(&self.cold_gc_max_delete),
|
||||
"cache.cold_gc_max_delete must be 1..10000"
|
||||
);
|
||||
ensure!(
|
||||
(5..=604_800).contains(&self.cold_after_seconds),
|
||||
"cache.cold_after_seconds must be 5..604800"
|
||||
);
|
||||
ensure!(
|
||||
(5..=2_592_000).contains(&self.cold_max_hot_seconds),
|
||||
"cache.cold_max_hot_seconds must be 5..2592000"
|
||||
);
|
||||
ensure!(
|
||||
valid_relative_cache_path(&self.cold_path),
|
||||
"cache.cold_path must be a simple relative path inside the data directory"
|
||||
);
|
||||
ensure!(
|
||||
(1..=604_800).contains(&self.max_ttl_seconds)
|
||||
&& self.default_ttl_seconds <= self.max_ttl_seconds,
|
||||
"cache TTL must be at most seven days; default TTL cannot exceed maximum"
|
||||
);
|
||||
ensure!(
|
||||
self.bypass_prefixes.len() <= 64,
|
||||
"at most 64 cache bypass prefixes"
|
||||
);
|
||||
for prefix in &self.bypass_prefixes {
|
||||
ensure!(
|
||||
prefix.len() <= 512
|
||||
&& prefix.starts_with('/')
|
||||
&& crate::acl::routing_path(prefix)? == *prefix,
|
||||
"cache bypass prefixes must be normalized absolute paths"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.static_extensions.len() <= 64,
|
||||
"at most 64 static cache extensions"
|
||||
);
|
||||
let mut extensions = std::collections::HashSet::new();
|
||||
for extension in &self.static_extensions {
|
||||
ensure!(
|
||||
!extension.is_empty()
|
||||
&& extension.len() <= 16
|
||||
&& extension.bytes().all(|b| b.is_ascii_alphanumeric()),
|
||||
"static cache extensions must be 1..16 ASCII letters or numbers without a dot"
|
||||
);
|
||||
ensure!(
|
||||
extensions.insert(extension.to_ascii_lowercase()),
|
||||
"duplicate static cache extension"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
(10..=60_000).contains(&self.collapse_wait_timeout_ms),
|
||||
"cache.collapse_wait_timeout_ms must be 10..60000"
|
||||
);
|
||||
ensure!(
|
||||
self.stale_if_error_statuses.len() <= 32,
|
||||
"cache.stale_if_error_statuses supports at most 32 statuses"
|
||||
);
|
||||
let mut statuses = std::collections::HashSet::new();
|
||||
for status in &self.stale_if_error_statuses {
|
||||
ensure!(
|
||||
(500..=599).contains(status),
|
||||
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
|
||||
);
|
||||
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
|
||||
}
|
||||
ensure!(
|
||||
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
|
||||
"cache.proactive_refresh_before_expiry_seconds must be 1..604800"
|
||||
);
|
||||
ensure!(
|
||||
self.proactive_refresh_percent <= 100,
|
||||
"cache.proactive_refresh_percent must be 0..100"
|
||||
);
|
||||
ensure!(
|
||||
(1..=64).contains(&self.proactive_refresh_max_inflight),
|
||||
"cache.proactive_refresh_max_inflight must be 1..64"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn active_for(&self, route: Option<bool>) -> bool {
|
||||
self.enabled && route != Some(false)
|
||||
}
|
||||
pub fn allows_stale_error_class(&self, class: StaleErrorClass) -> bool {
|
||||
match class {
|
||||
StaleErrorClass::Timeout => self.stale_if_error_timeout,
|
||||
StaleErrorClass::Connection => self.stale_if_error_connection,
|
||||
StaleErrorClass::Dns => self.stale_if_error_dns,
|
||||
StaleErrorClass::Tls => self.stale_if_error_tls,
|
||||
}
|
||||
}
|
||||
pub fn allows_stale_error_status(&self, status: StatusCode) -> bool {
|
||||
self.stale_if_error_statuses.contains(&status.as_u16())
|
||||
}
|
||||
pub fn ignore_cookie_for_path(&self, path: &str) -> bool {
|
||||
if !self.static_ignore_cookies {
|
||||
return false;
|
||||
}
|
||||
let segment = path.rsplit('/').next().unwrap_or(path);
|
||||
let Some((_, extension)) = segment.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
!extension.is_empty()
|
||||
&& self
|
||||
.static_extensions
|
||||
.iter()
|
||||
.any(|item| item.eq_ignore_ascii_case(extension))
|
||||
}
|
||||
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
|
||||
// Origin values must never masquerade as this proxy's diagnostics.
|
||||
for name in [
|
||||
"x-cache",
|
||||
"x-cache-hits",
|
||||
"x-cache-status",
|
||||
"cache-status",
|
||||
"x-cache-reason",
|
||||
"x-cache-resident-age",
|
||||
] {
|
||||
headers.remove(name);
|
||||
}
|
||||
// A vhost that explicitly disables cache must look like a normal reverse proxy.
|
||||
// Keep counters internally, but never expose cache diagnostics for that response.
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if self.expose_status {
|
||||
headers.insert("x-cache", HeaderValue::from_static(outcome));
|
||||
}
|
||||
if self.expose_hits {
|
||||
headers.insert(
|
||||
"x-cache-hits",
|
||||
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
let value = match outcome {
|
||||
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
||||
"MISS" => "RustProxy; fwd=uri-miss".into(),
|
||||
_ => "RustProxy; fwd=bypass".into(),
|
||||
};
|
||||
headers.insert(
|
||||
"cache-status",
|
||||
HeaderValue::from_str(&value).expect("generated cache status"),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
|
||||
headers.remove("x-cache-resident-age");
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if self.expose_resident_age {
|
||||
headers.insert(
|
||||
"x-cache-resident-age",
|
||||
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
|
||||
if !self.enabled || !self.expose_cache_status || !transformed {
|
||||
return;
|
||||
}
|
||||
let base = headers
|
||||
.get("cache-status")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("RustProxy");
|
||||
if base
|
||||
.split(';')
|
||||
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(value) = format!("{base}; transformed").parse() {
|
||||
headers.insert("cache-status", value);
|
||||
}
|
||||
}
|
||||
/// Reasons are fixed internal tokens: never echo request values or secrets.
|
||||
pub fn annotate_reason(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
outcome: &'static str,
|
||||
hits: u64,
|
||||
ttl: u64,
|
||||
reason: &'static str,
|
||||
) {
|
||||
self.annotate(headers, outcome, hits, ttl);
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if self.expose_status {
|
||||
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
let base = headers
|
||||
.get("cache-status")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("RustProxy");
|
||||
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
|
||||
headers.insert("cache-status", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn valid_relative_cache_path(value: &str) -> bool {
|
||||
if value.is_empty() || value.len() > 256 {
|
||||
return false;
|
||||
}
|
||||
let path = Path::new(value);
|
||||
!path.is_absolute()
|
||||
&& path
|
||||
.components()
|
||||
.all(|part| matches!(part, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Per-vhost behavior, not independent memory pools. None inherits the global
|
||||
/// default; the global resident-byte/entry/object ceilings remain hard limits.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
pub enabled: Option<bool>,
|
||||
pub default_ttl_seconds: Option<u64>,
|
||||
pub max_ttl_seconds: Option<u64>,
|
||||
pub max_object_bytes: Option<usize>,
|
||||
pub bypass_query: Option<bool>,
|
||||
pub bypass_prefixes: Option<Vec<String>>,
|
||||
pub static_ignore_cookies: Option<bool>,
|
||||
pub static_extensions: Option<Vec<String>>,
|
||||
pub expose_status: Option<bool>,
|
||||
pub expose_hits: Option<bool>,
|
||||
pub expose_cache_status: Option<bool>,
|
||||
pub expose_resident_age: Option<bool>,
|
||||
}
|
||||
impl Overrides {
|
||||
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
||||
let mut result = global.clone();
|
||||
// A vhost may narrow the global policy, but can never re-enable a cache
|
||||
// that the administrator disabled globally.
|
||||
result.enabled = global.enabled && self.enabled.unwrap_or(true);
|
||||
if let Some(value) = self.default_ttl_seconds {
|
||||
result.default_ttl_seconds = value;
|
||||
}
|
||||
if let Some(value) = self.max_ttl_seconds {
|
||||
result.max_ttl_seconds = value;
|
||||
}
|
||||
if let Some(value) = self.max_object_bytes {
|
||||
ensure!(
|
||||
value <= global.max_object_bytes,
|
||||
"vhost object limit cannot exceed the global ceiling"
|
||||
);
|
||||
result.max_object_bytes = value;
|
||||
}
|
||||
if let Some(value) = self.bypass_query {
|
||||
result.bypass_query = value;
|
||||
}
|
||||
if let Some(value) = &self.bypass_prefixes {
|
||||
result.bypass_prefixes = value.clone();
|
||||
}
|
||||
if let Some(value) = self.static_ignore_cookies {
|
||||
result.static_ignore_cookies = value;
|
||||
}
|
||||
if let Some(value) = &self.static_extensions {
|
||||
result.static_extensions = value.clone();
|
||||
}
|
||||
if let Some(value) = self.expose_status {
|
||||
result.expose_status = value;
|
||||
}
|
||||
if let Some(value) = self.expose_hits {
|
||||
result.expose_hits = value;
|
||||
}
|
||||
if let Some(value) = self.expose_cache_status {
|
||||
result.expose_cache_status = value;
|
||||
}
|
||||
if let Some(value) = self.expose_resident_age {
|
||||
result.expose_resident_age = value;
|
||||
}
|
||||
result.validate()?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ScopeView {
|
||||
pub entries: usize,
|
||||
pub resident_bytes: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
bypasses: AtomicU64,
|
||||
stored: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
expired: AtomicU64,
|
||||
rejected_fills: AtomicU64,
|
||||
memory_pressure_rejections: AtomicU64,
|
||||
hot_eviction_limited: AtomicU64,
|
||||
purges: AtomicU64,
|
||||
cold_hits: AtomicU64,
|
||||
cold_promotions: AtomicU64,
|
||||
hot_demotions: AtomicU64,
|
||||
cold_evictions: AtomicU64,
|
||||
cold_errors: AtomicU64,
|
||||
cold_queue_dropped: AtomicU64,
|
||||
collapsed_fills: AtomicU64,
|
||||
collapse_timeouts: AtomicU64,
|
||||
purge_entries: AtomicU64,
|
||||
bans_created: AtomicU64,
|
||||
bans_removed: AtomicU64,
|
||||
ban_matches: AtomicU64,
|
||||
proactive_scheduled: AtomicU64,
|
||||
proactive_started: AtomicU64,
|
||||
proactive_skipped: AtomicU64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct View {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub bypasses: u64,
|
||||
pub stored: u64,
|
||||
pub evictions: u64,
|
||||
pub expired: u64,
|
||||
pub rejected_fills: u64,
|
||||
pub memory_pressure_rejections: u64,
|
||||
pub hot_eviction_limited: u64,
|
||||
pub purges: u64,
|
||||
pub entries: usize,
|
||||
/// Compatibility alias for HOT allocated/charged bytes.
|
||||
pub resident_bytes: usize,
|
||||
pub hot_logical_bytes: usize,
|
||||
pub hot_allocated_bytes: usize,
|
||||
pub hot_limit_bytes: usize,
|
||||
pub transient_limit_bytes: usize,
|
||||
pub cold_queue_limit_bytes: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
pub cold_limit_bytes: u64,
|
||||
pub cold_limit_entries: usize,
|
||||
pub cold_hits: u64,
|
||||
pub cold_promotions: u64,
|
||||
pub hot_demotions: u64,
|
||||
pub cold_evictions: u64,
|
||||
pub cold_errors: u64,
|
||||
pub cold_queue_dropped: u64,
|
||||
pub collapsed_fills: u64,
|
||||
pub collapse_timeouts: u64,
|
||||
pub collapse_waiters: u64,
|
||||
pub purge_entries: u64,
|
||||
pub bans_created: u64,
|
||||
pub bans_removed: u64,
|
||||
pub ban_matches: u64,
|
||||
pub active_bans: usize,
|
||||
pub proactive_scheduled: u64,
|
||||
pub proactive_started: u64,
|
||||
pub proactive_skipped: u64,
|
||||
pub proactive_inflight: u64,
|
||||
pub fills_active: usize,
|
||||
/// Bytes currently allocated by in-progress response cache fills.
|
||||
pub fill_bytes_active: u64,
|
||||
/// Demoted objects accepted by the bounded cold-writer queue.
|
||||
pub cold_queue_items: u64,
|
||||
pub cold_queue_bytes: u64,
|
||||
/// Body bytes currently owned by the cold writer after leaving the queue.
|
||||
pub cold_write_bytes_active: u64,
|
||||
/// Full cold bodies currently read into RAM for promotion.
|
||||
pub cold_read_bytes_active: u64,
|
||||
/// RustProxy-accounted cache RAM outside the published hot store.
|
||||
pub transient_bytes: u64,
|
||||
pub hit_ratio: f64,
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct DomainCounters {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub bypasses: u64,
|
||||
pub stored: u64,
|
||||
pub saved_body_bytes: u64,
|
||||
pub reasons: BTreeMap<String, u64>,
|
||||
}
|
||||
Vendored
+426
@@ -0,0 +1,426 @@
|
||||
impl ColdStore {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
let keys = self.variants.entry(base_key).or_default();
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
fn next_sequence(&mut self) -> u64 {
|
||||
if self.sequence == u64::MAX {
|
||||
// Practically unreachable. Prefer dropping the in-memory index over
|
||||
// risking ambiguous LRU ordering after wrap.
|
||||
self.lru.clear();
|
||||
self.entries.clear();
|
||||
self.variants.clear();
|
||||
self.bytes = 0;
|
||||
self.sequence = 0;
|
||||
}
|
||||
self.sequence += 1;
|
||||
self.sequence
|
||||
}
|
||||
fn remove(&mut self, key: &Key) -> Option<ColdEntry> {
|
||||
let entry = self.entries.remove(key)?;
|
||||
self.lru.remove(&entry.sequence);
|
||||
self.bytes = self.bytes.saturating_sub(entry.disk_bytes);
|
||||
let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) {
|
||||
keys.retain(|candidate| candidate != key);
|
||||
keys.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if empty {
|
||||
self.variants.remove(&entry.base_key);
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct TransientLease {
|
||||
_inner: Arc<TransientLeaseInner>,
|
||||
}
|
||||
|
||||
struct TransientLeaseInner {
|
||||
counter: Arc<AtomicU64>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Drop for TransientLeaseInner {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cache {
|
||||
store: Arc<Mutex<Store>>,
|
||||
revision: AtomicU64,
|
||||
cold: Arc<Mutex<ColdStore>>,
|
||||
tier_settings: Mutex<Settings>,
|
||||
data_root: Option<PathBuf>,
|
||||
cold_tx: Option<mpsc::Sender<ColdCommand>>,
|
||||
cold_delete_fallbacks: Arc<Semaphore>,
|
||||
cleanup_slots: Arc<Semaphore>,
|
||||
fills: Arc<Semaphore>,
|
||||
fill_bytes_active: AtomicU64,
|
||||
transient_bytes_active: Arc<AtomicU64>,
|
||||
cold_queue_items: AtomicU64,
|
||||
cold_queue_bytes: AtomicU64,
|
||||
cold_write_bytes_active: AtomicU64,
|
||||
cold_read_bytes_active: AtomicU64,
|
||||
revalidating: Mutex<HashSet<Key>>,
|
||||
fill_flights: Mutex<HashMap<Key, Arc<FillFlight>>>,
|
||||
fill_flight_sequence: AtomicU64,
|
||||
collapse_waiters: AtomicU64,
|
||||
bans: Mutex<Vec<BanRule>>,
|
||||
ban_sequence: AtomicU64,
|
||||
proactive_scheduled_keys: Mutex<HashSet<Key>>,
|
||||
proactive_inflight: AtomicU64,
|
||||
cleanup_jobs: Mutex<HashMap<u64, ColdCleanupJob>>,
|
||||
cleanup_sequence: AtomicU64,
|
||||
counters: Counters,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Ticket {
|
||||
scheme: String,
|
||||
authority: String,
|
||||
domain: String,
|
||||
path: String,
|
||||
target: String,
|
||||
key: Key,
|
||||
request_headers: HeaderMap,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
scope_generation: u64,
|
||||
key_generation: u64,
|
||||
created_at: SystemTime,
|
||||
}
|
||||
pub struct BackgroundRevalidationGuard {
|
||||
cache: std::sync::Weak<Cache>,
|
||||
key: Key,
|
||||
}
|
||||
impl Drop for BackgroundRevalidationGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(cache) = self.cache.upgrade() {
|
||||
cache
|
||||
.revalidating
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
struct ColdReadGuard<'a> {
|
||||
cache: &'a Cache,
|
||||
key: Key,
|
||||
generation: u64,
|
||||
}
|
||||
impl Drop for ColdReadGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut cold = self.cache.cold.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = cold.entries.get_mut(&self.key) {
|
||||
if entry.generation == self.generation {
|
||||
entry.pinned_reads = entry.pinned_reads.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct Hit {
|
||||
pub response: Response,
|
||||
body: Bytes,
|
||||
pub hits: u64,
|
||||
pub transformed: bool,
|
||||
pub remaining_ttl: u64,
|
||||
pub resident_age_seconds: u64,
|
||||
pub stale: bool,
|
||||
pub background_revalidate: bool,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Revalidation {
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
body_allocated: usize,
|
||||
// COLD revalidation bodies remain charged to transient RAM until every
|
||||
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
|
||||
_transient_leases: Vec<TransientLease>,
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
storage_key: Key,
|
||||
resident_age_at_lookup: Duration,
|
||||
skip_compression: bool,
|
||||
reusable_without_validation: bool,
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_if_error: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
origin_etag: Option<HeaderValue>,
|
||||
origin_last_modified: Option<HeaderValue>,
|
||||
}
|
||||
impl Revalidation {
|
||||
pub fn transformed(&self) -> bool {
|
||||
self.transformed
|
||||
}
|
||||
pub fn reusable_without_validation(&self) -> bool {
|
||||
self.reusable_without_validation
|
||||
}
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
|
||||
// Client validators refer to the representation RustProxy served. If that
|
||||
// representation was transformed, translate a matching validator back to
|
||||
// the origin validator rather than leaking the generated proxy ETag upstream.
|
||||
if headers.contains_key("if-none-match") {
|
||||
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
|
||||
return false;
|
||||
};
|
||||
if !single_if_none_match_matches(headers, public_etag) {
|
||||
return false;
|
||||
}
|
||||
if let Some(origin) = self.origin_etag.clone() {
|
||||
headers.insert("if-none-match", origin);
|
||||
headers.remove("if-modified-since");
|
||||
return true;
|
||||
}
|
||||
if let Some(modified) = self.origin_last_modified.clone() {
|
||||
headers.remove("if-none-match");
|
||||
headers.insert("if-modified-since", modified);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if headers.contains_key("if-modified-since") {
|
||||
let Some(cached) = self
|
||||
.headers
|
||||
.get("last-modified")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(client) = headers
|
||||
.get("if-modified-since")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let same = httpdate::parse_http_date(cached)
|
||||
.ok()
|
||||
.zip(httpdate::parse_http_date(client).ok())
|
||||
.is_some_and(|(cached, client)| cached == client);
|
||||
if !same {
|
||||
return false;
|
||||
}
|
||||
let Some(origin) = self.origin_last_modified.clone() else {
|
||||
return false;
|
||||
};
|
||||
headers.insert("if-modified-since", origin);
|
||||
return true;
|
||||
}
|
||||
if let Some(etag) = self.origin_etag.clone() {
|
||||
headers.insert("if-none-match", etag);
|
||||
headers.remove("if-modified-since");
|
||||
true
|
||||
} else if let Some(modified) = self.origin_last_modified.clone() {
|
||||
headers.insert("if-modified-since", modified);
|
||||
headers.remove("if-none-match");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn reusable_for_request(&self, request_headers: &HeaderMap) -> bool {
|
||||
self.reusable_without_validation
|
||||
&& request_freshness_allows(self.age, self.ttl, request_headers)
|
||||
}
|
||||
|
||||
pub fn request_freshness_revalidation_reason(
|
||||
&self,
|
||||
request_headers: &HeaderMap,
|
||||
) -> Option<&'static str> {
|
||||
let control = directives(request_headers)?;
|
||||
if let Some(Some(value)) = control.get("max-age") {
|
||||
if value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|limit| self.age > limit)
|
||||
{
|
||||
return Some("request_max_age");
|
||||
}
|
||||
}
|
||||
if let Some(Some(value)) = control.get("min-fresh") {
|
||||
if value.parse::<u64>().ok().is_some_and(|required| {
|
||||
self.ttl.saturating_sub(self.age) < required
|
||||
}) {
|
||||
return Some("request_min_fresh");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_stale_if_error(&self) -> bool {
|
||||
self.age >= self.ttl
|
||||
&& self.stale_if_error > 0
|
||||
&& self.age.saturating_sub(self.ttl) <= self.stale_if_error
|
||||
&& !stale_forbidden(self.must_revalidate, self.proxy_revalidate)
|
||||
&& !self.reusable_without_validation
|
||||
}
|
||||
pub fn merge_validation_headers(&self, validation: &HeaderMap) -> HeaderMap {
|
||||
let mut merged = merge_revalidated_headers(&self.headers, validation);
|
||||
let public_etag = self.headers.get("etag").cloned();
|
||||
let representation_etag_differs = public_etag.as_ref().is_some_and(|public| {
|
||||
self.origin_etag
|
||||
.as_ref()
|
||||
.is_none_or(|origin| public != origin)
|
||||
});
|
||||
if representation_etag_differs {
|
||||
if let Some(public) = public_etag {
|
||||
merged.insert("etag", public);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
pub fn age(&self) -> u64 {
|
||||
self.age
|
||||
}
|
||||
pub fn remaining_ttl(&self) -> u64 {
|
||||
self.ttl.saturating_sub(self.age)
|
||||
}
|
||||
pub fn response(&self, headers: HeaderMap) -> Response {
|
||||
let mut response = Response::new(Body::from(self.body.clone()));
|
||||
*response.headers_mut() = headers;
|
||||
if self.transformed {
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::CachedTransformed);
|
||||
}
|
||||
if self.skip_compression {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
fn vary_names(headers: &HeaderMap) -> Option<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for value in headers.get_all("vary") {
|
||||
for raw in value.to_str().ok()?.split(',') {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw == "*" {
|
||||
return None;
|
||||
}
|
||||
let name = raw.parse::<http::HeaderName>().ok()?;
|
||||
names.push(name.as_str().to_owned());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
(names.len() <= 64).then_some(names)
|
||||
}
|
||||
|
||||
fn variant_key(base_key: &Key, vary: &[String], headers: &HeaderMap) -> Key {
|
||||
if vary.is_empty() {
|
||||
return *base_key;
|
||||
}
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"RustProxy-Vary-v1");
|
||||
digest.update(base_key);
|
||||
for name in vary {
|
||||
digest.update((name.len() as u64).to_be_bytes());
|
||||
digest.update(name.as_bytes());
|
||||
let values = headers.get_all(name.as_str());
|
||||
digest.update((values.iter().count() as u64).to_be_bytes());
|
||||
for value in values.iter() {
|
||||
digest.update((value.as_bytes().len() as u64).to_be_bytes());
|
||||
digest.update(value.as_bytes());
|
||||
}
|
||||
}
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn hot_resident_age(entry: &Entry) -> Duration {
|
||||
entry
|
||||
.resident_age_base
|
||||
.saturating_add(entry.resident_since.elapsed())
|
||||
}
|
||||
|
||||
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(&ticket.key)?
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let entry = store.entries.get(key)?;
|
||||
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
|
||||
.then_some((*key, entry.sequence))
|
||||
})
|
||||
.max_by_key(|(_, sequence)| *sequence)
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
fn matching_cold_key(store: &ColdStore, ticket: &Ticket) -> Option<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(&ticket.key)?
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let entry = store.entries.get(key)?;
|
||||
(variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key)
|
||||
.then_some((*key, entry.sequence))
|
||||
})
|
||||
.max_by_key(|(_, sequence)| *sequence)
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
fn incompatible_hot_variants(store: &Store, base_key: &Key, vary: &[String]) -> Vec<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(base_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|key| {
|
||||
store
|
||||
.entries
|
||||
.get(key)
|
||||
.filter(|entry| entry.vary.as_slice() != vary)
|
||||
.map(|_| *key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn incompatible_cold_variants(store: &ColdStore, base_key: &Key, vary: &[String]) -> Vec<Key> {
|
||||
store
|
||||
.variants
|
||||
.get(base_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|key| {
|
||||
store
|
||||
.entries
|
||||
.get(key)
|
||||
.filter(|entry| entry.vary.as_slice() != vary)
|
||||
.map(|_| *key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body_sha256(body: &[u8]) -> String {
|
||||
let digest = Sha256::digest(body);
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cold_body_matches(entry: &ColdEntry, body: &[u8]) -> bool {
|
||||
body.len() == entry.body_bytes && body_sha256(body).as_str() == entry.body_sha256.as_str()
|
||||
}
|
||||
|
||||
fn key_hex(key: &Key) -> String {
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in key {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
Vendored
+245
@@ -0,0 +1,245 @@
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
|
||||
path::{Component, Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const FILL_SLOTS: usize = 8;
|
||||
const COLD_DELETE_FALLBACKS: usize = 2;
|
||||
const COLD_CLEANUP_SLOTS: usize = 1;
|
||||
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
|
||||
// Cold writes can contain complete response bodies. Keep the queue deliberately
|
||||
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
|
||||
const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
type Key = [u8; 32];
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum CleanupPriority {
|
||||
Automatic(u64, SystemTime, SystemTime),
|
||||
Manual(SystemTime, SystemTime, u64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct CleanupCandidate {
|
||||
priority: CleanupPriority,
|
||||
sequence: u64,
|
||||
key: Key,
|
||||
}
|
||||
|
||||
/// `Bytes` releases its allocation when the last reference is dropped, but
|
||||
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
|
||||
/// return releasable pages to the OS instead of letting RSS stay artificially
|
||||
/// high. This is intentionally a no-op on other allocators/platforms.
|
||||
fn release_allocator_memory() {
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CollapseTimeoutFallback {
|
||||
Origin,
|
||||
StaleIfAvailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StaleErrorClass {
|
||||
Timeout,
|
||||
Connection,
|
||||
Dns,
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl Default for CollapseTimeoutFallback {
|
||||
fn default() -> Self {
|
||||
Self::Origin
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPreset {
|
||||
Conservative,
|
||||
Recommended,
|
||||
Performance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColdPreset {
|
||||
Recommended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// UI sizing profile last explicitly selected by the administrator. The
|
||||
/// actual numeric limits remain authoritative; this only preserves intent
|
||||
/// across reloads so the dashboard can show which preset was applied.
|
||||
pub memory_preset: Option<MemoryPreset>,
|
||||
/// Hard RAM budget for published HOT entries, charged by backing allocation
|
||||
/// plus directly owned metadata rather than only logical body length.
|
||||
pub max_bytes: usize,
|
||||
/// Maximum logical size of a single cacheable object body.
|
||||
pub max_object_bytes: usize,
|
||||
/// Hot tier object count.
|
||||
pub max_entries: usize,
|
||||
/// Bounded Varnish-style transient RAM used by in-progress fills and full
|
||||
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
|
||||
pub transient_max_bytes: usize,
|
||||
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
|
||||
pub cold_queue_max_bytes: usize,
|
||||
/// Maximum number of LRU objects removed during one admission attempt.
|
||||
/// Reaching the limit makes the new object pass through uncached.
|
||||
pub hot_eviction_max_objects: usize,
|
||||
/// Automatically release idle HOT objects once the tier is above the target waterline.
|
||||
pub hot_auto_trim_enabled: bool,
|
||||
/// Minimum idle age before automatic HOT trimming can evict an object.
|
||||
pub hot_auto_trim_after_seconds: u64,
|
||||
/// Target HOT usage percentage reached by each automatic trim pass.
|
||||
pub hot_auto_trim_target_percent: u8,
|
||||
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
|
||||
pub cold_enabled: bool,
|
||||
/// Last explicitly selected COLD policy profile, persisted with the cache
|
||||
/// configuration for the same reason as `memory_preset`.
|
||||
pub cold_preset: Option<ColdPreset>,
|
||||
/// Relative to the RustProxy data directory.
|
||||
pub cold_path: String,
|
||||
pub cold_max_bytes: u64,
|
||||
pub cold_max_entries: usize,
|
||||
/// Interval between asynchronous cold-cache GC checks.
|
||||
pub cold_gc_interval_seconds: u64,
|
||||
/// Maximum number of objects removed by one GC pass.
|
||||
pub cold_gc_max_delete: usize,
|
||||
/// Hot objects not touched for this long are demoted to disk.
|
||||
pub cold_after_seconds: u64,
|
||||
/// Demote objects which have been idle for `cold_after_seconds`.
|
||||
pub cold_on_idle: bool,
|
||||
/// Demote objects after this much continuous residency in the hot tier
|
||||
/// when `cold_on_age` is enabled, even when they are still being read.
|
||||
pub cold_max_hot_seconds: u64,
|
||||
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
|
||||
pub cold_on_age: bool,
|
||||
/// Preserve objects evicted from the hot LRU in the cold tier.
|
||||
pub cold_on_lru: bool,
|
||||
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
|
||||
pub cold_on_shutdown: bool,
|
||||
/// Only used for responses explicitly marked public and without origin TTL.
|
||||
pub default_ttl_seconds: u64,
|
||||
pub max_ttl_seconds: u64,
|
||||
pub bypass_query: bool,
|
||||
pub bypass_prefixes: Vec<String>,
|
||||
/// For explicitly selected static extensions, ignore request Cookie when
|
||||
/// deciding cache eligibility and when building the shared cache key.
|
||||
pub static_ignore_cookies: bool,
|
||||
pub static_extensions: Vec<String>,
|
||||
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
|
||||
pub collapse_enabled: bool,
|
||||
/// Maximum time a follower waits for the current fill before applying the configured fallback.
|
||||
pub collapse_wait_timeout_ms: u64,
|
||||
pub collapse_timeout_fallback: CollapseTimeoutFallback,
|
||||
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
|
||||
pub stale_if_error_timeout: bool,
|
||||
pub stale_if_error_connection: bool,
|
||||
pub stale_if_error_dns: bool,
|
||||
pub stale_if_error_tls: bool,
|
||||
pub stale_if_error_statuses: Vec<u16>,
|
||||
/// Schedule bounded validator refresh before normal freshness expires.
|
||||
pub proactive_refresh_enabled: bool,
|
||||
pub proactive_refresh_before_expiry_seconds: u64,
|
||||
pub proactive_refresh_percent: u8,
|
||||
pub proactive_refresh_max_inflight: usize,
|
||||
pub expose_status: bool,
|
||||
pub expose_hits: bool,
|
||||
pub expose_cache_status: bool,
|
||||
pub expose_resident_age: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
memory_preset: None,
|
||||
max_bytes: 256 * 1024 * 1024,
|
||||
max_object_bytes: 16 * 1024 * 1024,
|
||||
max_entries: 4096,
|
||||
transient_max_bytes: 64 * 1024 * 1024,
|
||||
cold_queue_max_bytes: 32 * 1024 * 1024,
|
||||
hot_eviction_max_objects: 64,
|
||||
hot_auto_trim_enabled: true,
|
||||
hot_auto_trim_after_seconds: 300,
|
||||
hot_auto_trim_target_percent: 80,
|
||||
cold_enabled: false,
|
||||
cold_preset: None,
|
||||
cold_path: "cache/cold".into(),
|
||||
cold_max_bytes: 4 * 1024 * 1024 * 1024,
|
||||
cold_max_entries: 50_000,
|
||||
cold_gc_interval_seconds: 60,
|
||||
cold_gc_max_delete: 256,
|
||||
cold_after_seconds: 300,
|
||||
cold_on_idle: true,
|
||||
cold_max_hot_seconds: 3600,
|
||||
cold_on_age: false,
|
||||
cold_on_lru: true,
|
||||
cold_on_shutdown: true,
|
||||
default_ttl_seconds: 0,
|
||||
max_ttl_seconds: 86_400,
|
||||
bypass_query: false,
|
||||
bypass_prefixes: Vec::new(),
|
||||
// Static assets are normally representation-identical for logged-in and
|
||||
// anonymous visitors. Keep this enabled by default once the shared cache
|
||||
// itself is enabled; response-side privacy guards still reject Set-Cookie,
|
||||
// private/no-store and Vary: Cookie before anything is stored.
|
||||
static_ignore_cookies: true,
|
||||
static_extensions: vec![
|
||||
// styles / scripts
|
||||
"css", "js", "mjs", "cjs", "wasm", // images
|
||||
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
|
||||
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
|
||||
"woff", "woff2", "ttf", "otf", "eot", // video
|
||||
"mp4", "webm", "ogv", "mov", "m4v", // audio
|
||||
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
|
||||
// common static downloads
|
||||
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
collapse_enabled: true,
|
||||
collapse_wait_timeout_ms: 1500,
|
||||
collapse_timeout_fallback: CollapseTimeoutFallback::Origin,
|
||||
stale_if_error_timeout: true,
|
||||
stale_if_error_connection: true,
|
||||
stale_if_error_dns: true,
|
||||
stale_if_error_tls: true,
|
||||
stale_if_error_statuses: vec![500, 502, 503, 504],
|
||||
proactive_refresh_enabled: false,
|
||||
proactive_refresh_before_expiry_seconds: 15,
|
||||
proactive_refresh_percent: 10,
|
||||
proactive_refresh_max_inflight: 4,
|
||||
expose_status: false,
|
||||
expose_hits: false,
|
||||
expose_cache_status: false,
|
||||
expose_resident_age: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+232
@@ -0,0 +1,232 @@
|
||||
fn parse_key_hex(value: &str) -> Option<Key> {
|
||||
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0_u8; 32];
|
||||
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
|
||||
let text = std::str::from_utf8(chunk).ok()?;
|
||||
key[index] = u8::from_str_radix(text, 16).ok()?;
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
fn default_cache_scheme() -> String {
|
||||
"http".to_owned()
|
||||
}
|
||||
|
||||
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
|
||||
let mut tags = Vec::new();
|
||||
for value in headers.get_all("cache-tag") {
|
||||
let Ok(value) = value.to_str() else { continue; };
|
||||
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
|
||||
let tag = raw.trim();
|
||||
if tag.is_empty()
|
||||
|| tag.len() > 128
|
||||
|| !tag
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !tags.iter().any(|existing| existing == tag) {
|
||||
tags.push(tag.to_owned());
|
||||
if tags.len() >= 64 {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
fn system_time_ms(value: u64) -> SystemTime {
|
||||
UNIX_EPOCH
|
||||
.checked_add(Duration::from_millis(value))
|
||||
.unwrap_or(UNIX_EPOCH)
|
||||
}
|
||||
|
||||
fn cold_age(entry: &ColdEntry) -> u64 {
|
||||
entry
|
||||
.age_at_store_seconds
|
||||
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
|
||||
}
|
||||
|
||||
fn cold_resident_age(entry: &ColdEntry) -> u64 {
|
||||
entry
|
||||
.resident_age_at_store_seconds
|
||||
.saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs())
|
||||
}
|
||||
|
||||
fn retention_deadline(
|
||||
_ttl: u64,
|
||||
_stale_while_revalidate: u64,
|
||||
_stale_if_error: u64,
|
||||
_has_validator: bool,
|
||||
) -> u64 {
|
||||
// Freshness and storage retention are deliberately separate. Expiry only
|
||||
// changes whether an object can be served without validation; it must not
|
||||
// physically evict the representation because a later request may carry
|
||||
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
|
||||
// mechanisms that decide how long an otherwise valid object is retained.
|
||||
u64::MAX
|
||||
}
|
||||
|
||||
fn has_validator(headers: &HeaderMap) -> bool {
|
||||
headers.contains_key("etag") || headers.contains_key("last-modified")
|
||||
}
|
||||
|
||||
fn stale_forbidden(must_revalidate: bool, proxy_revalidate: bool) -> bool {
|
||||
must_revalidate || proxy_revalidate
|
||||
}
|
||||
|
||||
fn client_max_stale(headers: &HeaderMap) -> Option<Option<u64>> {
|
||||
let control = directives(headers)?;
|
||||
let value = control.get("max-stale")?;
|
||||
match value {
|
||||
None => Some(None),
|
||||
Some(value) => value.parse::<u64>().ok().map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_freshness_allows(age: u64, ttl: u64, headers: &HeaderMap) -> bool {
|
||||
let Some(control) = directives(headers) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(Some(value)) = control.get("max-age") {
|
||||
let Ok(limit) = value.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
if age > limit {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(Some(value)) = control.get("min-fresh") {
|
||||
let Ok(required) = value.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
if ttl.saturating_sub(age) < required {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn stale_mode(
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
requires_revalidation: bool,
|
||||
request_headers: &HeaderMap,
|
||||
) -> Option<bool> {
|
||||
if age < ttl || requires_revalidation || stale_forbidden(must_revalidate, proxy_revalidate) {
|
||||
return None;
|
||||
}
|
||||
if !request_freshness_allows(age, ttl, request_headers) {
|
||||
return None;
|
||||
}
|
||||
let stale_for = age.saturating_sub(ttl);
|
||||
if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate {
|
||||
return Some(true);
|
||||
}
|
||||
match client_max_stale(request_headers) {
|
||||
Some(None) => Some(false),
|
||||
Some(Some(limit)) if stale_for <= limit => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CacheState {
|
||||
Fresh,
|
||||
StaleButServable { background_revalidate: bool },
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl CacheState {
|
||||
fn servable(self) -> bool {
|
||||
!matches!(self, Self::Expired)
|
||||
}
|
||||
|
||||
fn stale(self) -> bool {
|
||||
matches!(self, Self::StaleButServable { .. })
|
||||
}
|
||||
|
||||
fn background_revalidate(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::StaleButServable {
|
||||
background_revalidate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_state(
|
||||
age: u64,
|
||||
ttl: u64,
|
||||
stale_while_revalidate: u64,
|
||||
must_revalidate: bool,
|
||||
proxy_revalidate: bool,
|
||||
requires_revalidation: bool,
|
||||
request_headers: &HeaderMap,
|
||||
) -> CacheState {
|
||||
if age < ttl
|
||||
&& !requires_revalidation
|
||||
&& request_freshness_allows(age, ttl, request_headers)
|
||||
{
|
||||
return CacheState::Fresh;
|
||||
}
|
||||
match stale_mode(
|
||||
age,
|
||||
ttl,
|
||||
stale_while_revalidate,
|
||||
must_revalidate,
|
||||
proxy_revalidate,
|
||||
requires_revalidation,
|
||||
request_headers,
|
||||
) {
|
||||
Some(background_revalidate) => CacheState::StaleButServable {
|
||||
background_revalidate,
|
||||
},
|
||||
None => CacheState::Expired,
|
||||
}
|
||||
}
|
||||
|
||||
fn cold_paths(root: &Path, key: &Key) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let key = key_hex(key);
|
||||
let shard = root.join(&key[..2]);
|
||||
(
|
||||
shard.join(format!("{key}.body")),
|
||||
shard.join(format!("{key}.json")),
|
||||
shard.join(format!(".{key}.body.tmp")),
|
||||
shard.join(format!(".{key}.json.tmp")),
|
||||
)
|
||||
}
|
||||
|
||||
fn cold_header_pairs(headers: &HeaderMap) -> Option<Vec<(String, String)>> {
|
||||
let mut result = Vec::new();
|
||||
for (name, value) in headers {
|
||||
result.push((name.as_str().to_owned(), value.to_str().ok()?.to_owned()));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn pairs_to_headers(values: &[(String, String)]) -> Option<HeaderMap> {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in values {
|
||||
let name = name.parse::<http::HeaderName>().ok()?;
|
||||
let value = HeaderValue::from_str(value).ok()?;
|
||||
headers.append(name, value);
|
||||
}
|
||||
Some(headers)
|
||||
}
|
||||
Vendored
+2
-570
@@ -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");
|
||||
Vendored
+388
@@ -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>,
|
||||
}
|
||||
Vendored
+182
@@ -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,
|
||||
})
|
||||
}
|
||||
Vendored
+6
-3441
File diff suppressed because it is too large.
Load diff
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
mod cold_tier_tests {
|
||||
include!("cold_tier_tests/part_01.rs");
|
||||
include!("cold_tier_tests/part_02.rs");
|
||||
}
|
||||
+412
@@ -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
@@ -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());
|
||||
}
|
||||
Vendored
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+99
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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");
|
||||
}
|
||||
Vendored
+404
@@ -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);
|
||||
}
|
||||
Vendored
+411
@@ -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);
|
||||
}
|
||||
Vendored
+417
@@ -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);
|
||||
}
|
||||
Vendored
+221
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
mod v022_cache_tests {
|
||||
include!("v022_cache_tests/part_01.rs");
|
||||
include!("v022_cache_tests/part_02.rs");
|
||||
}
|
||||
+421
@@ -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
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user