diff --git a/sqlmesh/utils/cache.py b/sqlmesh/utils/cache.py index e72c34f632..59ffb91161 100644 --- a/sqlmesh/utils/cache.py +++ b/sqlmesh/utils/cache.py @@ -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. diff --git a/tests/utils/test_cache.py b/tests/utils/test_cache.py index ed19765b8a..cc7d9ee369 100644 --- a/tests/utils/test_cache.py +++ b/tests/utils/test_cache.py @@ -1,3 +1,5 @@ +import os +import time import typing as t from pathlib import Path @@ -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) + \ No newline at end of file