From 1dff9e387bf9a8b039572078056e93e729653953 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:12:12 -0700 Subject: [PATCH 01/16] feat(store): mount the on-disk store through one platform-aware policy --- src/core/src/cache/builders.rs | 2 +- src/core/src/cache/tests/squeezed.rs | 6 +- src/core/src/lib.rs | 1 + src/core/src/store.rs | 93 ++++++++++++++++++++ src/core/study/cache_storage.rs | 3 +- src/datafusion-local/src/lib.rs | 16 +--- src/datafusion-local/src/tests/page_index.rs | 4 +- src/datafusion-server/src/service.rs | 2 +- src/datafusion/bench/filter_pushdown.rs | 3 +- src/datafusion/src/lib.rs | 22 ++--- 10 files changed, 113 insertions(+), 39 deletions(-) create mode 100644 src/core/src/store.rs diff --git a/src/core/src/cache/builders.rs b/src/core/src/cache/builders.rs index 18db151f3..2607f7503 100644 --- a/src/core/src/cache/builders.rs +++ b/src/core/src/cache/builders.rs @@ -137,7 +137,7 @@ impl LiquidCacheBuilder { None => { let cache_dir = tempfile::tempdir().unwrap().keep(); let store_path = cache_dir.join("liquid_cache.t4"); - t4::mount(&store_path) + crate::store::mount(&store_path) .await .expect("failed to mount t4 store") } diff --git a/src/core/src/cache/tests/squeezed.rs b/src/core/src/cache/tests/squeezed.rs index ec46a4507..3d7c152e7 100644 --- a/src/core/src/cache/tests/squeezed.rs +++ b/src/core/src/cache/tests/squeezed.rs @@ -29,7 +29,7 @@ async fn read_squeezed_date_time() { .with_squeeze_policy(Box::new(TranscodeSqueezeEvict)) .with_max_memory_bytes(array_size * 2) .with_store( - t4::mount(temp_dir.path().join("liquid_cache.t4")) + crate::store::mount(temp_dir.path().join("liquid_cache.t4")) .await .unwrap(), ) @@ -95,7 +95,7 @@ async fn read_squeezed_variant_path() { .with_squeeze_policy(Box::new(TranscodeSqueezeEvict)) .with_max_memory_bytes(array_size * 3 / 2) .with_store( - t4::mount(temp_dir.path().join("liquid_cache.t4")) + crate::store::mount(temp_dir.path().join("liquid_cache.t4")) .await .unwrap(), ) @@ -158,7 +158,7 @@ async fn read_squeezed_int64_array() { .with_squeeze_policy(Box::new(TranscodeSqueezeEvict)) .with_max_memory_bytes(array_size * 2) .with_store( - t4::mount(temp_dir.path().join("liquid_cache.t4")) + crate::store::mount(temp_dir.path().join("liquid_cache.t4")) .await .unwrap(), ) diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs index fdba18115..8cfd11277 100644 --- a/src/core/src/lib.rs +++ b/src/core/src/lib.rs @@ -3,6 +3,7 @@ pub mod cache; pub mod liquid_array; +pub mod store; mod sync; pub mod utils; diff --git a/src/core/src/store.rs b/src/core/src/store.rs new file mode 100644 index 000000000..cd8da9ec8 --- /dev/null +++ b/src/core/src/store.rs @@ -0,0 +1,93 @@ +//! Mounting the on-disk store that backs the cache's disk tier. +//! +//! LiquidCache wants DIRECT I/O. Bypassing the OS page cache is what makes the +//! cache's own byte accounting the whole truth: one copy of a cached page +//! exists, and the cache knows about it. The admission gate +//! ([`crate::cache`] budgets, and `liquid-cache-datafusion`'s footprint gate) +//! is built on that premise. +//! +//! [`t4`] only implements DIRECT I/O on Linux — every other target refuses the +//! option outright rather than silently ignoring it. So off Linux we mount +//! buffered and say so. The cache stays correct: it writes, reads and evicts +//! exactly as before. What it loses is the accounting guarantee, because the +//! kernel now keeps a second copy of every page that the cache does not count. +//! That makes non-Linux fine for development and wrong for measurement. + +use std::path::Path; +use std::sync::Once; + +/// Whether this target mounts the on-disk store with DIRECT I/O. +/// +/// `false` means the OS page cache holds an uncounted second copy of cached +/// pages, so byte accounting understates real residency. Benchmark and +/// capacity-planning numbers are only meaningful when this is `true`. +pub const DIRECT_IO: bool = cfg!(target_os = "linux"); + +/// Mount the on-disk store for a LiquidCache instance at `path`. +/// +/// Prefer this over calling [`t4::mount`] directly: it is the one place that +/// decides the store's I/O mode, so the choice cannot drift between the cache +/// builders, the server, benches and tests. +#[cfg(target_os = "linux")] +pub async fn mount(path: impl AsRef) -> t4::Result { + t4::mount(path).await +} + +/// Mount the on-disk store for a LiquidCache instance at `path`. +/// +/// See the [module docs](self) for what buffered I/O costs off Linux. +#[cfg(not(target_os = "linux"))] +pub async fn mount(path: impl AsRef) -> t4::Result { + static WARNED: Once = Once::new(); + WARNED.call_once(|| { + log::warn!( + "mounting the liquid cache store with buffered I/O: t4 supports DIRECT I/O on Linux \ + only. The cache is functional, but the OS page cache holds a second copy of cached \ + pages that the cache does not count, so memory accounting understates residency. \ + Measure performance on Linux." + ); + }); + + // `dsync` stays at t4's default: O_DSYNC is honoured off Linux, so keeping + // it preserves the write-durability semantics Linux gets. + t4::mount_with_options( + path, + t4::MountOptions { + direct_io: false, + ..Default::default() + }, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The store must mount, round-trip a value and survive a remount on every + /// platform. On Linux this covers t4's io_uring backend; elsewhere it is the + /// only coverage the generic thread-pool backend gets. + #[tokio::test] + async fn mount_round_trips_on_this_platform() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("liquid_cache.t4"); + + let store = mount(&path).await.expect("mount must succeed"); + store.put(b"key".to_vec(), b"hello".to_vec()).await.unwrap(); + assert_eq!(store.get(b"key").await.unwrap(), b"hello"); + store.sync().await.unwrap(); + drop(store); + + let store = mount(&path).await.expect("remount must succeed"); + assert_eq!( + store.get(b"key").await.unwrap(), + b"hello", + "a remounted store must replay what was written" + ); + } + + #[test] + fn direct_io_tracks_the_target() { + assert_eq!(DIRECT_IO, cfg!(target_os = "linux")); + } +} diff --git a/src/core/study/cache_storage.rs b/src/core/study/cache_storage.rs index 0c69f4482..e4d6533a3 100644 --- a/src/core/study/cache_storage.rs +++ b/src/core/study/cache_storage.rs @@ -46,7 +46,8 @@ fn main() { .clone() .unwrap_or_else(|| tempfile::tempdir().unwrap().keep()); let store_path = cache_dir.join("liquid_cache.t4"); - let store = tokio_test::block_on(t4::mount(&store_path)).expect("failed to mount t4 store"); + let store = tokio_test::block_on(liquid_cache::store::mount(&store_path)) + .expect("failed to mount t4 store"); let storage = tokio_test::block_on(async { LiquidCacheBuilder::new() .with_max_memory_bytes(500 * 1024 * 1024) diff --git a/src/datafusion-local/src/lib.rs b/src/datafusion-local/src/lib.rs index 1c4638a40..82cc7ad7b 100644 --- a/src/datafusion-local/src/lib.rs +++ b/src/datafusion-local/src/lib.rs @@ -185,19 +185,9 @@ impl LiquidCacheLocalBuilder { config.options_mut().execution.parquet.skip_metadata = false; config.options_mut().execution.batch_size = self.batch_size; - // t4's default MountOptions enable direct_io, which only Linux - // supports; everywhere else the mount fails outright. Keep direct_io - // on Linux (production) and fall back to buffered I/O elsewhere so - // local mode — and its tests — run on macOS dev machines. - let store = t4::mount_with_options( - self.cache_dir.join("liquid_cache.t4"), - t4::MountOptions { - direct_io: cfg!(target_os = "linux"), - ..Default::default() - }, - ) - .await - .map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?; + let store = liquid_cache::store::mount(self.cache_dir.join("liquid_cache.t4")) + .await + .map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?; #[cfg(not(test))] let cache = LiquidCacheParquet::new( self.batch_size, diff --git a/src/datafusion-local/src/tests/page_index.rs b/src/datafusion-local/src/tests/page_index.rs index 85cecae23..779db571d 100644 --- a/src/datafusion-local/src/tests/page_index.rs +++ b/src/datafusion-local/src/tests/page_index.rs @@ -136,8 +136,8 @@ fn fixture_is_rejected_by_required_policy() { /// Scanning such a file through LiquidCache must succeed. The predicate matters: /// it is what builds the page-pruning predicate that consults the index. /// -/// The local builder mounts t4 with buffered I/O off Linux, so unlike the -/// direct-`t4::mount` suites this runs on every dev machine, not just CI. +/// The store is mounted through `liquid_cache::store::mount`, which uses +/// buffered I/O off Linux, so this runs on every dev machine, not just CI. #[tokio::test] async fn scans_file_without_offset_index() { let dir = TempDir::new().unwrap(); diff --git a/src/datafusion-server/src/service.rs b/src/datafusion-server/src/service.rs index 06015fa3b..b135dd93e 100644 --- a/src/datafusion-server/src/service.rs +++ b/src/datafusion-server/src/service.rs @@ -58,7 +58,7 @@ impl LiquidCacheServiceInner { let liquid_cache_dir = disk_cache_dir.join("liquid"); std::fs::create_dir_all(&liquid_cache_dir).expect("Failed to create liquid cache dir"); - let store = t4::mount(liquid_cache_dir.join("liquid_cache.t4")) + let store = liquid_cache::store::mount(liquid_cache_dir.join("liquid_cache.t4")) .await .expect("Failed to mount t4 store"); let liquid_cache = Arc::new( diff --git a/src/datafusion/bench/filter_pushdown.rs b/src/datafusion/bench/filter_pushdown.rs index 897d8e2e1..33244c733 100644 --- a/src/datafusion/bench/filter_pushdown.rs +++ b/src/datafusion/bench/filter_pushdown.rs @@ -40,7 +40,8 @@ fn create_boolean_filter(array_size: usize, selectivity: f64) -> BooleanBuffer { fn setup_cache() -> (Arc, tempfile::TempDir) { let tmp_dir = tempfile::tempdir().unwrap(); let store_path = tmp_dir.path().join("liquid_cache.t4"); - let store = tokio_test::block_on(t4::mount(&store_path)).expect("failed to mount t4 store"); + let store = tokio_test::block_on(liquid_cache::store::mount(&store_path)) + .expect("failed to mount t4 store"); let cache = tokio_test::block_on(LiquidCacheParquet::new( BATCH_SIZE, 1024 * 1024 * 1024, // max_memory_bytes (1GB) diff --git a/src/datafusion/src/lib.rs b/src/datafusion/src/lib.rs index e80d717be..058dacf80 100644 --- a/src/datafusion/src/lib.rs +++ b/src/datafusion/src/lib.rs @@ -11,24 +11,12 @@ pub(crate) mod utils; pub(crate) mod test_utils { //! Shared helpers for this crate's tests. - /// Mount a t4 store for a test. - /// - /// t4's default [`t4::MountOptions`] enable `direct_io`, which only Linux - /// supports; everywhere else the mount fails outright with - /// `direct_io not supported on target_os`. Keep it on Linux (production, CI) - /// and fall back to buffered I/O elsewhere, matching what - /// `LiquidCacheLocalBuilder` does, so these tests run on macOS dev machines - /// too. + /// Mount a t4 store for a test, using the same I/O mode the cache uses in + /// production on this platform. pub(crate) async fn mount_test_store(dir: &std::path::Path) -> t4::Store { - t4::mount_with_options( - dir.join("liquid_cache.t4"), - t4::MountOptions { - direct_io: cfg!(target_os = "linux"), - ..Default::default() - }, - ) - .await - .expect("mount t4 test store") + liquid_cache::store::mount(dir.join("liquid_cache.t4")) + .await + .expect("mount t4 test store") } } From ebec58b9d5453103efeb6b072de7a9be07384df2 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:14:03 -0700 Subject: [PATCH 02/16] fix(benchmark): build on targets without perf_event_open --- benchmark/Cargo.toml | 5 +++++ benchmark/src/inprocess_runner.rs | 35 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/benchmark/Cargo.toml b/benchmark/Cargo.toml index 25eb33de4..870bdb4c1 100644 --- a/benchmark/Cargo.toml +++ b/benchmark/Cargo.toml @@ -41,6 +41,11 @@ pprof = { version = "0.15.0", features = ["flamegraph"] } anyhow = "1.0" usdt = "0.6" regex = "1.12.4" + +# Hardware counters come from perf_event_open, a Linux-only syscall; the crate +# does not compile anywhere else. See `PerfEventCollector` in +# src/inprocess_runner.rs for the fallback. +[target.'cfg(target_os = "linux")'.dependencies] perf-event2 = "0.7.4" [features] diff --git a/benchmark/src/inprocess_runner.rs b/benchmark/src/inprocess_runner.rs index 4ef6f57db..b45707cf6 100644 --- a/benchmark/src/inprocess_runner.rs +++ b/benchmark/src/inprocess_runner.rs @@ -14,6 +14,7 @@ use liquid_cache::cache_policies::LiquidPolicy; use liquid_cache_datafusion::{LiquidCacheParquetRef, extract_execution_metrics}; use liquid_cache_datafusion_local::LiquidCacheLocalBuilder; use log::{info, warn}; +#[cfg(target_os = "linux")] use perf_event::{ Builder as PerfBuilder, Counter, Group, events::{Hardware, Software}, @@ -72,6 +73,13 @@ impl DiskIoGuard { } } +/// Hardware and software counters for one query iteration. +/// +/// `perf_event_open` is a Linux syscall, so off Linux the collector cannot be +/// constructed at all — see the uninhabited stub below. Callers already treat a +/// construction failure as "no counters this run", which is what we want: +/// absent counters rather than fabricated zeroes. +#[cfg(target_os = "linux")] struct PerfEventCollector { group: Group, cycles: Counter, @@ -82,6 +90,7 @@ struct PerfEventCollector { page_faults: Counter, } +#[cfg(target_os = "linux")] impl PerfEventCollector { fn new() -> io::Result { let mut group = Group::new()?; @@ -159,6 +168,32 @@ impl PerfEventCollector { } } +/// Stand-in for the collector on targets without `perf_event_open`. +/// +/// Uninhabited, so the only reachable method is [`Self::new`], which always +/// fails. `start` and `stop` are therefore statically unreachable and need no +/// panic. Keeping the same shape as the Linux type means the call site is +/// identical on every platform. +#[cfg(not(target_os = "linux"))] +enum PerfEventCollector {} + +#[cfg(not(target_os = "linux"))] +impl PerfEventCollector { + fn new() -> io::Result { + Err(io::Error::other( + "hardware counters need perf_event_open, which only Linux provides", + )) + } + + fn start(&mut self) -> io::Result<()> { + match *self {} + } + + fn stop(self) -> io::Result { + match self {} + } +} + #[derive(Clone, Debug, Default, Copy, PartialEq, Eq, Serialize)] pub enum InProcessBenchmarkMode { Parquet, From 7e6858c3216def3182ed03e655f825887a7214b5 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:14:46 -0700 Subject: [PATCH 03/16] docs: state what buffered I/O costs off Linux --- README.md | 10 +++++++++- src/datafusion/src/optimizers/mod.rs | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 98dcfa526..dfdc0dbe9 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,18 @@ tokio_test::block_on(async { ### LiquidCache uses DIRECT I/O -By default, LiquidCache uses [DIRECT I/O](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=O_DIRECT). This means that it bypasses the OS page cache, this avoids double-caching and bound memory usage. +On Linux, LiquidCache uses [DIRECT I/O](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=O_DIRECT). This means that it bypasses the OS page cache, this avoids double-caching and bound memory usage. This also means LiquidCache can *appear slower* than other caches when most data fits in OS page cache, which is common in dev environments but unrealistic in production. +### Platform support + +LiquidCache builds, runs and passes its test suite on both Linux and macOS. DIRECT I/O is the exception: the underlying store implements it on Linux only, so every other target mounts with buffered I/O instead and logs a warning once at startup. + +That fallback keeps the cache correct — it writes, reads and evicts exactly as on Linux — but it costs the property the accounting depends on. Under DIRECT I/O a cached page exists once and the cache knows about it. Under buffered I/O the kernel holds a second copy that the cache does not count, so reported memory understates real residency, and the admission gate's budget is measured against an incomplete figure. + +So: **develop anywhere, measure on Linux.** Benchmark numbers from macOS are not comparable to production, and generally flatter LiquidCache rather than penalising it, since reads may be served from the page cache that DIRECT I/O deliberately avoids. Use a Linux machine or VM for any performance or capacity work. + ### Use LiquidCache with DataFusion diff --git a/src/datafusion/src/optimizers/mod.rs b/src/datafusion/src/optimizers/mod.rs index f42e9b9d0..daf21fa65 100644 --- a/src/datafusion/src/optimizers/mod.rs +++ b/src/datafusion/src/optimizers/mod.rs @@ -47,6 +47,12 @@ use crate::{LiquidCacheParquetRef, LiquidParquetSource, cache::ColumnSqueezeHint /// The estimate multiplies the raw required parquet bytes by `expansion` /// (parquet -> liquid in-memory blow-up) and `safety` (extra margin); both are /// `>= 1.0` so the estimate is conservative (over-counts). +/// +/// The budget assumes the bytes the cache counts are the bytes actually +/// resident, which holds only under DIRECT I/O. Off Linux the store falls back +/// to buffered I/O (see [`liquid_cache::store`]) and the kernel keeps an +/// uncounted second copy, so real residency exceeds the figure this gate is +/// measured against. Tune these knobs on Linux. #[derive(Debug, Clone, Copy)] pub struct AdmissionGate { /// Parquet-bytes -> liquid-in-memory-bytes multiplier (>= 1.0). Inflates the From 3b8a52a8d2665643309892e735c5cd28826e8e55 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:17:46 -0700 Subject: [PATCH 04/16] refactor(store): collapse mount to a single policy body --- src/core/src/store.rs | 57 ++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/core/src/store.rs b/src/core/src/store.rs index cd8da9ec8..9f57679f1 100644 --- a/src/core/src/store.rs +++ b/src/core/src/store.rs @@ -16,50 +16,46 @@ use std::path::Path; use std::sync::Once; -/// Whether this target mounts the on-disk store with DIRECT I/O. -/// -/// `false` means the OS page cache holds an uncounted second copy of cached -/// pages, so byte accounting understates real residency. Benchmark and -/// capacity-planning numbers are only meaningful when this is `true`. -pub const DIRECT_IO: bool = cfg!(target_os = "linux"); - -/// Mount the on-disk store for a LiquidCache instance at `path`. +/// Mount the on-disk store for a LiquidCache instance at `path`, which is the +/// full path to the store file. /// /// Prefer this over calling [`t4::mount`] directly: it is the one place that /// decides the store's I/O mode, so the choice cannot drift between the cache /// builders, the server, benches and tests. -#[cfg(target_os = "linux")] -pub async fn mount(path: impl AsRef) -> t4::Result { - t4::mount(path).await -} - -/// Mount the on-disk store for a LiquidCache instance at `path`. /// -/// See the [module docs](self) for what buffered I/O costs off Linux. -#[cfg(not(target_os = "linux"))] +/// On Linux this is exactly [`t4::mount`] — `direct_io` and `dsync` both come +/// out `true`, matching [`t4::MountOptions::default`]. See the +/// [module docs](self) for what the buffered fallback costs elsewhere. pub async fn mount(path: impl AsRef) -> t4::Result { - static WARNED: Once = Once::new(); - WARNED.call_once(|| { - log::warn!( - "mounting the liquid cache store with buffered I/O: t4 supports DIRECT I/O on Linux \ - only. The cache is functional, but the OS page cache holds a second copy of cached \ - pages that the cache does not count, so memory accounting understates residency. \ - Measure performance on Linux." - ); - }); + #[cfg(not(target_os = "linux"))] + warn_buffered_once(); - // `dsync` stays at t4's default: O_DSYNC is honoured off Linux, so keeping - // it preserves the write-durability semantics Linux gets. + // `dsync` stays at t4's default: O_DSYNC is honoured off Linux too, so the + // fallback keeps the write-durability semantics Linux gets. t4::mount_with_options( path, t4::MountOptions { - direct_io: false, + direct_io: cfg!(target_os = "linux"), ..Default::default() }, ) .await } +#[cfg(not(target_os = "linux"))] +fn warn_buffered_once() { + static WARNED: Once = Once::new(); + WARNED.call_once(|| { + log::warn!( + "mounting the liquid cache store with buffered I/O: t4 implements DIRECT I/O on Linux \ + only. The cache is fully functional, but memory accounting now excludes the kernel \ + page-cache copy of every cached page, so reported usage understates real residency \ + and the admission gate's budget is measured against an incomplete figure. Measure \ + performance on Linux." + ); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -85,9 +81,4 @@ mod tests { "a remounted store must replay what was written" ); } - - #[test] - fn direct_io_tracks_the_target() { - assert_eq!(DIRECT_IO, cfg!(target_os = "linux")); - } } From 983132901b293d4142e7f8cdd031fef97623acd1 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:18:39 -0700 Subject: [PATCH 05/16] test(utils): cover the portable and_then path on every architecture --- src/datafusion/src/utils.rs | 107 ++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/datafusion/src/utils.rs b/src/datafusion/src/utils.rs index 7cc144271..632df08af 100644 --- a/src/datafusion/src/utils.rs +++ b/src/datafusion/src/utils.rs @@ -312,6 +312,113 @@ pub fn extract_execution_metrics( mod tests { use super::*; + /// Spells out `boolean_buffer_and_then`'s contract directly: walk `left`, + /// and each time it is set, take the next bit of `right`. + /// + /// The BMI2 differential tests below can only run on x86_64, so without an + /// independent reference the portable path — which is what executes on + /// aarch64, and on any x86_64 CPU without BMI2, since the fast path is + /// chosen at runtime — would have no test of its own at all. + /// + /// Both operands are assumed to start at bit 0. Neither implementation + /// handles a bit-offset (sliced) `left`, and they disagree with each other + /// on a bit-offset `right`; the sole caller only ever passes offset-0 + /// buffers. Offset handling is tracked separately and is deliberately not + /// exercised here. + fn reference_and_then(left: &BooleanBuffer, right: &BooleanBuffer) -> Vec { + debug_assert_eq!(left.offset(), 0); + debug_assert_eq!(right.offset(), 0); + let mut right_bits = right.iter(); + (0..left.len()) + .map(|i| { + left.value(i) + && right_bits + .next() + .expect("right must have one bit per set bit of left") + }) + .collect() + } + + fn buffer_of(bits: &[bool]) -> BooleanBuffer { + let mut builder = BooleanBufferBuilder::new(bits.len()); + for &bit in bits { + builder.append(bit); + } + builder.finish() + } + + fn assert_matches_reference(left: &[bool], right: &[bool]) { + let (left, right) = (buffer_of(left), buffer_of(right)); + assert_eq!( + left.count_set_bits(), + right.len(), + "malformed case: right must have one bit per set bit of left" + ); + + let expected = reference_and_then(&left, &right); + let actual = boolean_buffer_and_then(&left, &right); + + assert_eq!(actual.len(), left.len()); + let actual: Vec = (0..actual.len()).map(|i| actual.value(i)).collect(); + assert_eq!(actual, expected); + } + + /// Lengths chosen around the 64-bit word boundary the BMI2 path iterates on: + /// below one word, exactly one word, and a word plus a partial tail. + #[test] + fn and_then_matches_reference_across_shapes() { + // Empty. + assert_matches_reference(&[], &[]); + + // Nothing selected: `right` is empty, output is all false. + assert_matches_reference(&[false; 8], &[]); + assert_matches_reference(&[false; 100], &[]); + + // Everything selected, so `left.len() == right.len()` — this is the + // early-return path that clones `right` outright. + assert_matches_reference( + &[true; 8], + &[true, false, true, false, true, false, true, true], + ); + + for len in [1, 7, 8, 9, 63, 64, 65, 127, 128, 129, 200] { + // Every third bit set, alternating bits in `right`. + let left: Vec = (0..len).map(|i| i % 3 == 0).collect(); + let right: Vec = (0..left.iter().filter(|b| **b).count()) + .map(|i| i % 2 == 0) + .collect(); + assert_matches_reference(&left, &right); + + // All set, and all of `right` set: output must equal `left`. + let all: Vec = vec![true; len]; + assert_matches_reference(&all, &vec![true; len]); + + // All set, none of `right` set: output must be entirely false. + assert_matches_reference(&all, &vec![false; len]); + + // A single set bit at the very end, the tail-handling case. + let mut last = vec![false; len]; + last[len - 1] = true; + assert_matches_reference(&last, &[true]); + assert_matches_reference(&last, &[false]); + } + } + + /// The documented shortcut: when every bit of `left` is set, the result is + /// `right` unchanged. + #[test] + fn and_then_returns_right_when_left_selects_everything() { + let left = buffer_of(&[true; 64]); + let right = buffer_of(&(0..64).map(|i| i % 5 == 0).collect::>()); + + let result = boolean_buffer_and_then(&left, &right); + + assert_eq!(result.len(), 64); + for i in 0..64 { + assert_eq!(result.value(i), right.value(i), "mismatch at {i}"); + } + } + #[test] #[cfg(target_arch = "x86_64")] fn test_boolean_buffer_and_then_bmi2_large() { From 5ca1106347d657c3a86bd45ee237f71efbdb96af Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:19:25 -0700 Subject: [PATCH 06/16] fix(nix): keep Linux-only tools out of the darwin devShell --- flake.nix | 8 ++++++-- src/datafusion/src/utils.rs | 13 ++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/flake.nix b/flake.nix index ef8360ae8..576890697 100644 --- a/flake.nix +++ b/flake.nix @@ -41,8 +41,6 @@ llvmPackages.bintools lldb cargo-fuzz - bpftrace - perf nixd inferno cargo-flamegraph @@ -55,6 +53,12 @@ extensions = [ "rust-src" "llvm-tools-preview" ]; targets = [ "x86_64-unknown-linux-gnu" "wasm32-unknown-unknown" ]; })) + ] + # perf and bpftrace exist only on Linux in nixpkgs, and this flake + # is evaluated for every default system, macOS included. + ++ lib.optionals stdenv.isLinux [ + bpftrace + perf ]; shellHook = '' diff --git a/src/datafusion/src/utils.rs b/src/datafusion/src/utils.rs index 632df08af..8dcfc1832 100644 --- a/src/datafusion/src/utils.rs +++ b/src/datafusion/src/utils.rs @@ -320,11 +320,14 @@ mod tests { /// aarch64, and on any x86_64 CPU without BMI2, since the fast path is /// chosen at runtime — would have no test of its own at all. /// - /// Both operands are assumed to start at bit 0. Neither implementation - /// handles a bit-offset (sliced) `left`, and they disagree with each other - /// on a bit-offset `right`; the sole caller only ever passes offset-0 - /// buffers. Offset handling is tracked separately and is deliberately not - /// exercised here. + /// Both operands are assumed to start at bit 0, which is all the sole + /// caller ever passes. That limit is real and untested on purpose: the + /// fallback mishandles a bit-offset (sliced) `left`, because it seeds the + /// output from `left.values()` — which excludes the offset — while walking + /// the offset-aware `left.set_indices()`. The BMI2 path ignores the offset + /// of *both* operands, so the two disagree on a bit-offset `right`. Fixing + /// that means touching the filter hot path, so it is left to its own + /// change; covering it here would only pin down today's wrong answers. fn reference_and_then(left: &BooleanBuffer, right: &BooleanBuffer) -> Vec { debug_assert_eq!(left.offset(), 0); debug_assert_eq!(right.offset(), 0); From 477a35b5a4a26f32051fa13060efe5160a7c128f Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:27:32 -0700 Subject: [PATCH 07/16] test(local): gate the exact-memory snapshot to Linux --- src/datafusion-local/src/tests/mod.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/datafusion-local/src/tests/mod.rs b/src/datafusion-local/src/tests/mod.rs index 3e7ad0750..369122926 100644 --- a/src/datafusion-local/src/tests/mod.rs +++ b/src/datafusion-local/src/tests/mod.rs @@ -335,6 +335,26 @@ async fn test_single_column_filter_projection() { test_runner(sql, &reference, cache_dir.path()).await; } +/// Runs on Linux only, because the snapshot pins `usage.memory_bytes` exactly +/// and aarch64-darwin reports 935 bytes less at every one of the three +/// measurement points (1000915 -> 999980, 1036304 -> 1035369), reproducibly. +/// +/// Why is not yet known. Ruled out so far: `target_partitions` (pinned to 4 +/// below, so it is not the host's CPU count), the temp directory path, the +/// liquid encoding itself (`usage.disk_bytes` is identical at 35000, and the +/// entry mix — 5 memory.arrow, 1 memory.liquid, 2 disk.liquid — matches), and +/// buffer alignment (the string-view data buffers this file produces have +/// `capacity == len`, so the accounting is exact rather than rounded, which is +/// what makes an odd 935-byte delta possible in the first place). +/// +/// Diagnosing it needs a Linux and a macOS run side by side. Until then this +/// stays gated rather than redacted, so the Linux assertion keeps its full +/// strength and the `cargo insta` workflow keeps working. To see the diff on +/// macOS: `cargo test -p liquid-cache-datafusion-local -- --ignored`. +#[cfg_attr( + not(target_os = "linux"), + ignore = "snapshot pins exact memory_bytes; aarch64-darwin differs by 935 bytes" +)] #[tokio::test] async fn test_provide_schema2() { use std::fmt::Write as _; From 4118830a87d51859e6c7d09703c29c69d1a30622 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:28:05 -0700 Subject: [PATCH 08/16] ci: run clippy and tests on macOS --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e509b35..cdabc9a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,28 @@ jobs: files: codecov.json fail_ci_if_error: true + macos: + # The only job that exercises the store's non-io_uring I/O backend and + # the portable (non-BMI2) selection path, both of which Linux never + # reaches. Excludes dev-tools, whose generated tailwind.css comes from + # the npm steps the Linux jobs run; benchmarks stay Linux-only because + # buffered I/O makes their numbers unrepresentative. + name: macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-${{ runner.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Run clippy + run: cargo clippy --workspace --exclude dev-tools --all-targets --all-features -- -D warnings + - name: Run tests + run: cargo test --workspace --exclude dev-tools + shuttle_test: name: Shuttle Test runs-on: ubuntu-latest From 32859f2566c0e23850c5d3837074737c048cf8dd Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:46:49 -0700 Subject: [PATCH 09/16] fix(store): scope the Once import to the non-Linux path --- src/core/src/store.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/src/store.rs b/src/core/src/store.rs index 9f57679f1..6d9e03ca2 100644 --- a/src/core/src/store.rs +++ b/src/core/src/store.rs @@ -14,7 +14,6 @@ //! That makes non-Linux fine for development and wrong for measurement. use std::path::Path; -use std::sync::Once; /// Mount the on-disk store for a LiquidCache instance at `path`, which is the /// full path to the store file. @@ -44,7 +43,7 @@ pub async fn mount(path: impl AsRef) -> t4::Result { #[cfg(not(target_os = "linux"))] fn warn_buffered_once() { - static WARNED: Once = Once::new(); + static WARNED: std::sync::Once = std::sync::Once::new(); WARNED.call_once(|| { log::warn!( "mounting the liquid cache store with buffered I/O: t4 implements DIRECT I/O on Linux \ From 8ac23636884145973e7ac3ae32864c7bf346fde7 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:46:56 -0700 Subject: [PATCH 10/16] test(local): gate the exact-memory snapshot by arch, not OS --- src/datafusion-local/src/tests/mod.rs | 44 +++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/datafusion-local/src/tests/mod.rs b/src/datafusion-local/src/tests/mod.rs index 369122926..1ddea6ee0 100644 --- a/src/datafusion-local/src/tests/mod.rs +++ b/src/datafusion-local/src/tests/mod.rs @@ -335,25 +335,37 @@ async fn test_single_column_filter_projection() { test_runner(sql, &reference, cache_dir.path()).await; } -/// Runs on Linux only, because the snapshot pins `usage.memory_bytes` exactly -/// and aarch64-darwin reports 935 bytes less at every one of the three -/// measurement points (1000915 -> 999980, 1036304 -> 1035369), reproducibly. +/// Runs on x86_64 only, because the snapshot pins `usage.memory_bytes` exactly +/// and aarch64 reports 935 bytes less at every one of the three measurement +/// points (1000915 -> 999980, 1036304 -> 1035369), reproducibly. /// -/// Why is not yet known. Ruled out so far: `target_partitions` (pinned to 4 -/// below, so it is not the host's CPU count), the temp directory path, the -/// liquid encoding itself (`usage.disk_bytes` is identical at 35000, and the -/// entry mix — 5 memory.arrow, 1 memory.liquid, 2 disk.liquid — matches), and -/// buffer alignment (the string-view data buffers this file produces have -/// `capacity == len`, so the accounting is exact rather than rounded, which is -/// what makes an odd 935-byte delta possible in the first place). +/// The split is by architecture, not by OS. Measured: /// -/// Diagnosing it needs a Linux and a macOS run side by side. Until then this -/// stays gated rather than redacted, so the Linux assertion keeps its full -/// strength and the `cargo insta` workflow keeps working. To see the diff on -/// macOS: `cargo test -p liquid-cache-datafusion-local -- --ignored`. +/// | target | usage.memory_bytes | +/// |---------------------|--------------------| +/// | x86_64-linux | 1000915 (recorded) | +/// | aarch64-linux | 999980 | +/// | aarch64-darwin | 999980 | +/// +/// aarch64-linux and aarch64-darwin agree exactly, so the OS is not the +/// variable — gating on `target_os` would still fail on Graviton or on any ARM +/// Linux runner. +/// +/// Why the architectures differ is still unknown. Ruled out: `target_partitions` +/// (pinned to 4 below, so not the host CPU count), the temp directory path, the +/// serialized liquid encoding (`usage.disk_bytes` is identical at 35000 and the +/// entry mix — 5 memory.arrow, 1 memory.liquid, 2 disk.liquid — matches, so only +/// the in-memory footprint moves), and buffer rounding (the string-view data +/// buffers this file produces have `capacity == len`, so the accounting is exact, +/// which is what admits an odd 935-byte delta at all). `fastlanes` bit-packing is +/// the obvious next place to look. +/// +/// Gated rather than redacted so the x86_64 assertion keeps full strength and the +/// `cargo insta` workflow keeps working. To see the diff elsewhere: +/// `cargo test -p liquid-cache-datafusion-local -- --ignored`. #[cfg_attr( - not(target_os = "linux"), - ignore = "snapshot pins exact memory_bytes; aarch64-darwin differs by 935 bytes" + not(target_arch = "x86_64"), + ignore = "snapshot pins exact memory_bytes; aarch64 differs by 935 bytes" )] #[tokio::test] async fn test_provide_schema2() { From 0603cc6b6fc0b34acea7f00b267a4f2de6466e7b Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 26 Aug 2026 23:48:55 -0700 Subject: [PATCH 11/16] docs(utils): note which and_then impl each host tests --- src/datafusion/src/utils.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/datafusion/src/utils.rs b/src/datafusion/src/utils.rs index 8dcfc1832..11cab883a 100644 --- a/src/datafusion/src/utils.rs +++ b/src/datafusion/src/utils.rs @@ -320,6 +320,14 @@ mod tests { /// aarch64, and on any x86_64 CPU without BMI2, since the fast path is /// chosen at runtime — would have no test of its own at all. /// + /// Which implementation these tests reach therefore depends on the host, + /// because they go through the public dispatcher: BMI2 on an x86_64 CPU that + /// has it, the portable path everywhere else. That is deliberate. Between the + /// two CI targets each implementation gets checked against this reference, + /// and on x86_64 it also pins BMI2 to an oracle it does not share code with — + /// the pre-existing differential tests only ever compared the two + /// implementations to each other, which a mistake common to both would pass. + /// /// Both operands are assumed to start at bit 0, which is all the sole /// caller ever passes. That limit is real and untested on purpose: the /// fallback mishandles a bit-offset (sliced) `left`, because it seeds the From deca1e66c938f1f0a0af7a99b397cec748ce040c Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Thu, 27 Aug 2026 00:00:54 -0700 Subject: [PATCH 12/16] docs: record the arch-dependent FSST symbol table --- README.md | 2 ++ src/datafusion-local/src/tests/mod.rs | 31 ++++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dfdc0dbe9..aa1c86a95 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ That fallback keeps the cache correct — it writes, reads and evicts exactly as So: **develop anywhere, measure on Linux.** Benchmark numbers from macOS are not comparable to production, and generally flatter LiquidCache rather than penalising it, since reads may be served from the page cache that DIRECT I/O deliberately avoids. Use a Linux machine or VM for any performance or capacity work. +Separately, and independent of the operating system: **compressed sizes differ slightly between arm64 and x86_64.** FSST picks its symbol table by draining a hash map into a priority queue, and its candidate ordering does not fully break ties, so equally-good symbols are chosen in hash-iteration order — which is not stable across architectures. The compressed output is valid and interchangeable either way, but a given column will not compress to exactly the same number of bytes on Graviton as on x86_64 (we measure ~0.4% on one test column). Compare compression ratios and capacity figures only within one architecture. + ### Use LiquidCache with DataFusion diff --git a/src/datafusion-local/src/tests/mod.rs b/src/datafusion-local/src/tests/mod.rs index 1ddea6ee0..64d1ac8b8 100644 --- a/src/datafusion-local/src/tests/mod.rs +++ b/src/datafusion-local/src/tests/mod.rs @@ -351,14 +351,29 @@ async fn test_single_column_filter_projection() { /// variable — gating on `target_os` would still fail on Graviton or on any ARM /// Linux runner. /// -/// Why the architectures differ is still unknown. Ruled out: `target_partitions` -/// (pinned to 4 below, so not the host CPU count), the temp directory path, the -/// serialized liquid encoding (`usage.disk_bytes` is identical at 35000 and the -/// entry mix — 5 memory.arrow, 1 memory.liquid, 2 disk.liquid — matches, so only -/// the in-memory footprint moves), and buffer rounding (the string-view data -/// buffers this file produces have `capacity == len`, so the accounting is exact, -/// which is what admits an odd 935-byte delta at all). `fastlanes` bit-packing is -/// the obvious next place to look. +/// The whole delta is the FSST-compressed payload — `RawFsstBuffer::values.len()`. +/// Componentwise, everything else is byte-identical across the two architectures +/// (the arrow entries, the fastlanes bit-packed dictionary keys at 17504, the +/// prefix keys, the compact offsets, the struct sizes, and the 537585 bytes of +/// uncompressed FSST input). Only the compressed output moves: 254655 on aarch64 +/// against 255590 on x86_64. +/// +/// Cause: `fsst-rs` 0.5.11 drains a hash map of symbol candidates into a +/// `BinaryHeap` (`builder.rs:796`), and `Candidate`'s ordering key is just +/// `(gain, symbol.len())` (`builder.rs:835-837`) — the symbol bytes are excluded. +/// Two distinct symbols with equal gain and equal length therefore compare +/// `Equal`, so which one wins is decided by heap insertion order, i.e. hash-map +/// iteration order, which is not stable across architectures (hashbrown selects +/// an SSE2, NEON or generic probe implementation per target). Different +/// tie-break, different 255-symbol table, different compressed length. The real +/// fix belongs upstream: make `Candidate`'s ordering total by including the +/// symbol bytes as a final tie-breaker. +/// +/// Note this means liquid-encoded bytes are NOT identical across architectures — +/// `to_bytes` writes `values` verbatim — so compression ratios and capacity +/// figures do not transfer between arm64 and x86_64. `usage.disk_bytes` staying +/// at 35000 is not evidence against that; the two disk-resident entries are +/// different, much smaller columns than the one that moves. /// /// Gated rather than redacted so the x86_64 assertion keeps full strength and the /// `cargo insta` workflow keeps working. To see the diff elsewhere: From 4944e8b217484f12e92613974a4fea349ef2a152 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Thu, 27 Aug 2026 00:03:55 -0700 Subject: [PATCH 13/16] test(local): bound memory_bytes off x86_64 instead of skipping --- src/datafusion-local/src/tests/mod.rs | 53 +++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/datafusion-local/src/tests/mod.rs b/src/datafusion-local/src/tests/mod.rs index 64d1ac8b8..a850eecbb 100644 --- a/src/datafusion-local/src/tests/mod.rs +++ b/src/datafusion-local/src/tests/mod.rs @@ -375,13 +375,9 @@ async fn test_single_column_filter_projection() { /// at 35000 is not evidence against that; the two disk-resident entries are /// different, much smaller columns than the one that moves. /// -/// Gated rather than redacted so the x86_64 assertion keeps full strength and the -/// `cargo insta` workflow keeps working. To see the diff elsewhere: -/// `cargo test -p liquid-cache-datafusion-local -- --ignored`. -#[cfg_attr( - not(target_arch = "x86_64"), - ignore = "snapshot pins exact memory_bytes; aarch64 differs by 935 bytes" -)] +/// The byte-exact snapshot therefore runs on x86_64 only, keeping its full +/// strength and the `cargo insta` workflow there. Everywhere else the test still +/// runs and bounds the same figures to within 1% — see the bottom of this test. #[tokio::test] async fn test_provide_schema2() { use std::fmt::Write as _; @@ -463,7 +459,50 @@ async fn test_provide_schema2() { } } + #[cfg(target_arch = "x86_64")] insta::assert_snapshot!(snapshot); + + // Off x86_64 the byte-exact snapshot cannot match, because FSST picks a + // different symbol table (see above). Bound the figures instead of skipping + // the test: arm64 is a production target, so it still deserves a tripwire on + // a gross accounting regression, and everything else this test covers — the + // plans, the cache hits, the tier split, the `Utf8`-declared schema over a + // `string_view` file — is architecture-independent and worth running. + #[cfg(not(target_arch = "x86_64"))] + assert_memory_bytes_within_1pct(&snapshot, &[1000915, 1000915, 1036304]); +} + +/// Checks each `usage.memory_bytes` line in `snapshot` against the value recorded +/// on x86_64, allowing 1%. +/// +/// The known architecture difference is ~0.1% (935 bytes in ~1 MiB), so 1% has an +/// order of magnitude of headroom while still catching the kind of regression that +/// matters — a buffer counted twice, or a tier accounted at the wrong size. +#[cfg(not(target_arch = "x86_64"))] +fn assert_memory_bytes_within_1pct(snapshot: &str, expected: &[u64]) { + let actual: Vec = snapshot + .lines() + .filter_map(|line| line.strip_prefix("usage.memory_bytes: ")) + .map(|value| value.trim().parse().expect("memory_bytes must be a number")) + .collect(); + + assert_eq!( + actual.len(), + expected.len(), + "expected {} memory_bytes readings, found {}: {actual:?}", + expected.len(), + actual.len() + ); + + for (idx, (&actual, &expected)) in actual.iter().zip(expected).enumerate() { + let drift = actual.abs_diff(expected); + assert!( + drift * 100 <= expected, + "query[{idx}]: memory_bytes {actual} is more than 1% from the x86_64 \ + figure {expected} (off by {drift}); the architecture difference should \ + be ~0.1%, so this is a real accounting change" + ); + } } #[tokio::test] From 0ed767d92a34bdb5ce254d13a52ba5a66d427d00 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Thu, 27 Aug 2026 00:52:29 -0700 Subject: [PATCH 14/16] chore: drop t4 from crates that no longer reference it --- Cargo.lock | 2 -- src/datafusion-local/Cargo.toml | 1 - src/datafusion-server/Cargo.toml | 1 - 3 files changed, 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20b7f2aeb..89e315fa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4312,7 +4312,6 @@ dependencies = [ "liquid-cache-common", "liquid-cache-datafusion", "parquet", - "t4", "tempfile", "tokio", ] @@ -4341,7 +4340,6 @@ dependencies = [ "prost", "serde", "sysinfo", - "t4", "tempfile", "tokio", "tonic", diff --git a/src/datafusion-local/Cargo.toml b/src/datafusion-local/Cargo.toml index d4d2836cb..f7687f16c 100644 --- a/src/datafusion-local/Cargo.toml +++ b/src/datafusion-local/Cargo.toml @@ -17,7 +17,6 @@ arrow = { workspace = true } arrow-schema = { workspace = true } tokio = { workspace = true } fastrace = { workspace = true } -t4 = { workspace = true } [dev-dependencies] diff --git a/src/datafusion-server/Cargo.toml b/src/datafusion-server/Cargo.toml index 192d1563e..20a0b3166 100644 --- a/src/datafusion-server/Cargo.toml +++ b/src/datafusion-server/Cargo.toml @@ -38,7 +38,6 @@ fastrace = { workspace = true } pprof = { version = "0.15.0", features = ["flamegraph"] } anyhow = "1.0" liquid-cache = { workspace = true } -t4 = { workspace = true } hdrhistogram = "7.5.4" [dev-dependencies] From 954284284888bf28500ebbdf60653a3e3d3e4549 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Thu, 27 Aug 2026 11:44:43 -0700 Subject: [PATCH 15/16] test(local): derive the arm64 memory bound from the snapshot --- src/datafusion-local/src/tests/mod.rs | 53 ++++++++++++++++++++------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/datafusion-local/src/tests/mod.rs b/src/datafusion-local/src/tests/mod.rs index a850eecbb..2f6f0e6e0 100644 --- a/src/datafusion-local/src/tests/mod.rs +++ b/src/datafusion-local/src/tests/mod.rs @@ -465,27 +465,39 @@ async fn test_provide_schema2() { // Off x86_64 the byte-exact snapshot cannot match, because FSST picks a // different symbol table (see above). Bound the figures instead of skipping // the test: arm64 is a production target, so it still deserves a tripwire on - // a gross accounting regression, and everything else this test covers — the - // plans, the cache hits, the tier split, the `Utf8`-declared schema over a - // `string_view` file — is architecture-independent and worth running. + // a gross accounting regression. The plan text is only checked byte-for-byte + // on x86_64, but what this test covers besides it — the DataFusion-vs-liquid + // column equality, the cache hits, the tier split, the `Utf8`-declared schema + // over a `string_view` file — is architecture-independent and worth running. #[cfg(not(target_arch = "x86_64"))] - assert_memory_bytes_within_1pct(&snapshot, &[1000915, 1000915, 1036304]); + assert_memory_bytes_within_1pct( + &snapshot, + include_str!("snapshots/liquid_cache_datafusion_local__tests__provide_schema2.snap"), + ); } -/// Checks each `usage.memory_bytes` line in `snapshot` against the value recorded -/// on x86_64, allowing 1%. +/// Checks each `usage.memory_bytes` line in `snapshot` against the figure the +/// committed x86_64 snapshot records for the same query, allowing 1%. +/// +/// `recorded` is the `.snap` file itself rather than a hand-copied array, so +/// regenerating the snapshot on x86_64 with `cargo insta accept` cannot leave +/// this assertion silently checking stale numbers. /// /// The known architecture difference is ~0.1% (935 bytes in ~1 MiB), so 1% has an /// order of magnitude of headroom while still catching the kind of regression that /// matters — a buffer counted twice, or a tier accounted at the wrong size. #[cfg(not(target_arch = "x86_64"))] -fn assert_memory_bytes_within_1pct(snapshot: &str, expected: &[u64]) { - let actual: Vec = snapshot - .lines() - .filter_map(|line| line.strip_prefix("usage.memory_bytes: ")) - .map(|value| value.trim().parse().expect("memory_bytes must be a number")) - .collect(); - +fn assert_memory_bytes_within_1pct(snapshot: &str, recorded: &str) { + let actual = memory_bytes(snapshot); + let expected = memory_bytes(recorded); + + // Both sides are parsed with the same predicate, so a changed prefix would + // empty both and make the length check below pass on nothing. + assert!( + !expected.is_empty(), + "found no `usage.memory_bytes` readings in the committed snapshot; the \ + stats format has changed and this assertion is no longer reading anything" + ); assert_eq!( actual.len(), expected.len(), @@ -494,7 +506,7 @@ fn assert_memory_bytes_within_1pct(snapshot: &str, expected: &[u64]) { actual.len() ); - for (idx, (&actual, &expected)) in actual.iter().zip(expected).enumerate() { + for (idx, (&actual, &expected)) in actual.iter().zip(&expected).enumerate() { let drift = actual.abs_diff(expected); assert!( drift * 100 <= expected, @@ -505,6 +517,19 @@ fn assert_memory_bytes_within_1pct(snapshot: &str, expected: &[u64]) { } } +/// Pulls every `usage.memory_bytes` figure out of a stats snapshot, in order. +/// +/// Works on both the live snapshot and a committed `.snap` file: insta writes the +/// snapshot body unindented after its YAML header, so the same prefix matches. +#[cfg(not(target_arch = "x86_64"))] +fn memory_bytes(snapshot: &str) -> Vec { + snapshot + .lines() + .filter_map(|line| line.strip_prefix("usage.memory_bytes: ")) + .map(|value| value.trim().parse().expect("memory_bytes must be a number")) + .collect() +} + #[tokio::test] async fn test_provide_schema_with_filter() { let cache_dir = TempDir::new().unwrap(); From d901a325616cdf66cb0f000e667d1d777e80ba80 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Thu, 27 Aug 2026 11:44:48 -0700 Subject: [PATCH 16/16] chore(lint): forbid direct t4 mounts outside the store policy --- clippy.toml | 4 ++++ src/core/src/store.rs | 4 ++++ src/datafusion/clippy.toml | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 clippy.toml diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..ac2fc9760 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +disallowed-methods = [ + { path = "t4::mount", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" }, + { path = "t4::mount_with_options", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" }, +] diff --git a/src/core/src/store.rs b/src/core/src/store.rs index 6d9e03ca2..ae7c2be6d 100644 --- a/src/core/src/store.rs +++ b/src/core/src/store.rs @@ -25,6 +25,10 @@ use std::path::Path; /// On Linux this is exactly [`t4::mount`] — `direct_io` and `dsync` both come /// out `true`, matching [`t4::MountOptions::default`]. See the /// [module docs](self) for what the buffered fallback costs elsewhere. +// The one place allowed to mount through `t4` directly — `clippy.toml` sends +// every other call site here, so this is where the `disallowed_methods` rule has +// to stop. +#[allow(clippy::disallowed_methods)] pub async fn mount(path: impl AsRef) -> t4::Result { #[cfg(not(target_os = "linux"))] warn_buffered_once(); diff --git a/src/datafusion/clippy.toml b/src/datafusion/clippy.toml index 7eabfd21f..98bd9571f 100644 --- a/src/datafusion/clippy.toml +++ b/src/datafusion/clippy.toml @@ -1,4 +1,9 @@ -disallowed-methods = [] +# Kept in sync with the workspace-root `clippy.toml`: clippy reads exactly one +# config file per crate, so a crate with its own gets no inheritance. +disallowed-methods = [ + { path = "t4::mount", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" }, + { path = "t4::mount_with_options", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" }, +] disallowed-types = [ { path = "dashmap::DashMap", reason = "DashMap can easily lead to deadlocks, use RwLock with shuttle tests instead" },