Skip to content
Merged
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
14 changes: 13 additions & 1 deletion src/cachier/cores/pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,19 @@ def _clear_all_cache_files(self) -> None:
path, name = os.path.split(self.cache_fpath)
for subpath in os.listdir(path):
if subpath.startswith(f"{name}_"):
os.remove(os.path.join(path, subpath))
fpath = os.path.join(path, subpath)
# Retry loop to handle Windows mandatory file-locking (WinError 32):
# portalocker holds an exclusive lock while a thread is computing,
# so os.remove() may fail transiently until the lock is released.
for attempt in range(3): # pragma: no branch
try:
os.remove(fpath)
break
except PermissionError:
if attempt < 2:
time.sleep(0.1 * (attempt + 1))
else:
raise

def _clear_being_calculated_all_cache_files(self) -> None:
path, name = os.path.split(self.cache_fpath)
Expand Down
75 changes: 75 additions & 0 deletions tests/pickle_tests/test_pickle_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,81 @@ def mock_func():
core.delete_stale_entries(timedelta(hours=1))


@pytest.mark.pickle
def test_clear_all_cache_files_retries_on_permission_error(tmp_path):
"""Test _clear_all_cache_files retries on PermissionError then succeeds."""
core = _PickleCore(
hash_func=None,
cache_dir=tmp_path,
pickle_reload=False,
wait_for_calc_timeout=10,
separate_files=True,
)

def mock_func():
pass

core.set_func(mock_func)

# Create a cache file that matches the name pattern
cache_fpath = core.cache_fpath
dummy_file = cache_fpath + "_dummykey"
with open(dummy_file, "wb") as f:
f.write(b"")

# os.remove fails twice then succeeds on the third call
real_remove = os.remove
call_count = 0

def flaky_remove(path):
nonlocal call_count
call_count += 1
if call_count < 3:
raise PermissionError("locked")
real_remove(path)

with (
patch("cachier.cores.pickle.os.remove", side_effect=flaky_remove),
patch("cachier.cores.pickle.time.sleep") as mock_sleep,
):
core._clear_all_cache_files()
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(0.1)
mock_sleep.assert_any_call(0.2)

assert not os.path.exists(dummy_file)


@pytest.mark.pickle
def test_clear_all_cache_files_raises_on_persistent_permission_error(tmp_path):
"""Test _clear_all_cache_files re-raises PermissionError after all retries."""
core = _PickleCore(
hash_func=None,
cache_dir=tmp_path,
pickle_reload=False,
wait_for_calc_timeout=10,
separate_files=True,
)

def mock_func():
pass

core.set_func(mock_func)

# Create a cache file that matches the name pattern
cache_fpath = core.cache_fpath
dummy_file = cache_fpath + "_dummykey"
with open(dummy_file, "wb") as f:
f.write(b"")

with (
patch("cachier.cores.pickle.os.remove", side_effect=PermissionError("locked")),
patch("cachier.cores.pickle.time.sleep"),
pytest.raises(PermissionError),
):
core._clear_all_cache_files()


# Redis core static method tests
@pytest.mark.parametrize(
("test_input", "expected"),
Expand Down