004 Add cache
This commit is contained in:
+96
-10
@@ -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:
|
class PriceCache:
|
||||||
async def get(self, key: str) -> Any | None:
|
"""Repository for cached provider payloads."""
|
||||||
_ = key
|
|
||||||
raise NotImplementedError("Redis repository implementation is added in task 004.")
|
|
||||||
|
|
||||||
async def set(self, key: str, value: Any, ttl: int) -> None:
|
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None:
|
||||||
_ = (key, value, ttl)
|
self._redis_client = redis_client
|
||||||
raise NotImplementedError("Redis repository implementation is added in task 004.")
|
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:
|
async def invalidate(self, key: str) -> None:
|
||||||
_ = key
|
await self._redis_client.delete(key)
|
||||||
raise NotImplementedError("Redis repository implementation is added in task 004.")
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
# Spec Tasks Index
|
# Spec Tasks Index
|
||||||
|
|
||||||
> ⚠️ This file is generated. Do not edit manually.
|
> ⚠️ 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
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` |
|
||||||
@@ -21,5 +21,5 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Total: **10**
|
- Total: **10**
|
||||||
- TODO: **6**
|
- TODO: **5**
|
||||||
- DONE: **4**
|
- DONE: **5**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: 004
|
id: 004
|
||||||
title: Add Redis price cache repository
|
title: Add Redis price cache repository
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-03-07
|
created: 2026-03-07
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+155
@@ -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}
|
||||||
Reference in New Issue
Block a user