From e66620fabd04ff6c299fe4c08ee55f9fbc90a5f9 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Sun, 24 May 2026 00:44:21 +0100 Subject: [PATCH] core: fix potential race condition for synthetic key --- src/cachew/__init__.py | 20 ++++++++-------- src/cachew/tests/test_cachew.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/cachew/__init__.py b/src/cachew/__init__.py index de8c62a..13ad94c 100644 --- a/src/cachew/__init__.py +++ b/src/cachew/__init__.py @@ -553,16 +553,6 @@ def cachew_wrapper[**P, ItemT]( logger.debug('hash mismatch: computing data and writing to db') - if synthetic_key is not None: - missing_synthetic_values = _synthetic.missing_synthetic_key_values_for_hashes( - old_hash=old_hash, - new_hash_d=new_hash_d, - ) - if missing_synthetic_values is not None: - # can reuse cache - kwargs[_synthetic.CACHEW_CACHED] = session.cached_items() # ty: ignore[invalid-assignment] - kwargs[synthetic_key] = missing_synthetic_values # ty: ignore[invalid-assignment] - got_write = backend.get_exclusive_write() if not got_write: # NOTE: this is the bit we really have to watch out for and not put in a helper function @@ -573,6 +563,16 @@ def cachew_wrapper[**P, ItemT]( running_uncached = False return + if synthetic_key is not None: + missing_synthetic_values = _synthetic.missing_synthetic_key_values_for_hashes( + old_hash=old_hash, + new_hash_d=new_hash_d, + ) + if missing_synthetic_values is not None: + # can reuse cache + kwargs[_synthetic.CACHEW_CACHED] = session.cached_items() # ty: ignore[invalid-assignment] + kwargs[synthetic_key] = missing_synthetic_values # ty: ignore[invalid-assignment] + # at this point we're guaranteed to have an exclusive write transaction try: yield from session.write_to_cache(func(*args, **kwargs)) diff --git a/src/cachew/tests/test_cachew.py b/src/cachew/tests/test_cachew.py index 9cbc1b4..8c72f84 100644 --- a/src/cachew/tests/test_cachew.py +++ b/src/cachew/tests/test_cachew.py @@ -1160,6 +1160,48 @@ def fun() -> Iterator[int]: assert calls == 1 +def test_synthetic_lock_lost_runs_uncached_with_original_args( + tmp_path: Path, + restore_settings, +) -> None: + """ + If synthetic cachew loses the write lock, it should run uncached with the original arguments. + """ + settings.THROW_ON_ERROR = False + + cache_path = tmp_path / 'cache' + recomputed: list[str] = [] + consumed_cached = False + + @cachew(cache_path, force_file=True, synthetic_key='keys') + def fun(keys: Sequence[str], *, cachew_cached: Iterable[str] = ()) -> Iterator[str]: + nonlocal consumed_cached + + for item in cachew_cached: + consumed_cached = True + yield item + + for key in keys: + recomputed.append(key) + yield key + + assert list(fun(keys=['a'])) == ['a'] + assert recomputed == ['a'] + + recomputed.clear() + backend_cls = { + 'file': FileBackend, + 'sqlite': SqliteBackend, + }[settings.DEFAULT_BACKEND] + + with backend_cls(cache_path=cache_path, logger=logger) as backend: + assert backend.get_exclusive_write() + assert list(fun(keys=['a', 'b'])) == ['a', 'b'] + + assert recomputed == ['a', 'b'] + assert consumed_cached is False + + @pytest.mark.parametrize('throw', [False, True]) def test_bad_annotation(*, tmp_path: Path, throw: bool) -> None: """