Skip to content
9 changes: 7 additions & 2 deletions sqlmesh/utils/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ def __init__(self, path: Path, prefix: t.Optional[str] = None):
# the file.stat() call below will fail on windows if the :file name is longer than 260 chars
file = fix_windows_path(file)

if not file.stem.startswith(self._cache_version) or file.stat().st_atime < threshold:
file.unlink(missing_ok=True)
try:
stat_result = file.stat()
if not file.stem.startswith(self._cache_version) or stat_result.st_atime < threshold:
file.unlink(missing_ok=True)
except FileNotFoundError:
# File was deleted between glob() and stat() — skip stale cache entries gracefully
continue

def get_or_load(self, name: str, entry_id: str = "", *, loader: t.Callable[[], T]) -> T:
"""Returns an existing cached entry or loads and caches a new one.
Expand Down
21 changes: 21 additions & 0 deletions tests/utils/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import time
import typing as t
from pathlib import Path

Expand Down Expand Up @@ -131,3 +133,22 @@ def test_optimized_query_cache_macro_def_change(tmp_path: Path, mocker: MockerFi
new_model.render_query_or_raise().sql()
== 'SELECT "_0"."a" AS "a" FROM (SELECT 1 AS "a") AS "_0" WHERE "_0"."a" = 2'
)


def test_file_cache_init_handles_stale_file(tmp_path: Path, mocker: MockerFixture) -> None:
cache: FileCache[_TestEntry] = FileCache(tmp_path)

stale_file = tmp_path / f"{cache._cache_version}__fake_deleted_model_9999999999"
stale_file.touch()

original_stat = Path.stat

def flaky_stat(self, **kwargs):
if self.name == stale_file.name:
raise FileNotFoundError(f"Simulated stale file: {self}")
return original_stat(self, **kwargs)

mocker.patch.object(Path, "stat", flaky_stat)

FileCache(tmp_path)