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
22 changes: 18 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -52,4 +67,3 @@ jobs:
run: rustup toolchain install stable --profile minimal
- run: rustup override set stable
- run: cargo publish --dry-run

17 changes: 15 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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`.

9 changes: 4 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
name = "range-cache"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
rust-version = "1.86"
authors = ["HelixDB, Inc. <hello@helix-db.com>"]
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"]
Expand Down Expand Up @@ -45,4 +45,3 @@ rustdoc-args = ["--cfg", "docsrs"]
[[bench]]
name = "range_cache"
harness = false

146 changes: 128 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<K>` for an immutable source, then wrap it in
Expand All @@ -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<dyn std::error::Error>> {
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<Vec<Range<usize>>>,
}

#[async_trait::async_trait]
impl RangeReader<String> for ObjectStore {
type Error = Infallible;

async fn read_range(
&self,
_key: &String,
range: Range<usize>,
) -> Result<Bytes, Self::Error> {
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,
Expand All @@ -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.

Loading