From 3fff4b8878b55bf09f0120e52a5cbc44bd1b508b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 7 Mar 2026 15:18:29 +0300 Subject: [PATCH] 004 Add cache --- app/repositories/cache/redis_cache.py | 106 ++++++++++-- spec/index.md | 8 +- .../004_add_redis_price_cache_repository.md | 2 +- tests/repositories/cache/test_redis_cache.py | 155 ++++++++++++++++++ 4 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 tests/repositories/cache/test_redis_cache.py diff --git a/app/repositories/cache/redis_cache.py b/app/repositories/cache/redis_cache.py index b7d8907..6df7609 100644 --- a/app/repositories/cache/redis_cache.py +++ b/app/repositories/cache/redis_cache.py @@ -1,17 +1,103 @@ -"""Redis cache repository skeleton.""" +"""Redis-backed cache repository.""" -from typing import Any +from dataclasses import asdict, is_dataclass +from decimal import Decimal +from enum import Enum +import json +from typing import Any, Callable, Protocol + +from pydantic import BaseModel + +from app.config import RepositoryConfig + + +class RedisClientProtocol(Protocol): + async def get(self, key: str) -> bytes | str | None: ... + + async def set(self, key: str, value: str, ex: int | None = None) -> Any: ... + + async def delete(self, key: str) -> Any: ... class PriceCache: - async def get(self, key: str) -> Any | None: - _ = key - raise NotImplementedError("Redis repository implementation is added in task 004.") + """Repository for cached provider payloads.""" - async def set(self, key: str, value: Any, ttl: int) -> None: - _ = (key, value, ttl) - raise NotImplementedError("Redis repository implementation is added in task 004.") + def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None: + self._redis_client = redis_client + self._ttl_seconds = ttl_seconds + + @classmethod + def from_repository_config( + cls, + repository_config: RepositoryConfig, + *, + client_factory: Callable[[str], RedisClientProtocol] | None = None, + ) -> "PriceCache": + resolved_factory = client_factory or _default_redis_client_factory + redis_client = resolved_factory(repository_config.redis_dsn) + return cls( + redis_client=redis_client, + ttl_seconds=repository_config.price_cache_ttl_seconds, + ) + + async def get(self, key: str) -> Any | None: + payload = await self._redis_client.get(key) + if payload is None: + return None + + if isinstance(payload, bytes): + serialized_payload = payload.decode("utf-8") + elif isinstance(payload, str): + serialized_payload = payload + else: + raise TypeError("Redis cache payload must be bytes or str.") + + return json.loads(serialized_payload) + + async def set(self, key: str, value: Any, ttl: int | None = None) -> None: + serialized_payload = _serialize(value) + resolved_ttl = self._ttl_seconds if ttl is None else ttl + await self._redis_client.set(key, serialized_payload, ex=resolved_ttl) async def invalidate(self, key: str) -> None: - _ = key - raise NotImplementedError("Redis repository implementation is added in task 004.") + await self._redis_client.delete(key) + + +def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol: + """Create async Redis client from DSN.""" + + try: + import redis.asyncio as redis_asyncio # type: ignore[import-not-found] + except ModuleNotFoundError: + try: + from aioredis import from_url as aioredis_from_url # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + "No async Redis client implementation is available." + ) from exc + return aioredis_from_url(redis_dsn, encoding="utf-8", decode_responses=False) + + return redis_asyncio.from_url(redis_dsn, encoding="utf-8", decode_responses=False) + + +def _serialize(value: Any) -> str: + normalized = _normalize_for_json(value) + return json.dumps(normalized, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def _normalize_for_json(value: Any) -> Any: + if isinstance(value, BaseModel): + return _normalize_for_json(value.model_dump(mode="json")) + if is_dataclass(value): + return _normalize_for_json(asdict(value)) + if isinstance(value, Enum): + return _normalize_for_json(value.value) + if isinstance(value, Decimal): + return str(value) + if isinstance(value, dict): + return {str(key): _normalize_for_json(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize_for_json(item) for item in value] + if value is None or isinstance(value, (str, int, float, bool)): + return value + raise TypeError(f"Unsupported cache payload type: {type(value)!r}") diff --git a/spec/index.md b/spec/index.md index 81ec0b9..5161475 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-07T11:56:55+00:00` +> Generated at (UTC): `2026-03-07T12:18:12+00:00` ## Tasks @@ -11,7 +11,7 @@ | 001 | DONE | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` | | 002 | DONE | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | | 003 | DONE | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | -| 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | +| 004 | DONE | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | | 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | | 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | | 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | @@ -21,5 +21,5 @@ ## Summary - Total: **10** -- TODO: **6** -- DONE: **4** +- TODO: **5** +- DONE: **5** diff --git a/spec/tasks/004_add_redis_price_cache_repository.md b/spec/tasks/004_add_redis_price_cache_repository.md index 0366344..119bd19 100644 --- a/spec/tasks/004_add_redis_price_cache_repository.md +++ b/spec/tasks/004_add_redis_price_cache_repository.md @@ -1,7 +1,7 @@ --- id: 004 title: Add Redis price cache repository -status: TODO +status: DONE created: 2026-03-07 --- diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py new file mode 100644 index 0000000..1e46e38 --- /dev/null +++ b/tests/repositories/cache/test_redis_cache.py @@ -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}