# iqdb-cache v0.2.0 — `CachedIndex` + LRU result cache
**The cache lands.** v0.2.0 turns the scaffold into a working caching layer: a drop-in `IndexCore` wrapper that memoizes search results in a bounded LRU cache and invalidates exactly when the index changes. Caching stays what it should be — a transparent, opt-in optimization that never alters *what* a search returns, only how fast a repeat returns.
## What is iqdb-cache?
An in-process caching layer between the database and an index. For large indexes that do not fit in RAM, a well-tuned cache turns a repeated query into a memory read instead of a fresh scan. It wraps any `iqdb_index::IndexCore` as a `CachedIndex` — itself an `IndexCore` — so it slots in anywhere the wrapped index does, and a database is correct with no cache at all.
## What's new in 0.2.0
### The `CachedIndex` wrapper
`CachedIndex<I>` holds any `I: IndexCore` and forwards every call to it. The one addition: an identical `search` — same query and same `SearchParams` — is served from an in-memory cache instead of re-running. Because the wrapper is itself an `IndexCore`, it composes with everything downstream, including `Box<dyn IndexCore>`.
```rust
use iqdb_cache::CachedIndex;
use iqdb_index::IndexCore;
use iqdb_types::{DistanceMetric, SearchParams};
let cached = CachedIndex::new(iqdb_cache::doc_stub::stub_index());
let params = SearchParams::new(3, DistanceMetric::Cosine);
let cold = cached.search(&[1.0, 0.0, 0.0], ¶ms).expect("search"); // miss
let warm = cached.search(&[1.0, 0.0, 0.0], ¶ms).expect("search"); // hit
assert_eq!(cold, warm);
assert_eq!(cached.cache_stats().hits, 1);
```
### A bounded, allocation-lean LRU
Under the wrapper is a hand-built least-recently-used cache: a flat arena of slots threaded by an intrusive doubly-linked list, paired with a hash map for `O(1)` lookup. Lookups, inserts, and evictions are all amortized `O(1)`; a full cache evicts the least-recently-used entry in place, so the steady state performs no per-insert allocation. The footprint never exceeds the configured capacity, and the whole crate is `#![forbid(unsafe_code)]`.
### Mutation-exact invalidation — never a stale result
The defining invariant: a search after a write never sees the old answer. `insert` and `delete` invalidate the cache when they succeed (a failed duplicate insert changes nothing, so the cache is kept); `insert_batch`, which is fail-fast and may apply partially, always invalidates; `flush` does not (it commits durable state without changing the searchable set).
```rust
use std::sync::Arc;
use iqdb_cache::CachedIndex;
use iqdb_index::IndexCore;
use iqdb_types::{DistanceMetric, SearchParams, VectorId};
let mut cached = CachedIndex::new(iqdb_cache::doc_stub::stub_index());
let params = SearchParams::new(10, DistanceMetric::Cosine);
let before = cached.search(&[0.0, 0.0, 0.0], ¶ms).expect("search");
cached
.insert(VectorId::from(7u64), Arc::from(&[0.0, 0.0, 0.0][..]), None)
.expect("insert");
let after = cached.search(&[0.0, 0.0, 0.0], ¶ms).expect("search");
assert_eq!(after.len(), before.len() + 1); // the new vector is visible at once
```
### Off by default, sized when you want it
Construct a cache with `new` (a 1024-entry default) or `with_capacity`. A capacity of `0` disables caching entirely — every search passes straight through and nothing is stored — which is the honest way to A/B the cache's effect without touching call sites.
```rust
use iqdb_cache::CachedIndex;
let sized = CachedIndex::with_capacity(iqdb_cache::doc_stub::stub_index(), 4096);
assert_eq!(sized.capacity(), 4096);
let bypass = CachedIndex::with_capacity(iqdb_cache::doc_stub::stub_index(), 0);
assert!(!bypass.is_enabled());
```
### Hit/miss statistics
`cache_stats` returns a `CacheStats` snapshot — lifetime `hits` and `misses`, current `len`, and `capacity` — with `lookups` and a `hit_rate` for tuning. Optional `serde` derives (the `serde` feature) let the metrics flow to logs or telemetry.
### `VERSION`
`iqdb_cache::VERSION` exposes the crate's compile-time `CARGO_PKG_VERSION` for diagnostics and version-skew checks across the family.
## Breaking changes
**Pre-1.0 API churn.** Relative to the v0.1.0 scaffold (no domain logic), everything here is an addition. Two configuration changes are worth calling out:
- The no-op `std` feature was removed; the crate is std-only (it builds on `iqdb-index`, which is) and now defaults to no optional features.
- The crate now depends on `iqdb-types` 1.0 and `iqdb-index` 1.0.
## Verification
Every public item carries a runnable rustdoc example. The core invariants are property-tested against a brute-force reference index — the cached wrapper returns exactly what the bare index returns across cold and warm lookups (transparency), a write is never stale, and the cache never exceeds its capacity — alongside a concurrency test for shared-`&self` searches and a `criterion` benchmark of the hit path versus an uncached scan. The same gates run across the CI matrix (Linux, macOS, Windows) on stable and the 1.87 MSRV:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
```
MSRV: Rust 1.87.
## What's next
- **v0.3.0 — result cache with TTL + invalidation.** A per-entry time-to-live so cached results also expire after a configured duration, with finer invalidation, benchmarked where it is a hot path.
## Installation
```toml
[dependencies]
iqdb-cache = "0.2"
```
## Documentation
- [README](https://github.com/jamesgober/iqdb-cache/blob/main/README.md)
- [API reference](https://github.com/jamesgober/iqdb-cache/blob/main/docs/API.md)
- [ROADMAP](https://github.com/jamesgober/iqdb-cache/blob/main/dev/ROADMAP.md)
- [Standards (REPS)](https://github.com/jamesgober/iqdb-cache/blob/main/REPS.md)
- [CHANGELOG](https://github.com/jamesgober/iqdb-cache/blob/main/CHANGELOG.md)
---
**Full diff:** [`v0.1.0...v0.2.0`](https://github.com/jamesgober/iqdb-cache/compare/v0.1.0...v0.2.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/iqdb-cache/blob/main/CHANGELOG.md).