feat: implement cache cleanup

This commit is contained in:
arabcoders 2026-01-16 20:42:51 +03:00
parent d87995c4b0
commit ea572ec4c5
3 changed files with 115 additions and 0 deletions

View file

@ -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.")

View file

@ -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)

View file

@ -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__])