fix(cache_store): stop CacheStore serving data the source no longer holds - #298
Open
d-v-b wants to merge 2 commits into
Open
fix(cache_store): stop CacheStore serving data the source no longer holds#298d-v-b wants to merge 2 commits into
d-v-b wants to merge 2 commits into
Conversation
…olds `CacheStore` had four write paths that bypassed the cache entirely, plus two accounting bugs and two lock windows where the tracking state and the backing store could be observed out of step. Bypassed write paths. `set_if_not_exists`, `_set_many`, `delete_dir` and `clear` were not overridden, so `WrapperStore` forwarded them straight to the source store and no invalidation ran. Under the default `max_age_seconds="infinity"` the stale value was then served forever. The most visible case: `zarr.create_array(..., overwrite=True)` goes through `delete_dir`, so overwriting an array through a `CacheStore` left the *old* array readable through the cache. Accounting. `delete` dropped an entry's tracking without reclaiming its bytes, so every delete permanently inflated `current_size` and ate into the `max_size` budget. A value too large to cache was left in the backing store untracked -- uncounted against `max_size`, never eviction-eligible, and still served as a hit. `_track_entry` could also select the very entry it was re-tracking as an eviction candidate, double-subtracting its size and deleting the value just written. Lock discipline. Every backing-store mutation is now published in the same locked section as its tracking mutation. Previously `delete` and `clear_cache` mutated the backing store outside the lock, so a concurrent `set` landing in that window either had its backing value deleted underneath it or was left in the backing store with no tracking entry at all. Also fixes `CacheStore.open()`, which inherited `WrapperStore.open()` -- that builds the wrapped store from a `store_cls` argument and cannot supply the required `cache_store`, so it always raised. `cache_store` must now support listing as well as deletes, since prefix deletions need it; this is checked in the constructor rather than failing later mid-write. Tests: adds `TestCacheStoreWriteCoherence` (12 of its 14 cases fail without this change) and runs `CacheStore` through the shared `StoreTests` conformance suite for the first time. Assisted-by: ClaudeCode:claude-opus-5
The changelog check requires an integer filename. Note this is the fork PR number; it needs renaming again if this goes upstream. Assisted-by: ClaudeCode:claude-opus-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Split out of the review of zarr-developers#4042. That PR adds negative caching to
CacheStore, but a large part of its diff is fixing pre-existing coherence bugs that have nothing to do with the new feature. This is that part, on its own, so it can land and be reviewed independently.Every fix here reproduces as a bug on
mainwith no negative caching involved.Write paths that bypassed the cache
set_if_not_exists,_set_many,delete_dirandclearwere not overridden, soWrapperStoreforwarded them straight to the source store and no invalidation ran. With the defaultmax_age_seconds="infinity"the superseded value was then served forever:CacheStore.getbefore this PR_set_manyNEWOLDset_if_not_existsNEWOLDdelete_dirOLDclearOLDThe user-visible form, since
zarr.create_array(..., overwrite=True)goes throughdelete_dir:Each of the four now invalidates the keys it affects.
Size accounting
deletedropped an entry's tracking without reclaiming its bytes, so every delete permanently inflatedcurrent_sizeand ate into themax_sizebudget. Setting then deleting a single 100-byte value leftcurrent_size == 100.max_size, never eviction-eligible, but still served as a hit._track_entrycould select the very entry it was re-tracking as an eviction candidate, double-subtracting its size, terminating the eviction loop early, and deleting the value the caller had just written.Lock discipline
Every backing-store mutation is now published in the same locked section as its tracking mutation. Previously
deleteandclear_cachemutated the backing store outside the lock, so a concurrentsetlanding in that window either had its backing value deleted underneath it (leaving a tracking entry claiming bytes the backing store no longer held) or was left in the backing store with no tracking entry at all.Both are covered by tests that drive a deterministic interleaving through a gated cache backend, rather than hoping a timing race reproduces.
Two smaller fixes
CacheStore.open()never worked. It inheritedWrapperStore.open(), which builds the wrapped store from astore_clsargument and so cannot supply the requiredcache_store.await CacheStore.open(MemoryStore(), cache_store=MemoryStore())raisedTypeError: 'MemoryStore' object is not callable. Included because it is five lines and it unblocks the conformance suite below — happy to drop it if you would rather keep this PR purely about coherence.cache_storemust now support listing as well as deletes, since prefix deletion needs it. Checked in the constructor rather than failing later, mid-write, with aNotImplementedErrorfrom the backing store.Tests
CacheStorenow runs through the sharedStoreTestsconformance suite for the first time — these write paths are exactly what that suite exercises and nothing else did. Five of its tests are skipped with explicit reasons rather than papered over, because they are pre-existing gaps rather than things this PR should fix:read_only=constructor kwarg, whichCacheStoredoes not take (it derives read-only from the source store;with_read_onlyis the supported route and is covered separately);test_store_context_managerneeds_with_store, whichCacheStoreraises on by design — a copy wrapping a different source store would share this store's cache and collide on keys;test_delete_sync_visible_to_async_getfails becauseCacheStoresets_supports_sync_io = False, but the inheritedWrapperStoresync methods still satisfySupportsDeleteSyncstructurally, so the harness does not skip it itself.Whether the read-only gap is worth closing is a separate question; the skips at least record it somewhere other than a review comment.
New
TestCacheStoreWriteCoherenceclass: 12 of its 14 cases fail onmain. The two that pass are thesetanddeletearms of the parametrized invalidation test — those paths already invalidated correctly, and thedeletebug was accounting, which the accounting test covers instead.Full run: 132 passed, 5 skipped in the cache-store file; 1063 passed across
tests/test_store+tests/test_experimental. ruff, mypy (strict), numpydoc and codespell clean.Deliberately not included
_KeyStaterestructuring and the read-path routing (get_ranges/get_partial_values/_get_many);max_age_secondsdefault change;The in-memory byte-range cache is still unbounded when
max_size is None. That is pre-existing, and it belongs with the read-path routing work, since that is what would start filling it with chunk payloads rather than just shard indexes.Notes
changes/XXXX.bugfix.mdand needs renaming to the PR number.d-v-b/zarr-python@main, which is currently 3 commits behind upstream. Nothing here depends on those commits.🤖 Generated with Claude Code