From 4e9e7baa61ede11bf70b0d246954f3187c1328d8 Mon Sep 17 00:00:00 2001 From: xav-db Date: Fri, 24 Jul 2026 10:06:34 +0100 Subject: [PATCH 1/3] Expand range cache benchmarks --- Cargo.toml | 3 +- benches/range_cache.rs | 562 ++++++++++++++++++++++++++++++++--------- 2 files changed, 437 insertions(+), 128 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d59b4f8..d166c7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "range-cache" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.86" authors = ["HelixDB, Inc. "] description = "A thread-safe sparse byte-range cache with optional async read-through" license = "Apache-2.0" @@ -45,4 +45,3 @@ rustdoc-args = ["--cfg", "docsrs"] [[bench]] name = "range_cache" harness = false - diff --git a/benches/range_cache.rs b/benches/range_cache.rs index 0e92763..cf5ab93 100644 --- a/benches/range_cache.rs +++ b/benches/range_cache.rs @@ -1,5 +1,13 @@ +use std::{ + hint::black_box, + num::NonZeroUsize, + sync::{Arc, Barrier}, + thread, + time::{Duration, Instant}, +}; + use bytes::Bytes; -use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use range_cache::{CacheCapacity, RangeCache}; fn full_hits(criterion: &mut Criterion) { @@ -9,116 +17,323 @@ fn full_hits(criterion: &mut Criterion) { cache .insert(0_u8, 0..size, Bytes::from(vec![1; size])) .expect("benchmark insert"); - group.throughput(Throughput::Bytes( - u64::try_from(size / 2).expect("benchmark size fits u64"), - )); - group.bench_function(size.to_string(), |bencher| { - bencher.iter(|| { - black_box( - cache - .get(&0, size / 4..size * 3 / 4) - .expect("benchmark range"), - ) - }); - }); + group.bench_with_input( + BenchmarkId::new("cached_bytes", size), + &size, + |bencher, &size| { + bencher.iter(|| { + black_box( + cache + .get(&0, size / 4..size * 3 / 4) + .expect("benchmark range"), + ) + }); + }, + ); + } + group.finish(); +} + +fn misses(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("miss"); + for ranges in [8, 64, 512] { + let cache = fragmented_cache(ranges); + let start = ranges * 32; + group.bench_with_input( + BenchmarkId::new("resident_ranges", ranges), + &start, + |bencher, &start| { + bencher + .iter(|| black_box(cache.get(&0, start..start + 16).expect("benchmark range"))); + }, + ); } group.finish(); } fn gap_calculation(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("gap_calculation"); - for segments in [8, 64, 512] { - let cache = RangeCache::new(CacheCapacity::Unbounded); - for segment in 0..segments { - let start = segment * 32; - cache - .insert(0_u8, start..start + 16, Bytes::from_static(&[1; 16])) - .expect("benchmark insert"); - } - let end = segments * 32; - group.bench_function(segments.to_string(), |bencher| { - bencher.iter(|| black_box(cache.missing_ranges(&0, 0..end).expect("benchmark range"))); - }); + for ranges in [8, 64, 512] { + let cache = fragmented_cache(ranges); + let end = ranges * 32; + group.bench_with_input( + BenchmarkId::new("resident_ranges", ranges), + &end, + |bencher, &end| { + bencher + .iter(|| black_box(cache.missing_ranges(&0, 0..end).expect("benchmark range"))); + }, + ); } group.finish(); } -fn overlapping_insertion(criterion: &mut Criterion) { - let mut group = criterion.benchmark_group("overlapping_insertion"); - for segments in [8, 64, 512] { - let end = segments * 32; - group.throughput(Throughput::Bytes( - u64::try_from(end - 16).expect("benchmark size fits u64"), - )); - group.bench_function(segments.to_string(), |bencher| { - bencher.iter_batched( +fn insertion(criterion: &mut Criterion) { + let mut cold_group = criterion.benchmark_group("cold_insertion"); + for size in [16, 4_096, 65_536] { + cold_group.bench_with_input(BenchmarkId::new("bytes", size), &size, |bencher, &size| { + bencher.iter_batched_ref( || { - let cache = RangeCache::new(CacheCapacity::Unbounded); - for segment in 0..segments { - let start = segment * 32; - cache - .insert(0_u8, start..start + 16, Bytes::from_static(&[1; 16])) - .expect("benchmark insert"); - } - cache + ( + RangeCache::new(CacheCapacity::Unbounded), + Some(Bytes::from(vec![1; size])), + ) }, - |cache| { + |(cache, payload)| { + let Some(payload) = payload.take() else { + panic!("benchmark payload is available"); + }; black_box( cache - .insert(0_u8, 8..end - 8, Bytes::from(vec![2; end - 16])) - .expect("benchmark overlap"), + .insert(0_u8, 0..size, payload) + .expect("benchmark insert"), ) }, BatchSize::SmallInput, ); }); } - group.finish(); + cold_group.finish(); + + let mut contained_group = criterion.benchmark_group("contained_insertion"); + for size in [16, 1_024, 4_096] { + contained_group.bench_with_input( + BenchmarkId::new("bytes", size), + &size, + |bencher, &size| { + bencher.iter_batched_ref( + || { + let cache = RangeCache::new(CacheCapacity::Unbounded); + cache + .insert(0_u8, 0..4_096, Bytes::from(vec![1; 4_096])) + .expect("benchmark insert"); + (cache, Some(Bytes::from(vec![2; size]))) + }, + |(cache, payload)| { + let Some(payload) = payload.take() else { + panic!("benchmark payload is available"); + }; + black_box( + cache + .insert(0_u8, 0..size, payload) + .expect("benchmark contained insert"), + ) + }, + BatchSize::SmallInput, + ); + }, + ); + } + contained_group.finish(); + + let mut overlap_group = criterion.benchmark_group("overlapping_insertion"); + for ranges in [8, 64, 512] { + let end = ranges * 32; + overlap_group.throughput(Throughput::Bytes( + u64::try_from(end - 16).expect("benchmark size fits u64"), + )); + overlap_group.bench_with_input( + BenchmarkId::new("resident_ranges", ranges), + &end, + |bencher, &end| { + bencher.iter_batched_ref( + || { + ( + fragmented_cache(ranges), + Some(Bytes::from(vec![2; end - 16])), + ) + }, + |(cache, payload)| { + let Some(payload) = payload.take() else { + panic!("benchmark payload is available"); + }; + black_box( + cache + .insert(0_u8, 8..end - 8, payload) + .expect("benchmark overlap"), + ) + }, + BatchSize::SmallInput, + ); + }, + ); + } + overlap_group.finish(); } fn eviction(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("eviction"); - for segments in [8, 64, 512] { - group.bench_function(segments.to_string(), |bencher| { - bencher.iter_batched( - || { - let cache = RangeCache::new(CacheCapacity::Bounded( - std::num::NonZeroUsize::new(segments * 16) - .expect("benchmark capacity is non-zero"), - )); - for key in 0..segments { - cache - .insert(key, 0..16, Bytes::from_static(&[1; 16])) - .expect("benchmark insert"); - } - cache - }, - |cache| { - black_box( + for ranges in [8, 64, 512] { + group.bench_with_input( + BenchmarkId::new("resident_ranges", ranges), + &ranges, + |bencher, &ranges| { + bencher.iter_batched_ref( + || { + let cache = RangeCache::new(CacheCapacity::Bounded( + NonZeroUsize::new(ranges * 16).expect("benchmark capacity is non-zero"), + )); + for key in 0..ranges { + cache + .insert(key, 0..16, Bytes::from_static(&[1; 16])) + .expect("benchmark insert"); + } cache - .insert(segments, 0..16, Bytes::from_static(&[2; 16])) - .expect("benchmark eviction"), - ) - }, - BatchSize::SmallInput, - ); - }); + }, + |cache| { + black_box( + cache + .insert(ranges, 0..16, Bytes::from_static(&[2; 16])) + .expect("benchmark eviction"), + ) + }, + BatchSize::SmallInput, + ); + }, + ); } group.finish(); } +fn concurrent_hits(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("concurrent_hit"); + group.throughput(Throughput::Elements(1)); + for workers in [1, 2, 4, 8] { + let cache = RangeCache::new(CacheCapacity::Unbounded); + for key in 0..workers { + cache + .insert(key, 0..4_096, Bytes::from(vec![1; 4_096])) + .expect("benchmark insert"); + } + + group.bench_with_input( + BenchmarkId::new("shared_key_workers", workers), + &workers, + |bencher, &workers| { + bencher.iter_custom(|iterations| { + concurrent_hit_duration(&cache, workers, iterations, true) + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("independent_key_workers", workers), + &workers, + |bencher, &workers| { + bencher.iter_custom(|iterations| { + concurrent_hit_duration(&cache, workers, iterations, false) + }); + }, + ); + } + group.finish(); +} + +fn fragmented_cache(ranges: usize) -> RangeCache { + let cache = RangeCache::new(CacheCapacity::Unbounded); + for range in 0..ranges { + let start = range * 32; + cache + .insert(0, start..start + 16, Bytes::from_static(&[1; 16])) + .expect("benchmark insert"); + } + cache +} + +fn concurrent_hit_duration( + cache: &RangeCache, + workers: usize, + iterations: u64, + shared_key: bool, +) -> Duration { + let ready = Arc::new(Barrier::new(workers + 1)); + let start = Arc::new(Barrier::new(workers + 1)); + let done = Arc::new(Barrier::new(workers + 1)); + let workers_u64 = u64::try_from(workers).expect("worker count fits u64"); + + thread::scope(|scope| { + for worker in 0..workers { + let ready = Arc::clone(&ready); + let start = Arc::clone(&start); + let done = Arc::clone(&done); + let worker_index = u64::try_from(worker).expect("worker index fits u64"); + let worker_iterations = + iterations / workers_u64 + u64::from(worker_index < iterations % workers_u64); + scope.spawn(move || { + ready.wait(); + start.wait(); + let key = if shared_key { 0 } else { worker }; + for _ in 0..worker_iterations { + black_box( + cache + .get(&key, 1_024..2_048) + .expect("benchmark range") + .expect("benchmark hit"), + ); + } + done.wait(); + }); + } + + ready.wait(); + let started = Instant::now(); + start.wait(); + done.wait(); + started.elapsed() + }) +} + #[cfg(feature = "async")] mod asynchronous { - use std::{convert::Infallible, num::NonZeroUsize, ops::Range, sync::Arc}; + use std::{ + convert::Infallible, + hint::black_box, + num::NonZeroUsize, + ops::Range, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; use async_trait::async_trait; use bytes::Bytes; - use criterion::{BatchSize, Criterion, black_box}; + use criterion::{BatchSize, BenchmarkId, Criterion}; use futures_util::future::join_all; use range_cache::{CacheCapacity, CachedReader, RangeCache, RangeReader, ReaderConfig}; struct Source { data: Bytes, + yield_before_response: bool, + calls: AtomicUsize, + fetched_bytes: AtomicUsize, + } + + impl Source { + fn immediate(data: Bytes) -> Self { + Self { + data, + yield_before_response: false, + calls: AtomicUsize::new(0), + fetched_bytes: AtomicUsize::new(0), + } + } + + fn yielding(data: Bytes) -> Self { + Self { + data, + yield_before_response: true, + calls: AtomicUsize::new(0), + fetched_bytes: AtomicUsize::new(0), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::Relaxed) + } + + fn fetched_bytes(&self) -> usize { + self.fetched_bytes.load(Ordering::Relaxed) + } } #[async_trait] @@ -130,7 +345,11 @@ mod asynchronous { _key: &usize, range: Range, ) -> Result { - tokio::task::yield_now().await; + self.calls.fetch_add(1, Ordering::Relaxed); + self.fetched_bytes.fetch_add(range.len(), Ordering::Relaxed); + if self.yield_before_response { + tokio::task::yield_now().await; + } Ok(self.data.slice(range)) } } @@ -141,45 +360,116 @@ mod asynchronous { .expect("benchmark runtime") } + fn reader( + source: Arc, + cache: RangeCache, + concurrency: usize, + ) -> CachedReader { + CachedReader::new( + source, + cache, + ReaderConfig::new( + NonZeroUsize::new(concurrency).expect("benchmark concurrency is non-zero"), + ), + ) + } + pub(super) fn benchmarks(criterion: &mut Criterion) { + direct_cold_and_warm_reads(criterion); fragmented_reconstruction(criterion); coalesced_concurrent_reads(criterion); } + fn direct_cold_and_warm_reads(criterion: &mut Criterion) { + let runtime = runtime(); + let data = Bytes::from(vec![1; 4_096]); + let direct_source = Source::immediate(data.clone()); + let warm_source = Arc::new(Source::immediate(data.clone())); + let warm_cache = RangeCache::new(CacheCapacity::Unbounded); + warm_cache + .insert(0, 0..4_096, data.clone()) + .expect("benchmark insert"); + let warm_reader = reader(Arc::clone(&warm_source), warm_cache, 1); + + let mut group = criterion.benchmark_group("read_through"); + group.bench_function("direct_4096_bytes", |bencher| { + bencher.iter(|| { + black_box( + runtime + .block_on(direct_source.read_range(&0, 0..4_096)) + .expect("benchmark direct read"), + ) + }); + }); + group.bench_function("cold_4096_bytes", |bencher| { + bencher.iter_batched_ref( + || { + reader( + Arc::new(Source::immediate(data.clone())), + RangeCache::new(CacheCapacity::Unbounded), + 1, + ) + }, + |reader| { + black_box( + runtime + .block_on(reader.read(&0, 0..4_096)) + .expect("benchmark cold read"), + ) + }, + BatchSize::SmallInput, + ); + }); + group.bench_function("warm_4096_bytes", |bencher| { + bencher.iter(|| { + black_box( + runtime + .block_on(warm_reader.read(&0, 0..4_096)) + .expect("benchmark warm read"), + ) + }); + }); + group.finish(); + + assert_eq!( + warm_source.calls(), + 0, + "warm benchmark must not access the source" + ); + } + fn fragmented_reconstruction(criterion: &mut Criterion) { let runtime = runtime(); let mut group = criterion.benchmark_group("fragmented_reconstruction"); - for segments in [8, 64, 256] { - let length = segments * 64; - group.bench_function(segments.to_string(), |bencher| { - bencher.iter_batched( - || { - let data = Bytes::from(vec![1; length]); - let cache = RangeCache::new(CacheCapacity::Unbounded); - for segment in (0..segments).step_by(2) { - let start = segment * 64; - cache - .insert(0, start..start + 64, data.slice(start..start + 64)) - .expect("benchmark insert"); - } - CachedReader::new( - Arc::new(Source { data }), - cache, - ReaderConfig::new( - NonZeroUsize::new(16).expect("benchmark concurrency is non-zero"), - ), - ) - }, - |reader| { - black_box( - runtime - .block_on(reader.read(&0, 0..length)) - .expect("benchmark read"), - ) - }, - BatchSize::SmallInput, - ); - }); + for ranges in [8, 64, 256] { + let length = ranges * 64; + group.bench_with_input( + BenchmarkId::new("segments", ranges), + &length, + |bencher, &length| { + bencher.iter_batched_ref( + || { + let data = Bytes::from(vec![1; length]); + let cache = RangeCache::new(CacheCapacity::Unbounded); + for segment in (0..ranges).step_by(2) { + let start = segment * 64; + cache + .insert(0, start..start + 64, data.slice(start..start + 64)) + .expect("benchmark insert"); + } + reader(Arc::new(Source::immediate(data)), cache, 16) + }, + |reader| { + black_box( + runtime + .block_on(reader.read(&0, 0..length)) + .expect("benchmark read"), + ) + }, + BatchSize::SmallInput, + ); + }, + ); } group.finish(); } @@ -188,30 +478,48 @@ mod asynchronous { let runtime = runtime(); let mut group = criterion.benchmark_group("coalesced_concurrent_reads"); for waiters in [2, 8, 32] { - group.bench_function(waiters.to_string(), |bencher| { - bencher.iter_batched( - || { - CachedReader::new( - Arc::new(Source { - data: Bytes::from(vec![1; 4_096]), - }), - RangeCache::new(CacheCapacity::Unbounded), - ReaderConfig::new( - NonZeroUsize::new(waiters) - .expect("benchmark concurrency is non-zero"), - ), - ) - }, - |reader| { - let reads = (0..waiters).map(|_| reader.read(&0, 0..4_096)); - black_box(runtime.block_on(join_all(reads))) - }, - BatchSize::SmallInput, - ); - }); + assert_coalescing_metrics(&runtime, waiters); + group.bench_with_input( + BenchmarkId::new("waiters", waiters), + &waiters, + |bencher, &waiters| { + bencher.iter_batched_ref( + || { + reader( + Arc::new(Source::yielding(Bytes::from(vec![1; 4_096]))), + RangeCache::new(CacheCapacity::Unbounded), + waiters, + ) + }, + |reader| { + let reads = (0..waiters).map(|_| reader.read(&0, 0..4_096)); + black_box(runtime.block_on(join_all(reads))) + }, + BatchSize::SmallInput, + ); + }, + ); } group.finish(); } + + fn assert_coalescing_metrics(runtime: &tokio::runtime::Runtime, waiters: usize) { + let source = Arc::new(Source::yielding(Bytes::from(vec![1; 4_096]))); + let reader = reader( + Arc::clone(&source), + RangeCache::new(CacheCapacity::Unbounded), + waiters, + ); + let reads = (0..waiters).map(|_| reader.read(&0, 0..4_096)); + let results = runtime.block_on(join_all(reads)); + assert!(results.into_iter().all(|result| result.is_ok())); + assert_eq!(source.calls(), 1, "identical reads share one source call"); + assert_eq!( + source.fetched_bytes(), + 4_096, + "identical reads fetch one payload" + ); + } } #[cfg(feature = "async")] @@ -225,9 +533,11 @@ fn async_benchmarks(_criterion: &mut Criterion) {} criterion_group!( benches, full_hits, + misses, gap_calculation, - overlapping_insertion, + insertion, eviction, + concurrent_hits, async_benchmarks ); criterion_main!(benches); From 5f62ed34b3644241b4d8bf65b02239ec665097bf Mon Sep 17 00:00:00 2001 From: xav-db Date: Fri, 24 Jul 2026 10:29:08 +0100 Subject: [PATCH 2/3] Clarify range-cache positioning --- CHANGELOG.md | 17 +++++- CONTRIBUTING.md | 5 +- Cargo.toml | 6 +- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 149 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed2a7c..87b35c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Expanded Criterion microbenchmarks for cache operations, contention, and + async read-through behavior. +- Complete core and async README examples plus documented benchmark results. + +### Changed + +- Raised the minimum supported Rust version from 1.85 to 1.86. +- Updated canonical repository links after the project ownership transfer. + ## [0.1.0] - 2026-07-21 ### Added @@ -17,5 +30,5 @@ project follows [Semantic Versioning](https://semver.org/). - Reference-model property tests, Criterion benchmarks, cross-platform CI, and coverage enforcement. -[0.1.0]: https://github.com/HelixDB/range-cache/releases/tag/v0.1.0 - +[Unreleased]: https://github.com/xav-db/range-cache/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/xav-db/range-cache/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ba9fc5..a6cb759 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,10 +5,12 @@ and run the same checks as CI: ```bash cargo fmt --all -- --check -cargo test --no-default-features +cargo test --lib --tests --no-default-features cargo test --all-features cargo clippy --all-targets --all-features -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo test --doc --no-default-features RUSTDOCFLAGS="-D warnings" cargo test --doc --all-features +cargo bench --all-features --bench range_cache -- --test cargo publish --dry-run ``` @@ -20,4 +22,3 @@ cargo llvm-cov --all-features --workspace --fail-under-lines 95 Use a current stable toolchain for development. Changes must remain compatible with the package MSRV declared in `Cargo.toml`. - diff --git a/Cargo.toml b/Cargo.toml index d166c7d..08babcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,10 @@ version = "0.1.0" edition = "2024" rust-version = "1.86" authors = ["HelixDB, Inc. "] -description = "A thread-safe sparse byte-range cache with optional async read-through" +description = "Sparse byte-range caching and async read coalescing for immutable objects such as remote index files" license = "Apache-2.0" -repository = "https://github.com/HelixDB/range-cache" -homepage = "https://github.com/HelixDB/range-cache" +repository = "https://github.com/xav-db/range-cache" +homepage = "https://github.com/xav-db/range-cache" documentation = "https://docs.rs/range-cache" readme = "README.md" keywords = ["cache", "range", "bytes", "async", "storage"] diff --git a/README.md b/README.md index 60b5a2f..bfc55ec 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ It stores `bytes::Bytes` under ordered keys, merges adjacent or overlapping coverage, and can enforce a payload-byte ceiling with range-level LRU eviction. +Use it when a query engine repeatedly reads small, overlapping regions of the +same immutable object—for example, index blocks stored in S3. Instead of +downloading or caching the whole object, `range-cache` retains only fetched +ranges, serves later overlaps from memory, and coalesces identical concurrent +misses into one source read. + The core is synchronous and runtime-independent. The optional `async` feature adds an object-safe source trait and a read-through adapter that fetches only missing gaps. @@ -14,22 +20,27 @@ missing gaps. use std::num::NonZeroUsize; use bytes::Bytes; -use range_cache::{CacheCapacity, InsertOutcome, RangeCache}; - -let cache = RangeCache::new(CacheCapacity::Bounded( - NonZeroUsize::new(1024).expect("capacity is non-zero"), -)); - -assert_eq!( - cache.insert("object", 4..8, Bytes::from_static(b"data"))?, - InsertOutcome::Inserted, -); -assert_eq!( - cache.get(&"object", 5..7)?, - Some(Bytes::from_static(b"at")), -); -assert_eq!(cache.missing_ranges(&"object", 2..10)?, vec![2..4, 8..10]); -# Ok::<(), range_cache::RangeError>(()) +use range_cache::{CacheCapacity, InsertOutcome, RangeCache, RangeError}; + +fn main() -> Result<(), RangeError> { + let cache = RangeCache::new(CacheCapacity::Bounded( + NonZeroUsize::new(1024).expect("capacity is non-zero"), + )); + + assert_eq!( + cache.insert("object", 4..8, Bytes::from_static(b"data"))?, + InsertOutcome::Inserted, + ); + assert_eq!( + cache.get(&"object", 5..7)?, + Some(Bytes::from_static(b"at")), + ); + assert_eq!( + cache.missing_ranges(&"object", 2..10)?, + vec![2..4, 8..10], + ); + Ok(()) +} ``` Capacity must always be explicit. `CacheCapacity::Bounded` uses a non-zero @@ -58,7 +69,10 @@ Enable the optional layer with: ```toml [dependencies] +async-trait = "0.1" +bytes = "1" range-cache = { version = "0.1", features = ["async"] } +tokio = { version = "1", features = ["macros", "rt"] } ``` Implement `RangeReader` for an immutable source, then wrap it in @@ -70,6 +84,103 @@ that are not identical remain independent. The async layer uses Tokio synchronization primitives but does not spawn tasks or require a particular executor for `CachedReader::read`. +```rust +#[cfg(feature = "async")] +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + use std::{ + convert::Infallible, + num::NonZeroUsize, + ops::Range, + sync::{Arc, Mutex}, + }; + + use bytes::Bytes; + use range_cache::{CacheCapacity, CachedReader, RangeCache, RangeReader, ReaderConfig}; + + struct ObjectStore { + data: Bytes, + fetched: Mutex>>, + } + + #[async_trait::async_trait] + impl RangeReader for ObjectStore { + type Error = Infallible; + + async fn read_range( + &self, + _key: &String, + range: Range, + ) -> Result { + self.fetched + .lock() + .expect("fetch log lock is not poisoned") + .push(range.clone()); + Ok(self.data.slice(range)) + } + } + + let source = Arc::new(ObjectStore { + data: Bytes::from_static(b"abcdefghijklmnop"), + fetched: Mutex::new(Vec::new()), + }); + let reader = CachedReader::new( + Arc::clone(&source), + RangeCache::new(CacheCapacity::Bounded( + NonZeroUsize::new(1024).expect("capacity is non-zero"), + )), + ReaderConfig::new(NonZeroUsize::new(4).expect("concurrency is non-zero")), + ); + let key = String::from("s3://bucket/index"); + + assert_eq!( + reader.read(&key, 0..8).await?, + Bytes::from_static(b"abcdefgh"), + ); + assert_eq!( + reader.read(&key, 4..12).await?, + Bytes::from_static(b"efghijkl"), + ); + assert_eq!( + *source + .fetched + .lock() + .expect("fetch log lock is not poisoned"), + vec![0..8, 8..12], + ); + Ok(()) +} + +#[cfg(not(feature = "async"))] +fn main() {} +``` + +## Microbenchmarks + +The table below reports the median of three Criterion median point estimates. +Setup and fixture destruction are excluded from measured times. Full-hit +latency is reported instead of apparent byte throughput because the returned +`Bytes` value is a zero-copy slice. + +| Operation | Workload | Median estimate | +| --- | --- | ---: | +| Full hit | 32 KiB requested from a 64 KiB cached range | 19.89 ns | +| Gap calculation | 64 resident ranges | 498.41 ns | +| Overlapping insertion | Merge across 64 resident ranges | 3.16 µs | +| Eviction | Insert with 64 resident ranges at capacity | 288.34 ns | +| Concurrent hit | 8 workers sharing one key | 73.21 ns/read | +| Warm read-through | 4 KiB cached read | 85.80 ns | +| Fragmented reconstruction | 64 alternating cached/missing segments | 18.00 µs | +| Coalesced read-through | 32 identical concurrent readers | 11.17 µs | + +The coalesced 32-reader case performs one 4 KiB source read; issuing those reads +directly would perform 32 calls and fetch 128 KiB. + +Measured with `cargo bench --all-features --bench range_cache -- --noplot` on +commit `4e9e7baa61ede11bf70b0d246954f3187c1328d8` using `rustc 1.97.1` on macOS +26.5, an Apple M4 Pro (14 cores), and 24 GiB of memory. These numbers describe +that machine and revision; they are not cross-platform performance guarantees. + ## Observability `RangeCache::snapshot` returns a consistent view of capacity, resident bytes, @@ -78,11 +189,10 @@ admission rejections, and evictions. Statistics are retained across `clear`. ## Compatibility -- Rust 1.85 or newer +- Rust 1.86 or newer - Rust edition 2024 - Default features: none - License: Apache-2.0 See [CHANGELOG.md](CHANGELOG.md) for release notes and [CONTRIBUTING.md](CONTRIBUTING.md) for development commands. - From 8a5c559f3bf237cc278626387ad3d1e2d7ab6ecc Mon Sep 17 00:00:00 2001 From: xav-db Date: Fri, 24 Jul 2026 10:29:41 +0100 Subject: [PATCH 3/3] Smoke-test benchmarks in CI --- .github/workflows/ci.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68c5fc..9d48fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - toolchain: [stable, 1.85.0] + toolchain: [stable, 1.86.0] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -24,14 +24,29 @@ jobs: rustup toolchain install ${{ matrix.toolchain }} --profile minimal --component clippy,rustfmt rustup override set ${{ matrix.toolchain }} - run: cargo fmt --all -- --check - - run: cargo test --no-default-features + - run: cargo test --lib --tests --no-default-features - run: cargo test --all-features - run: cargo clippy --all-targets --all-features -- -D warnings - - name: Rustdoc and doctests + - name: Rustdoc and doctests (no default features) + env: + RUSTDOCFLAGS: -D warnings + run: cargo test --doc --no-default-features + - name: Rustdoc and doctests (all features) env: RUSTDOCFLAGS: -D warnings run: cargo test --doc --all-features + benchmark-smoke: + name: Benchmark smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup override set stable + - run: cargo bench --all-features --bench range_cache -- --test + coverage: name: Coverage runs-on: ubuntu-latest @@ -52,4 +67,3 @@ jobs: run: rustup toolchain install stable --profile minimal - run: rustup override set stable - run: cargo publish --dry-run -