Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions console-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ rust-embed = { version = "^8.4", features = ["interpolate-folder-path"] }
mime_guess = { version = "^2.0" }
moka = { version = "^0.12", features = ["sync"] }
flate2 = "1.0"
xxhash-rust = { version = "0.8.12", features = ["xxh3"] }

[build-dependencies]
fs_extra = "1.3.0"
Expand Down
62 changes: 58 additions & 4 deletions console-rs/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,28 @@ pub enum CacheItem {
Found {
mime: Mime,
data: Cow<'static, [u8]>,
/// Weak `ETag` opaque tag (bare hex, without the `W/"…"` wrapper) over
/// the rendered `data`, computed once when the entry is cached so
/// revalidation never re-hashes the (potentially multi-MB) body. Serve
/// it as `W/"{etag}"`.
etag: String,
},
}

/// Weak `ETag` opaque tag (bare hex) over the rendered response bytes. Weak
/// because a transport layer may re-encode the body (e.g. compression), which
/// would break a strong byte-for-byte validator. The bytes are config-templated
/// per cache entry, so the tag distinguishes the same filename served under
/// different configs.
///
/// Uses `xxh3` (as the catalog does for `loadTable` ETags) rather than the std
/// `DefaultHasher`: it is ~4x faster on the multi-MB `DuckDB` WASM and, unlike
/// SipHash, is a fixed algorithm — so the tag stays stable across Rust releases
/// and doesn't spuriously bust caches on a toolchain bump.
fn weak_etag_tag(data: &[u8]) -> String {
format!("{:x}", xxhash_rust::xxh3::xxh3_64(data))
}

/// File cache manager for static assets that handles configuration
#[derive(Debug, Clone)]
pub struct FileCache {
Expand Down Expand Up @@ -103,10 +122,14 @@ impl FileCache {
let content = crate::get_file(&file_path_owned, &effective_config);

let cache_item = match content {
Some(content) => CacheItem::Found {
mime,
data: content.data,
},
Some(content) => {
let etag = weak_etag_tag(&content.data);
CacheItem::Found {
mime,
data: content.data,
etag,
}
}
None => CacheItem::NotFound,
};

Expand All @@ -115,3 +138,34 @@ impl FileCache {
cache_item
}
}

#[cfg(test)]
mod tests {
use super::*;

fn etag_of(item: &CacheItem) -> String {
match item {
CacheItem::Found { etag, .. } => etag.clone(),
CacheItem::NotFound => panic!("expected Found, got NotFound"),
}
}

#[test]
fn test_found_carries_stable_nonempty_etag() {
let cache = FileCache::new(LakekeeperConsoleConfig::default());
let a = cache.get_file("index.html", None, None);
let b = cache.get_file("index.html", None, None);
assert!(!etag_of(&a).is_empty());
assert_eq!(etag_of(&a), etag_of(&b), "etag must be stable per entry");
}

#[test]
fn test_etag_varies_with_templated_prefix() {
// `index.html` embeds the base-URL prefix, so the same filename served
// under a different forwarded prefix must get a distinct etag.
let cache = FileCache::new(LakekeeperConsoleConfig::default());
let root = cache.get_file("index.html", None, None);
let prefixed = cache.get_file("index.html", Some("/lakekeeper"), None);
assert_ne!(etag_of(&root), etag_of(&prefixed));
}
}
Loading