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 hashlib
import logging
import threading import threading
import time import time
from typing import Any from typing import Any
from aiohttp import web
from app.library.Services import Services
from .Scheduler import Scheduler
from .Singleton import ThreadSafe from .Singleton import ThreadSafe
LOG = logging.getLogger("cache")
class Cache(metaclass=ThreadSafe): class Cache(metaclass=ThreadSafe):
def __init__(self) -> None: def __init__(self) -> None:
@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe):
self._cache: dict[str, tuple[Any, float | None]] = {} self._cache: dict[str, tuple[Any, float | None]] = {}
self._lock = threading.Lock() 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: 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. 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. Asynchronously generate a SHA-256 hash for the given input string.
""" """
return self.hash(key=key) 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 aiohttp import web
from app.library.BackgroundWorker import BackgroundWorker from app.library.BackgroundWorker import BackgroundWorker
from app.library.cache import Cache
from app.library.conditions import Conditions from app.library.conditions import Conditions
from app.library.config import Config from app.library.config import Config
from app.library.dl_fields import DLFields 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) SqliteStore.get_instance(db_path=self._config.db_file).attach(self._app)
BackgroundWorker.get_instance().attach(self._app) BackgroundWorker.get_instance().attach(self._app)
Scheduler.get_instance().attach(self._app) Scheduler.get_instance().attach(self._app)
Cache.get_instance().attach(self._app)
self._socket.attach(self._app) self._socket.attach(self._app)
self._http.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 asyncio
import threading import threading
import time import time
from unittest.mock import MagicMock
import pytest import pytest
@ -255,6 +256,74 @@ class TestCache:
retrieved = self.cache.get("object_key") retrieved = self.cache.get("object_key")
assert retrieved == custom_obj 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__": if __name__ == "__main__":
pytest.main([__file__]) pytest.main([__file__])