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 typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
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):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -347,6 +354,23 @@ class DiskCache:
|
|||||||
"""Get the cache directory path."""
|
"""Get the cache directory path."""
|
||||||
return self.cache_dir
|
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]:
|
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.
|
Clean up expired cache files based on retention policies.
|
||||||
@@ -381,11 +405,50 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Get snapshot of files while holding lock briefly
|
# 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:
|
except OSError as list_error:
|
||||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||||
stats['errors'] += 1
|
stats['errors'] += 1
|
||||||
return stats
|
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
|
# Process files outside the lock to avoid blocking get/set operations
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
|||||||
+4
-43
@@ -46,21 +46,7 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
|||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""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:
|
def __init__(self) -> None:
|
||||||
# Initialize logger first
|
# Initialize logger first
|
||||||
self.logger: logging.Logger = get_logger(__name__)
|
self.logger: logging.Logger = get_logger(__name__)
|
||||||
@@ -732,29 +718,11 @@ class CacheManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def start_cleanup_thread(self) -> None:
|
def start_cleanup_thread(self) -> None:
|
||||||
"""Start background thread for periodic disk cache cleanup.
|
"""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.
|
|
||||||
"""
|
|
||||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread already running")
|
self.logger.debug("Cleanup thread already running")
|
||||||
return
|
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():
|
def cleanup_loop():
|
||||||
"""Background loop that runs cleanup periodically."""
|
"""Background loop that runs cleanup periodically."""
|
||||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
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).
|
Signals the thread to stop and waits for it to finish (with timeout).
|
||||||
This allows for clean shutdown during testing or application termination.
|
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():
|
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread not running")
|
self.logger.debug("Cleanup thread not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.logger.info("Stopping disk cache cleanup thread...")
|
self.logger.info("Stopping disk cache cleanup thread...")
|
||||||
self._cleanup_stop_event.set() # Signal thread to stop
|
self._cleanup_stop_event.set() # Signal thread to stop
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ class DisplayController:
|
|||||||
# Validate startup configuration
|
# Validate startup configuration
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
validator = StartupValidator(self.config_manager,
|
validator = StartupValidator(self.config_manager)
|
||||||
cache_manager=self.cache_manager)
|
|
||||||
is_valid, errors, warnings = validator.validate_all()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
@@ -259,8 +258,7 @@ class DisplayController:
|
|||||||
# Validate plugins after plugin manager is created
|
# Validate plugins after plugin manager is created
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
validator = StartupValidator(self.config_manager, self.plugin_manager)
|
||||||
cache_manager=self.cache_manager)
|
|
||||||
is_valid, errors, warnings = validator.validate_all()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
|
|||||||
@@ -15,23 +15,16 @@ from src.logging_config import get_logger
|
|||||||
class StartupValidator:
|
class StartupValidator:
|
||||||
"""Validates system state on startup."""
|
"""Validates system state on startup."""
|
||||||
|
|
||||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
|
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
||||||
cache_manager: Optional[Any] = None) -> None:
|
|
||||||
"""
|
"""
|
||||||
Initialize the startup validator.
|
Initialize the startup validator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_manager: ConfigManager instance
|
config_manager: ConfigManager instance
|
||||||
plugin_manager: Optional PluginManager 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.config_manager = config_manager
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
self.cache_manager = cache_manager
|
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.errors: List[str] = []
|
self.errors: List[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: List[str] = []
|
||||||
@@ -98,21 +91,9 @@ class StartupValidator:
|
|||||||
def _validate_cache_directory(self) -> None:
|
def _validate_cache_directory(self) -> None:
|
||||||
"""Validate cache directory permissions."""
|
"""Validate cache directory permissions."""
|
||||||
try:
|
try:
|
||||||
cache_manager = self.cache_manager
|
from src.cache_manager import CacheManager
|
||||||
if cache_manager is None:
|
cache_manager = CacheManager()
|
||||||
# No caller supplied one (older embedders, direct use in a
|
cache_dir = cache_manager.get_cache_dir()
|
||||||
# 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()
|
|
||||||
|
|
||||||
if not cache_dir:
|
if not cache_dir:
|
||||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
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