004 Add cache

This commit is contained in:
Раис Юсупалиев
2026-03-07 15:18:29 +03:00
parent 86dbd2bdd6
commit 3fff4b8878
4 changed files with 256 additions and 15 deletions
+155
View File
@@ -0,0 +1,155 @@
import asyncio
from collections.abc import Callable
from app.config import Settings
from app.repositories.cache.redis_cache import PriceCache
class InMemoryRedisClient:
def __init__(self, *, clock: Callable[[], float] | None = None) -> None:
self._clock = clock or (lambda: 0.0)
self._storage: dict[str, tuple[str, float | None]] = {}
self.last_set: tuple[str, str, int | None] | None = None
self.delete_calls: list[str] = []
async def get(self, key: str) -> bytes | None:
cached_entry = self._storage.get(key)
if cached_entry is None:
return None
payload, expires_at = cached_entry
if expires_at is not None and self._clock() >= expires_at:
self._storage.pop(key, None)
return None
return payload.encode("utf-8")
async def set(self, key: str, value: str, ex: int | None = None) -> bool:
expires_at = None if ex is None else self._clock() + ex
self._storage[key] = (value, expires_at)
self.last_set = (key, value, ex)
return True
async def delete(self, key: str) -> int:
existed = key in self._storage
self._storage.pop(key, None)
self.delete_calls.append(key)
return int(existed)
def serialized_value(self, key: str) -> str:
return self._storage[key][0]
def test_cache_miss_returns_none() -> None:
cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120)
result = asyncio.run(cache.get("missing"))
assert result is None
def test_set_get_roundtrip_uses_deterministic_serialization() -> None:
redis_client = InMemoryRedisClient()
cache = PriceCache(redis_client, ttl_seconds=55)
first_payload = {"b": 2, "a": {"d": [3, 1], "c": "x"}}
second_payload = {"a": {"c": "x", "d": [3, 1]}, "b": 2}
async def scenario() -> tuple[object, object]:
await cache.set("first", first_payload)
await cache.set("second", second_payload)
return await cache.get("first"), await cache.get("second")
first_result, second_result = asyncio.run(scenario())
assert first_result == {"a": {"c": "x", "d": [3, 1]}, "b": 2}
assert second_result == {"a": {"c": "x", "d": [3, 1]}, "b": 2}
assert redis_client.serialized_value("first") == redis_client.serialized_value("second")
assert redis_client.last_set == ("second", '{"a":{"c":"x","d":[3,1]},"b":2}', 55)
def test_invalidate_removes_cached_entry() -> None:
redis_client = InMemoryRedisClient()
cache = PriceCache(redis_client, ttl_seconds=300)
async def scenario() -> object | None:
await cache.set("quote", {"price": 1990})
await cache.invalidate("quote")
return await cache.get("quote")
result = asyncio.run(scenario())
assert result is None
assert redis_client.delete_calls == ["quote"]
def test_ttl_expiration_removes_stale_value() -> None:
clock_state = {"now": 100.0}
redis_client = InMemoryRedisClient(clock=lambda: clock_state["now"])
cache = PriceCache(redis_client, ttl_seconds=3)
async def scenario() -> tuple[object | None, object | None]:
await cache.set("quote", {"provider": "cdek"})
before_expiration = await cache.get("quote")
clock_state["now"] = 103.0
after_expiration = await cache.get("quote")
return before_expiration, after_expiration
before_expiration, after_expiration = asyncio.run(scenario())
assert before_expiration == {"provider": "cdek"}
assert after_expiration is None
def test_set_accepts_explicit_ttl_override() -> None:
clock_state = {"now": 10.0}
redis_client = InMemoryRedisClient(clock=lambda: clock_state["now"])
cache = PriceCache(redis_client, ttl_seconds=100)
async def scenario() -> tuple[object | None, object | None]:
await cache.set("quote", {"provider": "cdek"}, ttl=2)
before_expiration = await cache.get("quote")
clock_state["now"] = 12.0
after_expiration = await cache.get("quote")
return before_expiration, after_expiration
before_expiration, after_expiration = asyncio.run(scenario())
assert before_expiration == {"provider": "cdek"}
assert after_expiration is None
assert redis_client.last_set == ("quote", '{"provider":"cdek"}', 2)
def test_repository_yaml_config_sets_redis_connection_and_ttl(
tmp_path, monkeypatch
) -> None:
config_file = tmp_path / "config.yaml"
config_file.write_text(
"""
repository:
redis_dsn: "redis://redis.internal:6380/5"
price_cache_ttl_seconds: 123
""".strip(),
encoding="utf-8",
)
monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file))
settings = Settings()
redis_client = InMemoryRedisClient()
seen_dsns: list[str] = []
def client_factory(redis_dsn: str) -> InMemoryRedisClient:
seen_dsns.append(redis_dsn)
return redis_client
cache = PriceCache.from_repository_config(
settings.repository, client_factory=client_factory
)
async def scenario() -> object | None:
await cache.set("config-key", {"ok": True})
return await cache.get("config-key")
result = asyncio.run(scenario())
assert seen_dsns == ["redis://redis.internal:6380/5"]
assert redis_client.last_set == ("config-key", '{"ok":true}', 123)
assert result == {"ok": True}