From 81e484d7d10dc8e5139cba6d616e81b9830ccf88 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: Sun, 8 Mar 2026 22:06:40 +0300 Subject: [PATCH] 011 fix redis --- app/config.py | 2 +- app/repositories/cache/redis_cache.py | 27 +++++-- docker-compose.yml | 7 +- poetry.lock | 21 +++++- pyproject.toml | 1 + spec/index.md | 7 +- ...11_migrate_redis_repository_to_aioredis.md | 41 +++++++++++ tests/repositories/cache/test_redis_cache.py | 71 ++++++++++++++++++- 8 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 spec/tasks/011_migrate_redis_repository_to_aioredis.md diff --git a/app/config.py b/app/config.py index de37980..48d32d1 100644 --- a/app/config.py +++ b/app/config.py @@ -13,7 +13,7 @@ from pydantic_settings import ( YamlConfigSettingsSource, ) -DEFAULT_CONFIG_FILE = "config.yaml" +DEFAULT_CONFIG_FILE = "/config.yaml" TEST_CONFIG_FILE = "config.test.yaml" diff --git a/app/repositories/cache/redis_cache.py b/app/repositories/cache/redis_cache.py index 6df7609..3c4276c 100644 --- a/app/repositories/cache/redis_cache.py +++ b/app/repositories/cache/redis_cache.py @@ -19,6 +19,10 @@ class RedisClientProtocol(Protocol): async def delete(self, key: str) -> Any: ... +class PriceCacheRepositoryError(RuntimeError): + """Deterministic repository error raised on Redis operation failures.""" + + class PriceCache: """Repository for cached provider payloads.""" @@ -41,7 +45,10 @@ class PriceCache: ) async def get(self, key: str) -> Any | None: - payload = await self._redis_client.get(key) + try: + payload = await self._redis_client.get(key) + except Exception as exc: + raise PriceCacheRepositoryError("Redis cache read failed.") from exc if payload is None: return None @@ -57,27 +64,33 @@ class PriceCache: 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) + try: + await self._redis_client.set(key, serialized_payload, ex=resolved_ttl) + except Exception as exc: + raise PriceCacheRepositoryError("Redis cache write failed.") from exc async def invalidate(self, key: str) -> None: - await self._redis_client.delete(key) + try: + await self._redis_client.delete(key) + except Exception as exc: + raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc 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] + from aioredis import from_url as aioredis_from_url # type: ignore except ModuleNotFoundError: try: - from aioredis import from_url as aioredis_from_url # type: ignore + import redis.asyncio as redis_asyncio # type: ignore[import-not-found] 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) - return redis_asyncio.from_url(redis_dsn, encoding="utf-8", decode_responses=False) + return aioredis_from_url(redis_dsn, encoding="utf-8", decode_responses=False) def _serialize(value: Any) -> str: diff --git a/docker-compose.yml b/docker-compose.yml index 13fac81..7504d89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,20 @@ services: app: image: yusupal1ev/g2s-aggregator:0.0.0 + container_name: aggregator ports: - "8000:8000" depends_on: - redis - signoz - + volumes: + - ./config.yaml:/config.yaml redis: image: redis:7-alpine + container_name: redis command: ["redis-server", "--save", "", "--appendonly", "no"] ports: - "6379:6379" - signoz: image: signoz/signoz:latest container_name: signoz @@ -22,6 +24,7 @@ services: SIGNOZ_ALERTS_CONTACT_POINT_FILE: /etc/signoz/alerts/signoz_contact_point_telegram.yaml SIGNOZ_ALERTS_RULES_FILE: /etc/signoz/alerts/signoz_alert_rules.yaml ports: + - "8080:8080" - "3301:3301" - "4317:4317" volumes: diff --git a/poetry.lock b/poetry.lock index e725a28..da55cf8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1091,6 +1091,25 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "redis" +version = "7.3.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.10" +files = [ + {file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"}, + {file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"}, +] + +[package.extras] +circuit-breaker = ["pybreaker (>=1.4.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] +otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"] +xxhash = ["xxhash (>=3.6.0,<3.7.0)"] + [[package]] name = "requests" version = "2.32.5" @@ -1573,4 +1592,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.14" -content-hash = "cf3bc6f669ae82e4110ed85fa24db240e9a2ec4f6cc48de1f1a8724937abe889" +content-hash = "954a8ff26e6abe5321bb634e27f6f458037ec59947f24ac7385623e5575fb817" diff --git a/pyproject.toml b/pyproject.toml index deac7fa..00b2f41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ opentelemetry-exporter-otlp = "^1.40.0" opentelemetry-instrumentation-fastapi = "^0.61b0" opentelemetry-instrumentation-httpx = "^0.61b0" pytest = "^9.0.2" +redis = "^7.3.0" [tool.pytest.ini_options] pythonpath = ["."] diff --git a/spec/index.md b/spec/index.md index 14b9439..2c088ad 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-08T13:21:58+00:00` +> Generated at (UTC): `2026-03-08T19:06:27+00:00` ## Tasks @@ -18,9 +18,10 @@ | 008 | DONE | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | | 009 | DONE | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | | 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` | +| 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` | ## Summary -- Total: **11** +- Total: **12** - TODO: **0** -- DONE: **11** +- DONE: **12** diff --git a/spec/tasks/011_migrate_redis_repository_to_aioredis.md b/spec/tasks/011_migrate_redis_repository_to_aioredis.md new file mode 100644 index 0000000..11ee961 --- /dev/null +++ b/spec/tasks/011_migrate_redis_repository_to_aioredis.md @@ -0,0 +1,41 @@ +--- +id: 011 +title: Migrate Redis repository client to aioredis +status: DONE +created: 2026-03-08 +--- + +## Context +Текущая реализация кеш-репозитория использует клиент `redis`. Требуется перейти на `aioredis` для асинхронного доступа к Redis без изменения бизнес-поведения приложения. + +## Goal +Перевести слой Repository (`PriceCache`) и связанные точки инициализации/конфигурации на `aioredis`, сохранив существующий контракт репозитория и runtime-поведение кеша. + +## Constraints +- Изменения только в слоях Repository и инициализации зависимостей, необходимых для подключения клиента. +- Не изменять бизнес-правила, Controller/API-контракты и оркестрацию Service. +- SQL/внешний IO вне Repository и Adapter не добавлять. +- Публичный интерфейс `PriceCache` (`get`, `set`, `invalidate`) должен остаться совместимым. +- Не изменять файлы в `spec/` кроме этой задачи и сгенерированного `spec/index.md`. + +## Acceptance criteria +- В кодовой базе для Redis repository используется `aioredis` вместо `redis`. +- Инициализация Redis-клиента и операции `get/set/invalidate` работают асинхронно через `aioredis`. +- Поведение TTL и сериализации кеша не изменено относительно текущего контракта. +- Конфигурация подключения к Redis продолжает читаться из секции `repository` YAML-конфига. +- Ошибки подключения/операций Redis обрабатываются детерминированно в рамках текущего repository-контракта. + +## Definition of Done +- [ ] Обновлены зависимости проекта для использования `aioredis`. +- [ ] Репозиторий кеша переведен на `aioredis` без изменения публичного интерфейса. +- [ ] Обновлены/добавлены тесты репозитория для нового клиента. +- [ ] Все релевантные тесты проходят. + +## Tests +- Обновить `tests/repositories/cache/test_redis_cache.py` под использование `aioredis`. +- Добавить/обновить тесты на cache hit/miss, `set/get` roundtrip, `invalidate`, TTL expiration. +- Добавить негативный тест на ошибку Redis-клиента при операции чтения или записи. + +## Commands +- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q` +- `poetry run pytest tests/services/test_aggregator.py -q` diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index 1e46e38..01f8050 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -1,8 +1,16 @@ import asyncio from collections.abc import Callable +import sys +import types +import app.config as app_config +import pytest from app.config import Settings -from app.repositories.cache.redis_cache import PriceCache +from app.repositories.cache.redis_cache import ( + PriceCache, + PriceCacheRepositoryError, + _default_redis_client_factory, +) class InMemoryRedisClient: @@ -39,6 +47,28 @@ class InMemoryRedisClient: return self._storage[key][0] +class FailingGetRedisClient: + async def get(self, key: str) -> bytes | None: # noqa: ARG002 + raise RuntimeError("redis get failed") + + async def set(self, key: str, value: str, ex: int | None = None) -> bool: # noqa: ARG002 + return True + + async def delete(self, key: str) -> int: # noqa: ARG002 + return 1 + + +class FailingSetRedisClient: + async def get(self, key: str) -> bytes | None: # noqa: ARG002 + return None + + async def set(self, key: str, value: str, ex: int | None = None) -> bool: # noqa: ARG002 + raise RuntimeError("redis set failed") + + async def delete(self, key: str) -> int: # noqa: ARG002 + return 1 + + def test_cache_miss_returns_none() -> None: cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120) @@ -130,7 +160,7 @@ repository: """.strip(), encoding="utf-8", ) - monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file)) + monkeypatch.setattr(app_config, "_resolve_runtime_config_file", lambda: str(config_file)) settings = Settings() redis_client = InMemoryRedisClient() @@ -153,3 +183,40 @@ repository: assert seen_dsns == ["redis://redis.internal:6380/5"] assert redis_client.last_set == ("config-key", '{"ok":true}', 123) assert result == {"ok": True} + + +def test_default_client_factory_uses_aioredis_from_url(monkeypatch) -> None: + seen: dict[str, object] = {} + expected_client = object() + + def fake_from_url(redis_dsn: str, *, encoding: str, decode_responses: bool) -> object: + seen["redis_dsn"] = redis_dsn + seen["encoding"] = encoding + seen["decode_responses"] = decode_responses + return expected_client + + fake_aioredis_module = types.SimpleNamespace(from_url=fake_from_url) + monkeypatch.setitem(sys.modules, "aioredis", fake_aioredis_module) + + actual_client = _default_redis_client_factory("redis://cache.internal:6381/9") + + assert actual_client is expected_client + assert seen == { + "redis_dsn": "redis://cache.internal:6381/9", + "encoding": "utf-8", + "decode_responses": False, + } + + +def test_get_raises_deterministic_repository_error_on_redis_failure() -> None: + cache = PriceCache(FailingGetRedisClient(), ttl_seconds=10) + + with pytest.raises(PriceCacheRepositoryError, match="Redis cache read failed."): + asyncio.run(cache.get("key")) + + +def test_set_raises_deterministic_repository_error_on_redis_failure() -> None: + cache = PriceCache(FailingSetRedisClient(), ttl_seconds=10) + + with pytest.raises(PriceCacheRepositoryError, match="Redis cache write failed."): + asyncio.run(cache.set("key", {"v": 1}))