manager: don't allow functions to depend on the cache root
- ID
d47dd2b- date
2026-11-06 23:48:11+00:00- author
Alex Chan <alexc@tailscale.com>- parent
b02a8b7- message
manager: don't allow functions to depend on the cache root If a function could depend on the cache root, it would be constantly invalidated as new values were written to the cache, so you'd be thrashing the disk and not getting any benefit. When registering a function, detect if it depends on files in the cache root, and throw an error if so.- changed files
2 files, 63 additions, 22 deletions
Changed files
src/funkycache/manager.py (11783 → 12102)
diff --git a/src/funkycache/manager.py b/src/funkycache/manager.py
index 17f4837..30dd868 100644
--- a/src/funkycache/manager.py
+++ b/src/funkycache/manager.py
@@ -105,7 +105,7 @@ class FunkyCache:
root = Path(".funkycache")
self._storage = storage
- self._root = Path(root)
+ self._root = Path(root).resolve()
self._known_caches = {}
atexit.register(self._close)
@@ -139,6 +139,14 @@ class FunkyCache:
dep_paths = _dep_paths(func, files=[Path(p) for p in (files or [])])
dep_values = values or []
+ if any(
+ dp.is_relative_to(self._root) or self._root.is_relative_to(dp)
+ for dp in dep_paths
+ ):
+ raise ValueError(
+ "A function cannot depend on a file/folder inside the cache root"
+ )
+
# Check if we already have a cache for this function.
#
# We must do this before creating the storage, or we could have
@@ -351,7 +359,7 @@ def _input_signature(dep_paths: set[Path], values: list[Any]) -> str:
all_paths.append(p)
elif p.is_dir():
all_paths.extend(gp for gp in p.rglob("*") if gp.is_file())
- else:
+ else: # pragma: no cover
raise TypeError(f"Invalid path: {p}")
for p in sorted(all_paths):
tests/test_manager.py (21772 → 22951)
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 361a210..7a41187 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -19,6 +19,14 @@ from funkycache import CannotOverwriteValueException, FunkyCache
from funkycache.storage import CacheStorage, MemoryCacheStorage, SQLiteCacheStorage
+type PathModifier = Callable[[Path], Path]
+
+path_styles = [
+ pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
+ pytest.param(lambda p: p.resolve(), id="absolute"),
+]
+
+
class FunkyCacheTestSuite(ABC):
"""
Tests for the properties of FunkyCache which should be independent
@@ -208,13 +216,14 @@ class FunkyCacheTestSuite(ABC):
If a function depends on a folder and a path under the folder
changes, the cache is invalided.
"""
- p = tmp_path / "greeting.txt"
+ p = tmp_path / "messages/greeting.txt"
+ p.parent.mkdir()
p.write_text("hello world!")
# Count how many times the underlying function was called
calls = Counter[str]()
- @cache.register(files=[tmp_path])
+ @cache.register(files=[tmp_path / "messages"])
def initial(s: str) -> str:
calls[s] += 1
return s[0].upper()
@@ -230,7 +239,7 @@ class FunkyCacheTestSuite(ABC):
# Change the underlying file, and send a notification for the
# directory changing
p.write_text("bonjour monde")
- cache.notify_path_change(tmp_path)
+ cache.notify_path_change(tmp_path / "messages/greeting.txt")
# The third call is a cache miss; goes to the real function
assert initial("apple") == "A"
@@ -310,26 +319,14 @@ class FunkyCacheTestSuite(ABC):
assert double(5) == 10
- @pytest.mark.parametrize(
- "modify_depends_on",
- [
- pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
- pytest.param(lambda p: p.resolve(), id="absolute"),
- ],
- )
- @pytest.mark.parametrize(
- "modify_path_change",
- [
- pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
- pytest.param(lambda p: p.resolve(), id="absolute"),
- ],
- )
+ @pytest.mark.parametrize("modify_depends_on", path_styles)
+ @pytest.mark.parametrize("modify_notify_path_change", path_styles)
def test_invalidation_handles_relative_paths(
self,
cache: FunkyCache,
tmp_path: Path,
- modify_depends_on: Callable[[Path], Path],
- modify_path_change: Callable[[Path], Path],
+ modify_depends_on: PathModifier,
+ modify_notify_path_change: PathModifier,
) -> None:
"""
The path invalidation logic can handle different combinations
@@ -357,7 +354,7 @@ class FunkyCacheTestSuite(ABC):
# Change the underlying file
p.write_text("bonjour monde")
- cache.notify_path_change(modify_path_change(p))
+ cache.notify_path_change(modify_notify_path_change(p))
# The third call is a cache miss; goes to the real function
assert initial("apple") == "A"
@@ -676,3 +673,39 @@ class TestSQLiteFunkyCache(FunkyCacheTestSuite):
@cache.register
def double(n: int): # pragma: no cover
return n * 2
+
+ @pytest.mark.parametrize("modify_root", path_styles)
+ @pytest.mark.parametrize("modify_depends_on", path_styles)
+ @pytest.mark.parametrize(
+ "depends_on",
+ [
+ pytest.param(lambda root: root.parent, id="parent"),
+ pytest.param(lambda root: root, id="self"),
+ pytest.param(lambda root: root / "test.db", id="file"),
+ ],
+ )
+ def test_function_cannot_depend_on_cache_root(
+ self,
+ tmp_path: Path,
+ modify_root: PathModifier,
+ modify_depends_on: PathModifier,
+ depends_on: PathModifier,
+ ) -> None:
+ """
+ A function cannot declare a dependency on the cache root or
+ any of the files inside it.
+ """
+ root = tmp_path / "example/.cache"
+
+ with contextlib.chdir(tmp_path):
+ cache = FunkyCache(storage="sqlite", root=modify_root(root))
+
+ files: list[Path | str] = [modify_depends_on(depends_on(root))]
+
+ with pytest.raises(
+ ValueError, match="cannot depend on a file/folder inside the cache root"
+ ):
+
+ @cache.register(files=files)
+ def double(n: int) -> int: # pragma: no cover
+ return n * 2