feat: implement cache cleanup
This commit is contained in:
parent
d87995c4b0
commit
ea572ec4c5
3 changed files with 115 additions and 0 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__])
|
||||
|
|
|
|||
Loading…
Reference in a new issue