mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 06:08:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d865d41b | ||
|
|
e03fbfe7b1 |
Vendored
+64
-1
@@ -14,6 +14,13 @@ import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||
# not tied to the retention policies: those describe how long data stays
|
||||
# useful, and a half-written file was never useful.
|
||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -347,6 +354,23 @@ class DiskCache:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@staticmethod
|
||||
def _is_orphaned_temp(filename: str) -> bool:
|
||||
"""Whether a name is one of set()'s temp files rather than real data.
|
||||
|
||||
Matches only what this class creates: mkstemp with a prefix of
|
||||
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
|
||||
checked rather than just the leading dot, because this predicate
|
||||
deletes things -- a stray dotfile someone left in the cache directory
|
||||
is not ours to remove, and a completed ".json" never is either.
|
||||
"""
|
||||
if not filename.startswith('.') or filename.endswith('.json'):
|
||||
return False
|
||||
head, sep, suffix = filename.rpartition('.json.')
|
||||
# head is the key (non-empty after the leading dot), suffix is
|
||||
# mkstemp's random component.
|
||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||
|
||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired cache files based on retention policies.
|
||||
@@ -381,11 +405,50 @@ class DiskCache:
|
||||
try:
|
||||
with self._lock:
|
||||
# Get snapshot of files while holding lock briefly
|
||||
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
|
||||
entries = os.listdir(self.cache_dir)
|
||||
except OSError as list_error:
|
||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
filenames = [f for f in entries if f.endswith('.json')]
|
||||
|
||||
# Sweep temp files abandoned by a write that never finished. set()
|
||||
# removes its own in a finally, so these are the ones where the
|
||||
# process died between mkstemp and os.replace -- a SIGKILL, a lost
|
||||
# restart race, a power cut. Nothing ever collected them: they are
|
||||
# named ".<key>.json.<random>", and the scan above only matches
|
||||
# names ending in .json, so they accumulated indefinitely. Measured
|
||||
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
|
||||
# directory, the oldest six months old.
|
||||
stats['orphan_temp_files_deleted'] = 0
|
||||
for filename in (f for f in entries if self._is_orphaned_temp(f)):
|
||||
# Counted as scanned like any other candidate, so files_deleted
|
||||
# can never exceed files_scanned and the summary line reads
|
||||
# honestly ("77/8864", not "77/0").
|
||||
stats['files_scanned'] += 1
|
||||
path = os.path.join(self.cache_dir, filename)
|
||||
try:
|
||||
# An in-flight write lives for milliseconds, so anything
|
||||
# this old is certainly abandoned rather than in progress.
|
||||
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
|
||||
continue
|
||||
with self._lock:
|
||||
size = os.path.getsize(path)
|
||||
os.remove(path)
|
||||
stats['files_deleted'] += 1
|
||||
stats['orphan_temp_files_deleted'] += 1
|
||||
stats['space_freed_bytes'] += size
|
||||
except FileNotFoundError:
|
||||
continue # another sweep got there first
|
||||
except OSError as e:
|
||||
stats['errors'] += 1
|
||||
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
|
||||
|
||||
if stats['orphan_temp_files_deleted']:
|
||||
self.logger.info(
|
||||
"Removed %d abandoned cache temp file(s)",
|
||||
stats['orphan_temp_files_deleted'])
|
||||
|
||||
# Process files outside the lock to avoid blocking get/set operations
|
||||
for filename in filenames:
|
||||
|
||||
+4
-43
@@ -46,21 +46,7 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
# Which cache directories already have a cleanup thread in this process.
|
||||
#
|
||||
# The sweep is directory-scoped work -- it lists a directory and deletes
|
||||
# from it -- so one per directory is the right number no matter how many
|
||||
# managers exist. Nothing enforced that before: every instance started its
|
||||
# own, and because the loop closes over `self`, a discarded manager could
|
||||
# never be collected and its thread woke to re-scan the same directory
|
||||
# every 24 hours for the life of the process. Startup validation runs
|
||||
# twice and built a throwaway manager each time, so a display process
|
||||
# carried three threads for one cache.
|
||||
_cleanup_owners: Dict[str, 'CacheManager'] = {}
|
||||
_cleanup_owners_lock = threading.Lock()
|
||||
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Initialize logger first
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
@@ -732,29 +718,11 @@ class CacheManager:
|
||||
}
|
||||
|
||||
def start_cleanup_thread(self) -> None:
|
||||
"""Start background thread for periodic disk cache cleanup.
|
||||
|
||||
At most one thread per cache directory per process: the sweep is
|
||||
directory-scoped, so a second one only duplicates the scan.
|
||||
"""
|
||||
"""Start background thread for periodic disk cache cleanup."""
|
||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread already running")
|
||||
return
|
||||
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
owner = CacheManager._cleanup_owners.get(self.cache_dir)
|
||||
if owner is not None and owner is not self:
|
||||
thread = owner._cleanup_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
self.logger.debug(
|
||||
"Cleanup thread for %s already owned by another cache "
|
||||
"manager in this process; not starting a second",
|
||||
self.cache_dir)
|
||||
return
|
||||
# The owner's thread died or was stopped -- take over.
|
||||
CacheManager._cleanup_owners[self.cache_dir] = self
|
||||
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
"""Background loop that runs cleanup periodically."""
|
||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||
@@ -802,17 +770,10 @@ class CacheManager:
|
||||
Signals the thread to stop and waits for it to finish (with timeout).
|
||||
This allows for clean shutdown during testing or application termination.
|
||||
"""
|
||||
# Release ownership first and unconditionally, so a manager that never
|
||||
# started a thread (or whose thread already exited) cannot keep the
|
||||
# directory claimed and block a live manager from sweeping it.
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
if CacheManager._cleanup_owners.get(self.cache_dir) is self:
|
||||
del CacheManager._cleanup_owners[self.cache_dir]
|
||||
|
||||
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread not running")
|
||||
return
|
||||
|
||||
|
||||
self.logger.info("Stopping disk cache cleanup thread...")
|
||||
self._cleanup_stop_event.set() # Signal thread to stop
|
||||
|
||||
|
||||
@@ -90,8 +90,7 @@ class DisplayController:
|
||||
# Validate startup configuration
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
validator = StartupValidator(self.config_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
@@ -259,8 +258,7 @@ class DisplayController:
|
||||
# Validate plugins after plugin manager is created
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
|
||||
@@ -15,23 +15,16 @@ from src.logging_config import get_logger
|
||||
class StartupValidator:
|
||||
"""Validates system state on startup."""
|
||||
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
|
||||
cache_manager: Optional[Any] = None) -> None:
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
||||
"""
|
||||
Initialize the startup validator.
|
||||
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: Optional PluginManager instance
|
||||
cache_manager: The CacheManager the application will actually use.
|
||||
Pass it. Without one this validator builds its own just to read
|
||||
a directory path, which reports on a cache the app does not
|
||||
use and leaves behind a cleanup thread that nothing stops --
|
||||
validation runs twice per startup, so that was two of them.
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.cache_manager = cache_manager
|
||||
self.logger = get_logger(__name__)
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
@@ -98,21 +91,9 @@ class StartupValidator:
|
||||
def _validate_cache_directory(self) -> None:
|
||||
"""Validate cache directory permissions."""
|
||||
try:
|
||||
cache_manager = self.cache_manager
|
||||
if cache_manager is None:
|
||||
# No caller supplied one (older embedders, direct use in a
|
||||
# script). Build one, but do not leave its cleanup thread
|
||||
# running behind us -- this instance is discarded on the next
|
||||
# line but the thread is a closure over it, so it would never
|
||||
# be collected.
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
try:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
finally:
|
||||
cache_manager.stop_cleanup_thread()
|
||||
else:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
|
||||
if not cache_dir:
|
||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests that one cache directory gets one cleanup thread per process.
|
||||
|
||||
The sweep lists a directory and deletes from it, so a second thread over the
|
||||
same directory only duplicates the scan. Nothing enforced that: every
|
||||
CacheManager started its own, and since the loop closes over `self`, a
|
||||
discarded manager could never be collected -- its thread stayed alive and
|
||||
re-scanned the same directory every 24 hours for the life of the process.
|
||||
|
||||
On the dev rig a display process carried three, for one cache directory:
|
||||
|
||||
14:22:59.954 display_controller (the real one)
|
||||
14:22:59.973 startup validation, run 1 (discarded)
|
||||
14:23:01.055 startup validation, run 2 (discarded)
|
||||
|
||||
Startup validation runs twice and built a throwaway manager each time, purely
|
||||
to read a directory path.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
CacheManager._cleanup_owners.clear()
|
||||
yield
|
||||
for owner in list(CacheManager._cleanup_owners.values()):
|
||||
owner.stop_cleanup_thread()
|
||||
CacheManager._cleanup_owners.clear()
|
||||
|
||||
|
||||
def _live_cleanup_threads():
|
||||
return [t for t in threading.enumerate()
|
||||
if t.name == 'DiskCacheCleanup' and t.is_alive()]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch):
|
||||
"""A CacheManager pinned to a temp dir, so tests never touch the real one."""
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: str(tmp_path))
|
||||
return CacheManager
|
||||
|
||||
|
||||
class TestOneThreadPerDirectory:
|
||||
def test_a_single_manager_starts_one(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
m = manager()
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_three_managers_still_start_one(self, manager):
|
||||
# Exactly the rig's shape: the real manager plus two throwaways.
|
||||
before = len(_live_cleanup_threads())
|
||||
managers = [manager() for _ in range(3)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in managers:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_the_first_one_owns_it(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert second._cleanup_thread is None
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_the_survivor_can_take_over(self, manager):
|
||||
first = manager()
|
||||
first.stop_cleanup_thread()
|
||||
assert not _live_cleanup_threads()
|
||||
|
||||
second = manager()
|
||||
try:
|
||||
# Ownership was released, so the directory is swept again rather
|
||||
# than being left permanently unclaimed by a dead owner.
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
assert CacheManager._cleanup_owners[second.cache_dir] is second
|
||||
finally:
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_stopping_a_non_owner_does_not_unclaim_the_directory(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
second.stop_cleanup_thread() # never owned it
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
|
||||
def test_separate_directories_get_separate_threads(self, tmp_path, monkeypatch):
|
||||
a, b = tmp_path / 'a', tmp_path / 'b'
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
dirs = iter([str(a), str(b)])
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: next(dirs))
|
||||
first, second = CacheManager(), CacheManager()
|
||||
try:
|
||||
assert first.cache_dir != second.cache_dir
|
||||
assert len(_live_cleanup_threads()) == 2
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_no_thread_leaks_across_many_constructions(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
made = [manager() for _ in range(12)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in made:
|
||||
m.stop_cleanup_thread()
|
||||
assert len(_live_cleanup_threads()) == before
|
||||
|
||||
|
||||
class TestValidatorDoesNotBuildItsOwn:
|
||||
def test_it_uses_the_cache_manager_it_is_given(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
shared = manager()
|
||||
try:
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object(), cache_manager=shared)
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"validation started another cleanup thread")
|
||||
finally:
|
||||
shared.stop_cleanup_thread()
|
||||
|
||||
def test_without_one_it_cleans_up_after_itself(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object())
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"the fallback manager left its cleanup thread running")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests that abandoned cache temp files get collected.
|
||||
|
||||
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
|
||||
own temp file in a finally. That covers a failed write, but not a process that
|
||||
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
|
||||
ordinary on a Pi. Nothing collected what was left behind: the temp names are
|
||||
".<key>.json.<random>", and the expiry sweep only listed names ending in
|
||||
.json, so they accumulated for as long as the card had been in service.
|
||||
|
||||
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
|
||||
of the entire cache directory -- the oldest six months old.
|
||||
|
||||
The predicate that decides what to delete is tested harder than the sweep
|
||||
itself, because a false positive here destroys real data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
|
||||
|
||||
|
||||
class FakeStrategy:
|
||||
@staticmethod
|
||||
def get_data_type_from_key(key):
|
||||
return 'default'
|
||||
|
||||
|
||||
POLICIES = {'default': 30}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path):
|
||||
return DiskCache(str(tmp_path))
|
||||
|
||||
|
||||
def _age(path, seconds):
|
||||
old = time.time() - seconds
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
def _write(tmp_path, name, body='{}'):
|
||||
p = tmp_path / name
|
||||
p.write_text(body, encoding='utf-8')
|
||||
return p
|
||||
|
||||
|
||||
class TestWhatCountsAsAnOrphan:
|
||||
@pytest.mark.parametrize('name', [
|
||||
'.weather.json.a1b2c3d4',
|
||||
'.odds_espn_football_nfl_401.json.xyz00000',
|
||||
'.a.json.b',
|
||||
])
|
||||
def test_our_temp_files_are_orphans(self, name):
|
||||
assert DiskCache._is_orphaned_temp(name)
|
||||
|
||||
@pytest.mark.parametrize('name', [
|
||||
'weather.json', # real data
|
||||
'.weather.json', # a dotted key that completed
|
||||
'.gitignore', # not ours
|
||||
'.hidden', # not ours
|
||||
'weather.json.bak', # no leading dot: someone else's
|
||||
'.json.abc', # no key between the dot and .json.
|
||||
'.weather.json.', # no random component
|
||||
'notes.txt',
|
||||
])
|
||||
def test_everything_else_is_left_alone(self, name):
|
||||
assert not DiskCache._is_orphaned_temp(name)
|
||||
|
||||
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
|
||||
"""Guard against the predicate and the writer drifting apart."""
|
||||
created = []
|
||||
real = os.replace
|
||||
|
||||
def capture(src, dst):
|
||||
created.append(os.path.basename(src))
|
||||
return real(src, dst)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
mod.os.replace = capture
|
||||
try:
|
||||
cache.set('weather', {'v': 1})
|
||||
finally:
|
||||
mod.os.replace = real
|
||||
|
||||
assert created, "set() did not go through the temp-file path"
|
||||
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
|
||||
|
||||
|
||||
class TestTheSweep:
|
||||
def test_an_old_orphan_is_removed(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not p.exists()
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
assert stats['space_freed_bytes'] >= 5000
|
||||
|
||||
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
|
||||
# The whole risk of this sweep: deleting a temp file another thread is
|
||||
# about to os.replace into place.
|
||||
p = _write(tmp_path, '.weather.json.inflight')
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert p.exists()
|
||||
|
||||
def test_real_cache_files_survive(self, cache, tmp_path):
|
||||
fresh = _write(tmp_path, 'weather.json')
|
||||
dotted = _write(tmp_path, '.weather.json')
|
||||
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert fresh.exists()
|
||||
assert dotted.exists(), "a completed .json was treated as a temp file"
|
||||
|
||||
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
|
||||
keep = _write(tmp_path, '.gitignore')
|
||||
_age(keep, 400 * 86400)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert keep.exists()
|
||||
|
||||
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
|
||||
stale = _write(tmp_path, 'old.json')
|
||||
_age(stale, 40 * 86400) # past the 30-day default
|
||||
orphan = _write(tmp_path, '.old.json.zz999999')
|
||||
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not stale.exists()
|
||||
assert not orphan.exists()
|
||||
assert stats['files_deleted'] == 2
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
|
||||
def test_the_rig_scenario(self, cache, tmp_path):
|
||||
"""76 orphans of assorted ages, none of them reachable before."""
|
||||
for i in range(76):
|
||||
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
|
||||
_age(p, (i + 2) * 86400)
|
||||
keep = _write(tmp_path, 'sched.json')
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['orphan_temp_files_deleted'] == 76
|
||||
assert keep.exists()
|
||||
assert not list(tmp_path.glob('.sched_*'))
|
||||
# The summary line is "<deleted>/<scanned>", so an orphan that is
|
||||
# deleted but never counted as scanned renders as "76/1".
|
||||
assert stats['files_scanned'] == 77
|
||||
assert stats['files_deleted'] <= stats['files_scanned']
|
||||
|
||||
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.only.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['files_deleted'] == 1
|
||||
assert stats['files_scanned'] == 1
|
||||
|
||||
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
real = mod.os.path.getsize
|
||||
|
||||
def vanish(path):
|
||||
if path.endswith('.a1b2c3d4'):
|
||||
os.remove(path)
|
||||
raise FileNotFoundError(path)
|
||||
return real(path)
|
||||
|
||||
mod.os.path.getsize = vanish
|
||||
try:
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
finally:
|
||||
mod.os.path.getsize = real
|
||||
|
||||
assert stats['errors'] == 0
|
||||
Reference in New Issue
Block a user