diff --git a/app/library/cache.py b/app/library/cache.py index 34aa7bbe..cce18362 100644 --- a/app/library/cache.py +++ b/app/library/cache.py @@ -1,10 +1,18 @@ import hashlib +import logging import threading import time from typing import Any +from aiohttp import web + +from app.library.Services import Services + +from .Scheduler import Scheduler from .Singleton import ThreadSafe +LOG = logging.getLogger("cache") + class Cache(metaclass=ThreadSafe): def __init__(self) -> None: @@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe): self._cache: dict[str, tuple[Any, float | None]] = {} self._lock = threading.Lock() + @staticmethod + def get_instance() -> "Cache": + """ + Get the singleton instance of Cache. + + Returns: + Cache: The singleton instance of Cache. + + """ + return Cache() + + def attach(self, _: web.Application) -> None: + Services.get_instance().add("cache", self) + Scheduler.get_instance().add( + timer="* * * * *", + func=self.cleanup, + id=f"{__class__.__name__}.{__class__.cleanup.__name__}", + ) + def set(self, key: str, value: Any, ttl: float | None = None) -> None: """ Synchronously set a value in the cache with an optional time-to-live. @@ -132,3 +159,20 @@ class Cache(metaclass=ThreadSafe): Asynchronously generate a SHA-256 hash for the given input string. """ return self.hash(key=key) + + async def cleanup(self) -> None: + """ + Remove all expired entries from the cache. + Called periodically by the scheduler. + """ + with self._lock: + now = time.time() + expired_keys: list[str] = [ + key for key, (_, expire_at) in self._cache.items() if expire_at is not None and now >= expire_at + ] + + for key in expired_keys: + del self._cache[key] + + if expired_keys: + LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.") diff --git a/app/main.py b/app/main.py index c9860b63..c3b3c036 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ import magic from aiohttp import web from app.library.BackgroundWorker import BackgroundWorker +from app.library.cache import Cache from app.library.conditions import Conditions from app.library.config import Config from app.library.dl_fields import DLFields @@ -110,6 +111,7 @@ class Main: SqliteStore.get_instance(db_path=self._config.db_file).attach(self._app) BackgroundWorker.get_instance().attach(self._app) Scheduler.get_instance().attach(self._app) + Cache.get_instance().attach(self._app) self._socket.attach(self._app) self._http.attach(self._app) diff --git a/app/tests/test_cache.py b/app/tests/test_cache.py index 8b03cda2..b76017b2 100644 --- a/app/tests/test_cache.py +++ b/app/tests/test_cache.py @@ -18,6 +18,7 @@ All cache operations and edge cases are covered. import asyncio import threading import time +from unittest.mock import MagicMock import pytest @@ -255,6 +256,74 @@ class TestCache: retrieved = self.cache.get("object_key") assert retrieved == custom_obj + @pytest.mark.asyncio + async def test_cleanup_removes_expired_entries(self): + """Test that _cleanup removes only expired entries.""" + # Set some keys with different TTLs + self.cache.set("permanent", "value") + self.cache.set("short", "value1", ttl=0.1) + self.cache.set("medium", "value2", ttl=1.0) + + # Wait for short to expire + time.sleep(0.15) + + # Run cleanup + await self.cache.cleanup() + + # Verify only expired key was removed + assert self.cache.get("permanent") == "value", "Should keep permanent key" + assert self.cache.get("medium") == "value2", "Should keep non-expired key" + assert self.cache.get("short") is None, "Should remove expired key" + + @pytest.mark.asyncio + async def test_cleanup_with_no_expired_entries(self): + """Test that _cleanup handles cache with no expired entries.""" + self.cache.set("key1", "value1") + self.cache.set("key2", "value2", ttl=1.0) + + # Run cleanup when nothing is expired + await self.cache.cleanup() + + # Verify all keys still exist + assert self.cache.get("key1") == "value1", "Should keep non-expired key" + assert self.cache.get("key2") == "value2", "Should keep non-expired key" + + @pytest.mark.asyncio + async def test_cleanup_with_empty_cache(self): + """Test that _cleanup handles empty cache without errors.""" + # Clear cache first + self.cache.clear() + + # Should not raise any errors + await self.cache.cleanup() + + @pytest.mark.asyncio + async def test_attach_registers_with_services(self): + """Test that attach method registers cache with Services and schedules cleanup.""" + from app.library.Scheduler import Scheduler + from app.library.Services import Services + + # Reset singletons + Cache._reset_singleton() + Scheduler._reset_singleton() + Services._reset_singleton() + + # Get event loop for scheduler + loop = asyncio.get_event_loop() + + # Create cache and attach + cache = Cache.get_instance() + mock_app = MagicMock() + cache.attach(mock_app) + + # Verify cache is registered with Services + services = Services.get_instance() + assert services.get("cache") is cache, "Should register cache with Services" + + # Verify cleanup job is scheduled + scheduler = Scheduler.get_instance(loop=loop) + assert scheduler.has(f"{Cache.__name__}.{Cache.cleanup.__name__}"), "Should schedule cleanup job" + if __name__ == "__main__": pytest.main([__file__])