From 14c111cbbdd2f02fcf6232e964f924d8ff78d7d9 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 3 Jul 2026 16:38:48 +0300 Subject: [PATCH 1/3] Create CacheKeyService base methods. --- app/core/config.py | 1 + app/core/redis/cache_keys_service.py | 86 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/core/redis/cache_keys_service.py diff --git a/app/core/config.py b/app/core/config.py index 8e47191..7f26647 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -127,6 +127,7 @@ class NotificationServiceConfig(BaseModel): class Settings(BaseSettings): + service_name: str = "movie-catalog" base_dir: Path = Path(__file__).parent.parent database: DataBaseConfig = DataBaseConfig() redis: RedisConfig = RedisConfig() diff --git a/app/core/redis/cache_keys_service.py b/app/core/redis/cache_keys_service.py new file mode 100644 index 0000000..0ca2916 --- /dev/null +++ b/app/core/redis/cache_keys_service.py @@ -0,0 +1,86 @@ +from core.config import settings +from core.constants import PrimitiveType +from core.redis import RedisService + + +class CacheKeyService: + list_field = "list" + + def __init__(self, redis: RedisService) -> None: + self.redis = redis + + async def __build( + self, + entity: str, + action: str, + entity_id: int | None = None, + **params: PrimitiveType, + ) -> str: + if entity_id is None: + version = await self.__get_version(entity) + if version is None: + await self.__init_version(entity) + key = f"{settings.service_name}:{entity}:{self.list_field}:version:{version}:{action}" + else: + key = f"{settings.service_name}:{entity}:{entity_id}:{action}" + + for parameter_name, value in params.items(): + key = f"{key}:{parameter_name}:{value}" + + return key + + async def __get_version(self, entity: str) -> int: + key = self.__create_version_entity_key(entity) + version = await self.redis.get(key, is_integer=True) + if not await self.redis.exists(key): + raise ValueError + return version + + async def __update_version(self, entity: str) -> None: + key = self.__create_version_entity_key(entity) + await self.redis.incr_by(key, amount=1) + + async def __init_version(self, entity: str) -> None: + key = self.__create_version_entity_key(entity) + await self.redis.set( + key=key, + value=1, + ) + + @classmethod + def __create_version_entity_key(cls, entity: str) -> str: + return f"{settings.service_name}:{cls.list_field}:{entity}:version" + + async def invalidate_list_keys( + self, + entity: str, + ) -> None: + await self.__update_version( + entity=entity, + ) + + async def build_item_key( + self, + entity: str, + action: str, + entity_id: int, + **params: PrimitiveType, + ) -> str: + return await self.__build( + entity=entity, + entity_id=entity_id, + action=action, + **params, + ) + + async def build_list_key( + self, + entity: str, + action: str, + **params: PrimitiveType, + ) -> str: + return await self.__build( + entity=entity, + action=action, + **params, + ) From e0bac04eaa25dbbb94a13a6e194d3c773ba4660d Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 6 Jul 2026 16:03:17 +0300 Subject: [PATCH 2/3] Rework genre caching: use cache versioning. --- app/api/api_v1/genres/list_views.py | 4 +- app/cache_services/genre.py | 82 ++++++++++++------- ...e_keys_service.py => cache_key_service.py} | 67 ++++++--------- app/core/redis/client.py | 7 +- app/core/redis/service.py | 10 ++- app/core/utils.py | 10 ++- app/dependencies/cache_services.py | 8 +- app/services/genre.py | 4 +- 8 files changed, 113 insertions(+), 79 deletions(-) rename app/core/redis/{cache_keys_service.py => cache_key_service.py} (52%) diff --git a/app/api/api_v1/genres/list_views.py b/app/api/api_v1/genres/list_views.py index b0ccf50..4b386db 100644 --- a/app/api/api_v1/genres/list_views.py +++ b/app/api/api_v1/genres/list_views.py @@ -36,12 +36,12 @@ async def get_genres( ], ) async def search_genres_by_name( - genre_name: str, + search_query: str, genre_cache_service: GenreCacheServiceDep, size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> GenreResponseList: - return await genre_cache_service.search_genres_by_name(genre_name, size, page) + return await genre_cache_service.search_genres_by_name(search_query, size, page) @router.post( diff --git a/app/cache_services/genre.py b/app/cache_services/genre.py index 9c2e63a..ca60f05 100644 --- a/app/cache_services/genre.py +++ b/app/cache_services/genre.py @@ -1,5 +1,7 @@ +import asyncio from typing import cast +from core.redis.cache_key_service import CacheKeyService from core.redis.service import RedisService from schemas.genre import ( GenreCreate, @@ -15,19 +17,25 @@ class GenreCacheService: def __init__( self, genre_service: GenreService, - cache_service: RedisService, + redis_service: RedisService, + cache_key_service: CacheKeyService, ) -> None: self.genre_service = genre_service - self.cache_service = cache_service + self.redis_service = redis_service + self.cache_key_service = cache_key_service async def get_genre_by_id(self, genre_id: int) -> GenreResponse: - key = RedisService.create_cache_key("genre", genre_id=genre_id) - cached_genre_response = await self.cache_service.get(key, GenreResponse) + key = self.cache_key_service.build_item_key( + entity="genre", + entity_id=genre_id, + action="get", + ) + cached_genre_response = await self.redis_service.get(key, GenreResponse) if cached_genre_response is not None: return cast(GenreResponse, cached_genre_response) genre_response = await self.genre_service.get_genre_by_id(genre_id) - await self.cache_service.set(key, genre_response, ttl=1800) + await self.redis_service.set(key, genre_response, ttl=24 * 60 * 60) return genre_response async def get_all_genres( @@ -35,48 +43,48 @@ async def get_all_genres( size: int = 10, page: int = 1, ) -> GenreResponseList: - key = RedisService.create_cache_key( - "genres", + key = await self.cache_key_service.build_list_key( + entity="genre", + action="get", size=size, page=page, ) - cached_genres_response = await self.cache_service.get(key, GenreResponseList) + cached_genres_response = await self.redis_service.get(key, GenreResponseList) if cached_genres_response is not None: return cast(GenreResponseList, cached_genres_response) genres_response = await self.genre_service.get_all_genres(size, page) - await self.cache_service.set(key, genres_response, ttl=180) + await self.redis_service.set(key, genres_response, ttl=24 * 60 * 60) return genres_response async def search_genres_by_name( self, - name: str, + search_query: str, size: int = 10, page: int = 1, ) -> GenreResponseList: - key = RedisService.create_cache_key( - "genres", - name=name, + key = await self.cache_key_service.build_list_key( + entity="genre", + action="get", + search_query=search_query, size=size, page=page, ) - cached_genres_response = await self.cache_service.get(key, GenreResponseList) + cached_genres_response = await self.redis_service.get(key, GenreResponseList) if cached_genres_response is not None: return cast(GenreResponseList, cached_genres_response) genres_response = await self.genre_service.search_genres_by_name( - name, + search_query, size, page, ) - await self.cache_service.set(key, genres_response, ttl=180) + await self.redis_service.set(key, genres_response, ttl=24 * 60 * 60) return genres_response async def create_genre(self, create_data: GenreCreate) -> GenreResponse: genre_response = await self.genre_service.create_genre(create_data) - key = RedisService.create_cache_key("genre") - pattern = key + "*" - await self.cache_service.delete_by_pattern(pattern) + await self.cache_key_service.invalidate_list_keys(entity="genre") return genre_response async def update_genre( @@ -85,9 +93,15 @@ async def update_genre( update_data: GenreUpdate, ) -> GenreResponse: genre_response = await self.genre_service.update_genre(genre_id, update_data) - key = RedisService.create_cache_key("genre") - pattern = key + "*" - await self.cache_service.delete_by_pattern(pattern) + key = self.cache_key_service.build_item_key( + entity="genre", + entity_id=genre_response.id, + action="get", + ) + await asyncio.gather( + self.cache_key_service.invalidate_list_keys(entity="genre"), + self.redis_service.delete(key=key), + ) return genre_response async def partial_update_genre( @@ -99,13 +113,25 @@ async def partial_update_genre( genre_id, update_data, ) - key = RedisService.create_cache_key("genre") - pattern = key + "*" - await self.cache_service.delete_by_pattern(pattern) + key = self.cache_key_service.build_item_key( + entity="genre", + entity_id=genre_response.id, + action="get", + ) + await asyncio.gather( + self.cache_key_service.invalidate_list_keys(entity="genre"), + self.redis_service.delete(key=key), + ) return genre_response async def delete_genre_by_id(self, genre_id: int) -> None: await self.genre_service.delete_genre_by_id(genre_id) - key = RedisService.create_cache_key("genre") - pattern = key + "*" - await self.cache_service.delete_by_pattern(pattern) + key = self.cache_key_service.build_item_key( + entity="genre", + entity_id=genre_id, + action="get", + ) + await asyncio.gather( + self.cache_key_service.invalidate_list_keys(entity="genre"), + self.redis_service.delete(key=key), + ) diff --git a/app/core/redis/cache_keys_service.py b/app/core/redis/cache_key_service.py similarity index 52% rename from app/core/redis/cache_keys_service.py rename to app/core/redis/cache_key_service.py index 0ca2916..70aac49 100644 --- a/app/core/redis/cache_keys_service.py +++ b/app/core/redis/cache_key_service.py @@ -1,3 +1,5 @@ +from typing import cast + from core.config import settings from core.constants import PrimitiveType from core.redis import RedisService @@ -9,47 +11,27 @@ class CacheKeyService: def __init__(self, redis: RedisService) -> None: self.redis = redis - async def __build( - self, - entity: str, - action: str, - entity_id: int | None = None, - **params: PrimitiveType, - ) -> str: - if entity_id is None: - version = await self.__get_version(entity) - if version is None: - await self.__init_version(entity) - key = f"{settings.service_name}:{entity}:{self.list_field}:version:{version}:{action}" - else: - key = f"{settings.service_name}:{entity}:{entity_id}:{action}" - - for parameter_name, value in params.items(): - key = f"{key}:{parameter_name}:{value}" - - return key - async def __get_version(self, entity: str) -> int: key = self.__create_version_entity_key(entity) version = await self.redis.get(key, is_integer=True) - if not await self.redis.exists(key): - raise ValueError - return version + return cast(int, version) async def __update_version(self, entity: str) -> None: key = self.__create_version_entity_key(entity) await self.redis.incr_by(key, amount=1) - async def __init_version(self, entity: str) -> None: + async def __init_version(self, entity: str) -> int: key = self.__create_version_entity_key(entity) + start_value = 1 await self.redis.set( key=key, - value=1, + value=start_value, ) + return start_value @classmethod def __create_version_entity_key(cls, entity: str) -> str: - return f"{settings.service_name}:{cls.list_field}:{entity}:version" + return f"{settings.service_name}:version:{cls.list_field}:{entity}" async def invalidate_list_keys( self, @@ -59,28 +41,31 @@ async def invalidate_list_keys( entity=entity, ) - async def build_item_key( - self, + @staticmethod + def build_item_key( entity: str, - action: str, entity_id: int, + action: str = "get", **params: PrimitiveType, ) -> str: - return await self.__build( - entity=entity, - entity_id=entity_id, - action=action, - **params, - ) + key = f"{settings.service_name}:{entity}:{entity_id}:{action}" + for parameter_name, value in params.items(): + key = f"{key}:{parameter_name}:{value}" + + return key async def build_list_key( self, entity: str, - action: str, + action: str = "get", **params: PrimitiveType, ) -> str: - return await self.__build( - entity=entity, - action=action, - **params, - ) + version = await self.__get_version(entity) + if version is None: + version = await self.__init_version(entity) + key = f"{settings.service_name}:{entity}:{self.list_field}:version:{version}:{action}" # noqa: E501 + + for parameter_name, value in params.items(): + key = f"{key}:{parameter_name}:{value}" + + return key diff --git a/app/core/redis/client.py b/app/core/redis/client.py index 6c83a66..4bc776b 100644 --- a/app/core/redis/client.py +++ b/app/core/redis/client.py @@ -38,7 +38,12 @@ async def get(self, key: str) -> str | None: async def exists(self, key: str) -> bool: return cast(bool, await self._redis.exists(key)) - async def set(self, key: str, value: str | int, expire: int) -> None: + async def set( + self, + key: str, + value: str | int, + expire: int | None = None, + ) -> None: await self._redis.set( key, value, diff --git a/app/core/redis/service.py b/app/core/redis/service.py index 75372f1..b70484e 100644 --- a/app/core/redis/service.py +++ b/app/core/redis/service.py @@ -59,7 +59,12 @@ async def _get_boolean(self, key: str) -> bool | None: return boolean[value] return None - async def set(self, key: str, value: Any, ttl: int = 300) -> None: + async def set( + self, + key: str, + value: Any, + ttl: int | None = None, + ) -> None: encoded_value = self.convert_object_to_string(value) await self.redis.set(key, encoded_value, ttl) @@ -87,7 +92,8 @@ def create_cache_key(cls, prefix: str, **kwargs: Any) -> str: @staticmethod def convert_string_to_object( - value: str | int, schema: type[AnyPydanticType], + value: str | int, + schema: type[AnyPydanticType], ) -> AnyPydanticType | PrimitiveType: if schema is None: return value diff --git a/app/core/utils.py b/app/core/utils.py index eca4984..1ea2bbd 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -7,6 +7,7 @@ from cache_services import GenreCacheService, MovieCacheService from core.database import session_factory from core.redis import RedisClient, RedisService +from core.redis.cache_key_service import CacheKeyService from dependencies.redis_client import ( get_genre_redis_client as get_genre_redis_client_dependency, ) @@ -56,9 +57,14 @@ async def get_genre_redis_service() -> AsyncGenerator[RedisService]: async def get_genre_cache_service() -> AsyncGenerator[GenreCacheService]: async with ( get_genre_service() as genre_service, - get_genre_redis_service() as cache_service, + get_genre_redis_service() as redis_service, ): - genre_cache_service = GenreCacheService(genre_service, cache_service) + cache_key_service = CacheKeyService(redis_service) + genre_cache_service = GenreCacheService( + genre_service, + redis_service, + cache_key_service, + ) yield genre_cache_service diff --git a/app/dependencies/cache_services.py b/app/dependencies/cache_services.py index b71f269..c4527fc 100644 --- a/app/dependencies/cache_services.py +++ b/app/dependencies/cache_services.py @@ -11,6 +11,7 @@ UserCacheService, ) from cache_services.watch_history import WatchHistoryCacheService +from core.redis.cache_key_service import CacheKeyService from core.redis.service import RedisService from dependencies.redis_services import ( get_favorite_movie_redis_service, @@ -49,7 +50,12 @@ async def get_genre_cache_service( ], ) -> AsyncGenerator[GenreCacheService]: try: - genre_cache_service = GenreCacheService(genre_service, genre_redis_service) + cache_key_service = CacheKeyService(genre_redis_service) + genre_cache_service = GenreCacheService( + genre_service, + genre_redis_service, + cache_key_service, + ) yield genre_cache_service finally: """ diff --git a/app/services/genre.py b/app/services/genre.py index c4b1a87..e45deff 100644 --- a/app/services/genre.py +++ b/app/services/genre.py @@ -55,14 +55,14 @@ async def get_all_genres( async def search_genres_by_name( self, - name: str, + search_query: str, size: int = 10, page: int = 1, ) -> GenreResponseList: genre_list = [ GenreResponse.model_validate(genre) for genre in await self.genre_repository.search_genres_by_name( - name, + search_query, size, page, ) From cedfdce572e7e59f709106b7fc4309b8aadd0555 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 6 Jul 2026 16:09:21 +0300 Subject: [PATCH 3/3] Small fixes (frontend). --- frontend/app/api/api_v1/genres.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/api/api_v1/genres.js b/frontend/app/api/api_v1/genres.js index f69dd10..8c9326d 100644 --- a/frontend/app/api/api_v1/genres.js +++ b/frontend/app/api/api_v1/genres.js @@ -14,7 +14,7 @@ var p = page != null ? page : 1; var s = size != null ? size : 10; var q = - "genre_name=" + + "search_query=" + encodeURIComponent(name) + "&page=" + encodeURIComponent(p) +