Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
175ce65
prototype for negative caching, i.e., get misses
espg Jun 5, 2026
5ff0af0
matching current api for positive cached values
espg Jun 5, 2026
83fcd85
Merge branch 'main' into feat/cache-store-negative-caching
d-v-b Jun 8, 2026
139fff3
Merge branch 'main' into feat/cache-store-negative-caching
d-v-b Jun 9, 2026
e0a09d3
Merge branch 'main' into feat/cache-store-negative-caching
espg Jun 25, 2026
497d698
unified slot-based cache for both positive and negative entries (shar…
espg Jun 25, 2026
7829bfb
fixing eviction bug where we weren't reclaiming bytes on evicted nega…
espg Jun 25, 2026
2ca2376
updating narrative user docs
espg Jun 25, 2026
bb0a346
minor bug fix
espg Jun 26, 2026
8e3b646
Merge branch 'main' into feat/cache-store-negative-caching
d-v-b Jun 29, 2026
d89c0f7
Merge remote-tracking branch 'upstream/main' into feat/cache-store-ne…
espg Jul 22, 2026
383448c
default finite max age, other minor review fixes
espg Jul 22, 2026
169bf63
Merge remote-tracking branch 'origin/feat/cache-store-negative-cachin…
espg Jul 22, 2026
ef49ac4
appease new docs linter: mkdocs-style literal in _Entry docstring
espg Jul 22, 2026
38edc70
fix write/miss race guard: compare entry identity, not timestamps (wi…
espg Jul 22, 2026
60b5e0d
Merge branch 'main' into feat/cache-store-negative-caching
d-v-b Jul 28, 2026
87fe493
Merge remote-tracking branch 'upstream/main' into feat/cache-store-ne…
espg Aug 11, 2026
92eda3b
rename changelog fragment to this PR's number (4042, not the ChunkLay…
espg Aug 11, 2026
a9ffcbd
one record per key: nest byte ranges and the absent marker in _KeySta…
espg Aug 11, 2026
8f79e75
O(1) eviction candidate when no absent markers exist (kills linear sc…
espg Aug 11, 2026
58a8365
one generation per key record: serve byte ranges by slicing a cached …
espg Aug 11, 2026
6bda7fd
route get_ranges, get_partial_values, delete_dir and clear through th…
espg Aug 11, 2026
7d98576
order LRU recency by a monotonic use-tick, not wall time: coarse cloc…
espg Aug 12, 2026
fb4c893
Merge branch 'main' into feat/cache-store-negative-caching
d-v-b Aug 14, 2026
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
3 changes: 3 additions & 0 deletions changes/4042.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`zarr.experimental.cache_store.CacheStore` now performs negative caching by default (`cache_missing=True`, opt-out). A full-key read that finds the key absent in the source store is remembered, so repeat reads of that absent key return immediately without a source round-trip — useful for sparse arrays where most chunks resolve to the fill value. Remembered misses respect `max_age_seconds` and are dropped when the key is written via `set`/`set_if_not_exists` or when a byte-range read observes data for the key. Negative-cache activity is reported as `negative_hits` in `cache_stats()` and `missing_keys` in `cache_info()`. Only full-key reads are affected (not byte-range reads or `exists`). Pass `cache_missing=False` to restore the previous behavior. The default `max_age_seconds` is now finite (300 seconds) so both cached values and remembered misses are re-validated against the source at bounded staleness; pass `"infinity"` to opt out. Negative markers share the `max_size` byte budget with cached values (each charged a small flat overhead, evicted marker-first), and when `max_size` is `None` the marker count is capped at an internal limit (100,000, least-recently-used evicted first), so scans over very large sparse key spaces stay bounded.

Everything the cache knows about a key now lives in one record, mutated under one lock, and each source observation for the key replaces that whole record. So a cached full value also answers byte-range reads of the key — served from the cache store rather than the source — and a byte-range read that does reach the source supersedes any older value cached for the key; full-key and byte-range reads through the same `CacheStore` therefore always answer from the same observation. `get_ranges`, `get_partial_values` and `delete_dir` are routed through the caching `get`/`delete` paths as well, so the sharded partial-read path and prefix deletions (e.g. `overwrite=True`) no longer read or delete around the cache.
57 changes: 57 additions & 0 deletions docs/user-guide/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,52 @@ cache = CacheStore(
)
```

**cache_missing**: Controls *negative caching* — remembering keys that are absent in the
source store (on by default). Without it, the positive cache cannot help with an absent
key: there is no value to store, so every read re-pays a source round-trip. This is the
dominant cost when reading sparse arrays (mostly empty chunks) repeatedly through a cache.
With `cache_missing=True`, a full-key read that finds the key absent records that absence,
so subsequent reads of the same key return immediately without consulting the source. The
remembered miss is evicted when the key is written and respects `max_age_seconds`.

```python exec="true" session="experimental" source="above"
import asyncio
from zarr.storage import MemoryStore
from zarr.core.buffer import default_buffer_prototype

neg_cache = CacheStore(
store=MemoryStore(),
cache_store=MemoryStore(),
cache_missing=True, # default; pass False to disable
max_age_seconds=300, # recommended: bound staleness of remembered misses
)

async def read_absent_twice():
proto = default_buffer_prototype()
await neg_cache.get("c/0", proto) # first read: real miss, consults the source
await neg_cache.get("c/0", proto) # second read: served from the negative cache

asyncio.run(read_absent_twice())

info = neg_cache.cache_info()
print(info['cache_missing']) # True
print(info['missing_keys']) # 1 — one remembered absent key
print(neg_cache.cache_stats()['negative_hits']) # 1 — one read served without a source round-trip
```

Negative markers share the `max_size` budget with cached values: each is charged a small
flat overhead, and under memory pressure markers are evicted (least-recently-used first)
before any cached value, so a flood of empty-chunk reads can never evict real cached data.
Only full-key reads are affected — byte-range reads and `exists()` are unchanged.

> **Note:** A remembered miss stays visible-as-absent until it expires (`max_age_seconds`,
> a finite 300 seconds by default) or the key is written through the cache. With
> `max_age_seconds="infinity"` it never expires, so a key written to the source by another
> process stays invisible through the cache; keep the TTL finite when the source may be
> written concurrently. For very large sparse arrays, prefer the
> array-level sparse-read primitives `zarr.shards_initialized` / `zarr.read_regions`, which
> read only populated chunks and avoid the empty-chunk reads entirely.

### Cache Statistics

The CacheStore provides statistics to monitor cache performance and state:
Expand All @@ -240,9 +286,19 @@ print(info['current_size'])
print(info['tracked_keys'])
print(info['cached_keys'])
print(info['cache_set_data'])
print(info['cache_missing']) # negative caching enabled?
print(info['missing_keys']) # number of remembered absent keys

# cache_stats() reports hit/miss counts and negative-cache activity
stats = cached_store.cache_stats()
print(stats['hits'])
print(stats['misses'])
print(stats['negative_hits']) # absent-key reads served without a source round-trip
```

The `cache_info()` method returns a dictionary with detailed information about the cache state.
A negative hit (an absent key served from the negative cache) is reported separately as
`negative_hits` and counts as neither a hit nor a miss, so it does not affect `hit_rate`.

### Cache Management

Expand Down Expand Up @@ -270,6 +326,7 @@ The `clear_cache()` method is an async method that clears both the cache store
4. **Monitor cache statistics**: Use `cache_info()` to tune cache size and access patterns
5. **Consider data locality**: Group related data accesses together to improve cache efficiency
6. **Set appropriate expiration**: Use `max_age_seconds` for time-sensitive data or "infinity" for static data
7. **Negative caching for sparse data**: Leave `cache_missing` on (the default) to skip repeated source round-trips for absent keys; pair it with a finite `max_age_seconds` if the source may be written by another process

### Working with Different Store Types

Expand Down
Loading
Loading