Skip to content
Open
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
1 change: 1 addition & 0 deletions changes/4040.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`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 evicted when the key is written via `set`/`set_if_not_exists`. 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.
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