# iqdb-cache v0.3.0 — Result-Cache Time-to-Live
**Staleness gets a clock.** v0.2.0 made the cache correct under mutation; v0.3.0 bounds it under *time*. A cached result can now carry an expiry, so it is recomputed once it ages past a configured TTL — even when nothing was written through the wrapper. The time source is `clock-lib`, the iQDB time standard, which means expiry is verified deterministically with a mock clock rather than `sleep`.
## What is iqdb-cache?
An in-process caching layer between the database and an index. It wraps any `iqdb_index::IndexCore` as a `CachedIndex` — itself an `IndexCore` — and memoizes search results, turning a repeated query into a memory read. It stays an opt-in optimization: a database is correct with no cache, and wrapping an index never changes *what* a search returns.
## What's new in 0.3.0
### `CacheConfig` — the Tier-2 configured path
Capacity and TTL are set together through a small chaining builder, then handed to `CachedIndex::with_config`. `new` and `with_capacity` are now thin shortcuts over it, so the common case stays one call while the configured case has one obvious home.
```rust
use std::time::Duration;
use iqdb_cache::{CacheConfig, CachedIndex};
let config = CacheConfig::new()
.capacity(4096)
.ttl(Duration::from_secs(300));
let cached = CachedIndex::with_config(iqdb_cache::doc_stub::stub_index(), config);
assert_eq!(cached.capacity(), 4096);
assert_eq!(cached.ttl(), Some(Duration::from_secs(300)));
```
### Per-entry TTL — two independent staleness guarantees
Each cached result records the moment it was stored. On a lookup, an entry that has reached its TTL is treated as a miss and recomputed; the fresh result replaces it. TTL and mutation invalidation are deliberately orthogonal:
- **Mutation invalidation** (from 0.2) is exact and immediate — a write through the wrapper drops the whole cache, so a search after a write is never stale.
- **TTL** (new) bounds an entry's *age* against changes the wrapper cannot see — the wrapped index mutated through another handle, or an external source behind it.
With no TTL — the default — the clock is never consulted, so the non-TTL hot path is byte-for-byte the 0.2 path.
### `clock-lib` for deterministic time
The wrapper holds a clock behind the `Clock` trait: a monotonic `SystemClock` in production, and a `ManualClock` the crate's own tests advance by hand. TTL expiry, the exclusive boundary (`elapsed >= ttl` expires), and "never expires without a TTL" are all proven without a single `sleep`, so the suite stays fast and flake-free.
```rust
use std::time::Duration;
use iqdb_cache::{CacheConfig, CachedIndex};
use iqdb_index::IndexCore;
use iqdb_types::{DistanceMetric, SearchParams};
let cached = CachedIndex::with_config(
iqdb_cache::doc_stub::stub_index(),
CacheConfig::new().ttl(Duration::from_secs(300)),
);
let params = SearchParams::new(1, DistanceMetric::Cosine);
let _ = cached.search(&[1.0, 0.0, 0.0], ¶ms).expect("search");
assert_eq!(cached.ttl(), Some(Duration::from_secs(300)));
```
## Performance
The hit path is unchanged when no TTL is set; with a TTL it adds exactly one monotonic clock read. On the reference machine, a top-10 search over 10,000 vectors at dim 64 (`cargo bench`):
| Path | Time | vs. uncached |
|------|------|--------------|
| `uncached_scan` (cache disabled, full scan) | **~234 µs** | 1× |
| `cache_hit` (served from the LRU) | **~238 ns** | **~985× faster** |
| `cache_hit_ttl` (hit + TTL expiry check) | ~267 ns | ~880× faster |
The TTL check costs ~29 ns — one `clock-lib` monotonic read — and only when a TTL is configured. Numbers are from the `criterion` bench; a hit also clones the result vector it returns, which is included above.
## Breaking changes
**Pre-1.0 API churn, but source-compatible.** Everything in 0.3.0 is additive: `new` and `with_capacity` keep their signatures and behaviour (no TTL). The crate gains one dependency, `clock-lib` 1.0, for monotonic, mockable time.
## Verification
TTL behaviour is covered by deterministic unit tests driven by `ManualClock` (expiry, the exclusive boundary, and never-expire-without-TTL) and by an integration suite over the public `CacheConfig` surface (capacity/TTL round-trip, `no_ttl`, disabled, and a long-TTL cache behaving like a plain one). The 0.2 invariants — transparency, no-stale-after-mutation, the capacity bound, and concurrent reads — still hold and still run. 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.4.0 — eviction policies + feature freeze.** LFU, FIFO, and ARC alongside the current LRU, selectable through `CacheConfig`, with the feature set frozen for the run to 1.0.
## Installation
```toml
[dependencies]
iqdb-cache = "0.3"
```
## 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.2.0...v0.3.0`](https://github.com/jamesgober/iqdb-cache/compare/v0.2.0...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/iqdb-cache/blob/main/CHANGELOG.md).