Skip to content

Conversation

ruaridhg
Copy link
Contributor

@ruaridhg ruaridhg commented Aug 11, 2025

Closes #3357

Store containing 2 stores i.e. primary store (where the data is coming from) and a cache store (where you want to store the data as a cache).

Introduces the class CacheStore

This cache wraps any Store implementation and uses a separate Store instance as the cache backend. This provides persistent caching capabilities with time-based expiration, size-based eviction, and flexible cache storage options.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/user-guide/*.rst
  • Changes documented as a new file in changes/
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Copy link

codecov bot commented Aug 11, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.86%. Comparing base (1cd4f7e) to head (af81c17).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3366      +/-   ##
==========================================
+ Coverage   61.25%   61.86%   +0.61%     
==========================================
  Files          84       85       +1     
  Lines        9949    10110     +161     
==========================================
+ Hits         6094     6255     +161     
  Misses       3855     3855              
Files with missing lines Coverage Δ
src/zarr/experimental/cache_store.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

await self._cache.delete(key)
self._remove_from_tracking(key)

def cache_info(self) -> dict[str, Any]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be great to use a typeddict here, so that the return type is known

Copy link
Contributor

@TomAugspurger TomAugspurger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. I left a few small comments, but a couple bigger picture things:

  1. This is perhaps the best demonstration for why #2473 (statically associating a Store with a Buffer type) might be helpful. I imagine that we don't want to use precious GPU RAM as a cache.
  2. Do we care about thread safety here at all? Given that the primary interface to concurrency in Zarr is async, I think we're probably OK not worrying about it. But there might be spots where we can do operations atomically (e.g. using dict.pop instead of an if key in dict followed by the operation) with little cost. Trying to synchronize changes to multiple dictionaries would require much more effort.

except Exception as e:
logger.warning("_evict_key: failed to evict key %s: %s", key, e)

def _cache_value(self, key: str, value: Any) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we accepting arbitrary value here? Is it possible to scope this to just Buffer objects (or maybe Buffer and NDBuffer)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a glance, it looks like we only call this with Buffer so hopefully this is an easy fix.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 189 to 190
if key in self._cache_order:
del self._cache_order[key]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is called from multiple threads, but this could be done atomically with self._cache_order.pop(key, None).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


def _cache_value(self, key: str, value: Any) -> None:
"""Cache a value with size tracking."""
value_size = buffer_size(value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we only accept Buffer here, then buffer_size can be removed hopefully.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buffer_size is now removed, as we are using the Buffer type

>>> cached_store = zarr.storage.CacheStore(
... store=source_store,
... cache_store=cache_store,
... max_size=256*1024*1024 # 256MB cache
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR. I think it would be better to have the LRU functionality on the cache_store (in this example the MemoryStore). Otherwise the enclosing CacheStore would need to keep track of all keys and their access order in the inner store. That could be problematic if the inner store would be shared with other CacheStores or other code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could be problematic if the inner store would be shared with other CacheStores or other code.

As long as one of the design goals is to use a regular zarr store as the caching layer, there will be nothing we can do to guard against external access to the cache store. For example, if someone uses a LocalStore as a cache, we can't protect the local file system from external modification. I think it's the user's responsibility to ensure that they don't use the same cache for separate CacheStores.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but our default behavior could be to create a fresh MemoryStore, which would be a safe default

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern here is about the abstraction. The LRU fits better in the inner store than in the CacheStore, imo. There could even be an LRUStore that wraps a store and implements the tracking and eviction.
The safety concern is, as you pointed out, something the user should take care of.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern here is about the abstraction. The LRU fits better in the inner store than in the CacheStore, imo.

That makes sense, maybe we could implement LRUStore as another store wrapper?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to have the LRU functionality on the cache_store (in this example the MemoryStore)

To understand, is the suggestion here that

  1. There's no CacheStore class
  2. Instead all the logic for caching is implemented on Store, and there is a cache_store property that can be set to a second store to enable caching?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@normanrz unless you have a concrete proposal for a refactor that would be workable in the scope of this PR, I would suggest we move forward with the PR as-is, and use experience to dial in the abstraction in a later PR.

But knowing that design here might change, I think we should introduce an "experimental" storage module, e.g. zarr.storage.experimental, or a top-level experimental module, zarr.experimental, and put this class there until we are sure that the API is final.

Thoughts? I would like to ship this important feature while also retaining the ability to safely adjust it later. An experimental module seems like a safe way to do that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just commenting that I'd love to have this included sooner rather than later, it will be immediately useful 🎉 Thanks @ruaridhg for taking the initiative and putting this together!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of experimental here! 🤩

@github-actions github-actions bot added the needs release notes Automatically applied to PRs which haven't added release notes label Sep 30, 2025
@github-actions github-actions bot removed the needs release notes Automatically applied to PRs which haven't added release notes label Sep 30, 2025
@d-v-b
Copy link
Contributor

d-v-b commented Oct 1, 2025

@ruaridhg I pushed a bunch of changes, please have a look before I merge!

@d-v-b d-v-b mentioned this pull request Oct 2, 2025
Copy link
Contributor

@d-v-b d-v-b left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing some doctests, but that's because we don't have doctests working (see #3500). I'm going to merge this as-is, and we can circle back with more fixes later. Thanks for this contribution @ruaridhg!

@d-v-b d-v-b enabled auto-merge (squash) October 2, 2025 08:46
@d-v-b d-v-b merged commit 2eb89e1 into zarr-developers:main Oct 2, 2025
29 checks passed
@ruaridhg
Copy link
Contributor Author

ruaridhg commented Oct 6, 2025

This is missing some doctests, but that's because we don't have doctests working (see #3500). I'm going to merge this as-is, and we can circle back with more fixes later. Thanks for this contribution @ruaridhg!

@d-v-b Thanks for making changes and merging. I'm on another project with a tight deadline so haven't had time to look at this, much appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add general CacheStore to Zarr 3.0
7 participants