Repository: taaylor/yandex_kinoservice
Pull request #64: Feature/Добавляю партиционирование к таблице истории сессий
Author: login: potatoforge
Created: 2025-05-11T14:56:11Z | Merged: 2025-05-13T20:11:53Z

- Реализовал партиционирование с использованием hash. Свой выбор обосновал в Readme.
- Обновил конфигурацию линтеров, чтобы они не проверяли файлы миграций
- Добавил Make файл для удобства (давно мечтал:)) инструкцию также привёл в Readme.


Diff:
diff --git a/.env.example b/.env.example
index 74ddcece..bec61464 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,6 @@
+#LOGGER
+LOG_LOG_LEVEL=DEBUG
+
 #DOCKER
 ##REDIS
 REDIS_HOST=redis
@@ -27,10 +30,12 @@ ELASTIC_PORT=9200
 ##Jaeger
 JAEGER_HOST=jaeger
 JAEGER_PORT=4317
-TRACE_PERCENT=1.0 #Доля запросов попадающий в трейсинг 1.0 - 100%
+#Доля запросов попадающий в трейсинг 1.0 - 100%
+TRACE_PERCENT=1.0
 
 ##APP_TRACING
-TRACING=True ##Включение/Выключение трассировки
+##Включение/Выключение трассировки
+TRACING=True
 
 #------------------------------------------------------------------------------
 # ПЕРЕМЕННЫЕ ДЛЯ ТЕСТОВ
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 62ce997a..3b63ca32 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,23 +5,53 @@ repos:
       - id: check-yaml
       - id: end-of-file-fixer
       - id: trailing-whitespace
+      - id: check-added-large-files
+      - id: check-json
+        exclude: ^backup/elastic_dump/dump/.*\.json$
+      - id: debug-statements
 
   - repo: https://github.com/PyCQA/isort
     rev: 5.13.2
     hooks:
       - id: isort
-        args: [--profile=black, --line-length=100]
+        name: isort (python)
+        args: ["--profile", "black", "--line-length=100"]
 
   - repo: https://github.com/psf/black
     rev: 25.1.0
     hooks:
       - id: black
-        args: [--safe, --quiet, --line-length=100]
         language_version: python3.12
+        args: ["--line-length=100"]
+        exclude: >
+            (?x)^(
+                \.git/.*|
+                \.venv/.*|
+                build/.*|
+                dist/.*|
+                migrations/.*|
+                migration/.*|
+                backup/.*
+            )$
 
   - repo: https://github.com/pycqa/flake8
     rev: 7.1.0
     hooks:
       - id: flake8
-        args: [--max-line-length=100]
         language_version: python3.12
+        args: [
+          "--max-line-length=100",
+          "--extend-ignore=E203,W503",
+          "--per-file-ignores=__init__.py:F401"
+        ]
+        exclude: >
+            (?x)^(
+                \.git/.*|
+                \.venv/.*|
+                build/.*|
+                dist/.*|
+                .*/migrations/.*|
+                .*/migration/.*|
+                services/auth-service/src/migration/.*|
+                backup/.*
+            )$
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..49495687
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,39 @@
+COMPOSE_FILE = docker-compose.yml
+COMPOSE_FILE_DEBUG = docker-compose.debug.yml
+COMPOSE_FILE_TEST = docker-compose-tests.yml
+
+# Можно указать конкретный серви или не указывать и команда будет выполнена для всех сервисов
+# Например: make up-logs srv=auth-api
+srv ?=
+
+# Запуск контейнеров в фоновом режиме
+up:
+	docker-compose -f $(COMPOSE_FILE) up -d --build $(srv)
+
+# Остановка контейнеров
+down:
+	docker-compose -f $(COMPOSE_FILE) down $(srv)
+
+# Остановка контейнеров и удаление волуме
+down-v:
+	docker-compose -f $(COMPOSE_FILE) down -v $(srv)
+
+# Запустить проект и посмотреть логи опредёлнного сервиса
+up-logs:
+	docker-compose -f $(COMPOSE_FILE) up -d --build && docker-compose -f $(COMPOSE_FILE) logs -f $(srv)
+
+# Просмотр логов
+logs:
+	docker-compose -f $(COMPOSE_FILE) logs -f $(srv)
+
+# Запуск тестов async-api
+test-async-api:
+	docker-compose -f $(COMPOSE_FILE_TEST) --profile async-api-test up --build -d
+	docker-compose -f $(COMPOSE_FILE_TEST) logs -f tests-async-api
+	docker-compose -f $(COMPOSE_FILE_TEST) --profile async-api-test down -v
+
+# Запуск тестов auth-api
+test-auth-api:
+	docker-compose -f $(COMPOSE_FILE_TEST) --profile auth-api-test up --build -d
+	docker-compose -f $(COMPOSE_FILE_TEST) logs -f tests-auth-api
+	docker-compose -f $(COMPOSE_FILE_TEST) --profile auth-api-test down -v
diff --git a/README.md b/README.md
index bf4a3467..ad8b7d24 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,11 @@
 ---
 
 ## 🚀 Запуск проекта
-1. Создать `.env` cкопировать содержимое `.env.example` в `.env`
-2. В корневой директории проекта выполнить команду `docker compose up -d --build `
+1. Создайте `.env`, скопировав содержимое `.env.example` в `.env`.
+2. В корневой директории проекта выполните команду:
+   ```bash
+   docker compose up -d --build
+   ```
 
 ---
 
@@ -71,7 +74,7 @@ yandex_kinoservice/
 ├── libs/                       # Общие библиотеки
 │   └── auth_utils/             # Библиотека для авторизации
 │       ├── auth_utils/         # Исходный код библиотеки
-│       └── dist/               # Скомпилированные файлы библиотеки=
+│       └── dist/               # Скомпилированные файлы библиотеки
 └── services/                   # Микросервисы
     ├── async-api/              # Асинхронный API-сервис
     │   ├── src/                # Исходный код сервиса
@@ -167,9 +170,49 @@ yandex_kinoservice/
   * Библиотека **auth_utils** использует только публичный ключ для верификации подписи.
 * Модель фильма была доработана. Фильмы разделены на 3 группы:
   * FREE - доступны всем пользователям, включая неавторизованных
-  * PAID - доступны пользователям обладающим правами CRUD_FILMS / PAID_FILMS
+  * PAID - доступны пользователям, обладающим правами CRUD_FILMS / PAID_FILMS
   * ARCHIVED - доступны пользователям с правами CRUD_FILMS
 
+### Партиционирование PG
+
+В проекте используется **хэш-партиционирование** для таблицы `user_sessions_hist`. Этот метод позволяет равномерно распределять данные между партициями на основе хэш-функции, применяемой к значению ключа партиционирования. В данном случае ключом партиционирования является поле `user_id`.
+
+### Преимущества хэш-партиционирования:
+- **Равномерное распределение данных**: Хэш-функция обеспечивает равномерное распределение записей между партициями, что минимизирует дисбаланс.
+- **Ускорение запросов**: Запросы, использующие ключ партиционирования, выполняются быстрее, так как они обращаются только к одной партиции. В рамках нашего бизнесс процесса получешние истории сессий выполняется для одного пользователя по user_id
+-  В рамках нашего бизнесс процесса получешние истории сессий выполняется для одного пользователя по user_id
+*Масштабируемость**: Легко добавлять новые партиции при увеличении объема данных.
+
+### Результаты тестирования
+
+В ходе тестирования было проверено распределение данных между партициями. Результаты показали равномерное распределение записей:
+
+| Партиция                         | Количество записей |
+| -------------------------------- | ------------------ |
+| **`user_sessions_hist (total)`** | **35491**          |
+| `user_sessions_hist_p0`          | 8935               |
+| `user_sessions_hist_p1`          | 8808               |
+| `user_sessions_hist_p2`          | 8851               |
+| `user_sessions_hist_p3`          | 8897               |
+
+
+**Тестовый запрос на получение данных**
+```sql
+explain analyze select * from session.user_sessions_hist where user_id='5e3047c4-9910-4fbe-b414-bac5ced680ca'
+```
+
+| QUERY PLAN                                                                                                                                                                  |
+| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Index Scan using idx_user_sessions_hist_p2_user_id on user_sessions_hist_p2 user_sessions_hist  (cost=0.29..8.30 rows=1 width=84) (actual time=0.020..0.021 rows=1 loops=1) |
+| Index Cond: (user_id ='5e3047c4-9910-4fbe-b414-bac5ced680ca'::uuid)                                                                                                         |
+| Planning Time: 0.061 ms                                                                                                                                                     |
+| Execution Time: 0.041 ms                                                                                                                                                    |
+
+
+### Вывод
+
+Результаты тестирования подтверждают, что выбранная методика хэш-партиционирования обеспечивает равномерное распределение данных между партициями. Это позволяет эффективно использовать ресурсы базы данных и ускоряет выполнение запросов.
+
 ---
 
 ## Основной сценарий авторизации пользователя
@@ -180,6 +223,45 @@ yandex_kinoservice/
 
 ## 📖 Полезные команды
 
+### Использование Makefile
+Makefile — это инструмент автоматизации, который позволяет упрощать выполнение часто используемых команд. В данном проекте Makefile используется для управления контейнерами и тестами.
+
+Пример вызова команды с параметром:
+```bash
+make up srv=auth-api
+```
+
+#### Список доступных команд
+- Запускает все контейнеры в фоновом режиме с пересборкой образов. Можно указать `srv` для запуска конкретного сервиса.
+  ```bash
+  make up
+  ```
+- Останавливает все контейнеры. Можно указать `srv` для остановки конкретного сервиса.
+  ```bash
+  make down
+  ```
+- Останавливает все контейнеры и удаляет связанные тома. Можно указать `srv` для удаления томов конкретного сервиса.
+  ```bash
+  make down-v
+  ```
+- Запускает контейнеры и сразу выводит логи. Можно указать `srv` для просмотра логов конкретного сервиса.
+  ```bash
+  make up-logs
+  ```
+- Выводит логи всех сервисов или указанного через `srv`.
+  ```bash
+  make logs
+  ```
+- Запускает тесты для сервиса async-api.
+  ```bash
+  make test-async-api
+  ```
+- Запускает тесты для сервиса auth-api.
+  ```bash
+  make test-auth-api
+  ```
+---
+
 ### Запуск тестов
 
 **Запуск тестов async-api:**
diff --git a/docs/test_tools/register_users.py b/docs/test_tools/register_users.py
new file mode 100644
index 00000000..7612e86c
--- /dev/null
+++ b/docs/test_tools/register_users.py
@@ -0,0 +1,44 @@
+import asyncio
+import logging
+
+import aiohttp
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
+logger = logging.getLogger(__name__)
+
+url = "http://localhost/auth/api/v1/sessions/register"
+
+body_template = {
+    "username": "username",
+    "email": "email",
+    "first_name": "string",
+    "last_name": "string",
+    "gender": "MALE",
+    "password": "stringst",
+}
+
+# Ограничение на количество одновременно выполняемых задач
+SEM_LIMIT = 200  # Максимальное количество параллельных задач
+semaphore = asyncio.Semaphore(SEM_LIMIT)
+
+
+async def send_request(session, i):
+    async with semaphore:  # Ограничиваем количество одновременных задач
+        name = f"username.{i}"
+        email = f"email.{i}"
+
+        body = body_template.copy()
+        body["username"] = name
+        body["email"] = email
+
+        async with session.post(url, json=body) as response:
+            logger.info(f"Итерация: {i}, Статус: {response.status}")
+
+
+async def main():
+    async with aiohttp.ClientSession() as session:
+        tasks = [send_request(session, i) for i in range(10000)]
+        await asyncio.gather(*tasks)
+
+
+asyncio.run(main())
diff --git a/poetry.lock b/poetry.lock
index 7df484a5..254d990d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -427,15 +427,15 @@ files = [
 
 [[package]]
 name = "click"
-version = "8.1.8"
+version = "8.2.0"
 description = "Composable command line interface toolkit"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.10"
 groups = ["main"]
 markers = "extra == \"dev\""
 files = [
-    {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
-    {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
+    {file = "click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c"},
+    {file = "click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d"},
 ]
 
 [package.dependencies]
@@ -1582,15 +1582,15 @@ ptyprocess = ">=0.5"
 
 [[package]]
 name = "platformdirs"
-version = "4.3.7"
+version = "4.3.8"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = true
 python-versions = ">=3.9"
 groups = ["main"]
 markers = "extra == \"dev\""
 files = [
-    {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"},
-    {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"},
+    {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
+    {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
 ]
 
 [package.extras]
@@ -2307,15 +2307,15 @@ zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.30.0"
+version = "20.31.2"
 description = "Virtual Python Environment builder"
 optional = true
 python-versions = ">=3.8"
 groups = ["main"]
 markers = "extra == \"dev\""
 files = [
-    {file = "virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6"},
-    {file = "virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8"},
+    {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"},
+    {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"},
 ]
 
 [package.dependencies]
diff --git a/pyproject.toml b/pyproject.toml
index 550c4f6e..7b27da6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,4 +50,13 @@ line_length = 100
 
 [tool.flake8]
 max-line-length = 100
-exclude = [".git", "__pycache__", "venv", ".venv"]
+exclude = [
+    ".git",
+    "__pycache__",
+    "venv",
+    ".venv",
+    "migration",
+    "migrations",
+    "services/auth-service/src/migration",
+    "services/auth-service/src/migrations"
+]
diff --git a/services/async-api/src/core/logger_config.py b/services/async-api/src/core/logger_config.py
index 6aef8457..10ff8562 100644
--- a/services/async-api/src/core/logger_config.py
+++ b/services/async-api/src/core/logger_config.py
@@ -65,6 +65,21 @@ def init_loggind(self) -> "LoggerSettings":
                     "level": self.log_level,
                     "propagate": False,
                 },
+                "sqlalchemy.engine": {
+                    "handlers": self.log_default_handlers,
+                    "level": self.log_level,
+                    "propagate": False,
+                },
+                "sqlalchemy.pool": {
+                    "handlers": self.log_default_handlers,
+                    "level": self.log_level,
+                    "propagate": False,
+                },
+                "alembic": {
+                    "handlers": self.log_default_handlers,
+                    "level": self.log_level,
+                    "propagate": False,
+                },
             },
             "root": {
                 "level": self.log_level,
diff --git a/services/async-api/src/utils/decorators.py b/services/async-api/src/utils/decorators.py
index 7cb294e6..01826cce 100644
--- a/services/async-api/src/utils/decorators.py
+++ b/services/async-api/src/utils/decorators.py
@@ -13,9 +13,9 @@
 logger = logging.getLogger(__name__)
 
 
-def elastic_handler_exeptions[**P, R](
-    func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Coroutine[Any, Any, R | None]]:
+def elastic_handler_exeptions[
+    **P, R
+](func: Callable[P, Coroutine[Any, Any, R]],) -> Callable[P, Coroutine[Any, Any, R | None]]:
     @wraps(func)
     async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
         try:
@@ -39,9 +39,9 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
     return wrapper
 
 
-def redis_handler_exeptions[**P, R](
-    func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Coroutine[Any, Any, R | None]]:
+def redis_handler_exeptions[
+    **P, R
+](func: Callable[P, Coroutine[Any, Any, R]],) -> Callable[P, Coroutine[Any, Any, R | None]]:
     async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
         try:
             return await func(*args, **kwargs)
@@ -64,9 +64,9 @@ def backoff(
     max_attempts: int = 5,
 ):
 
-    def func_wrapper[**P, R](
-        func: Callable[P, Coroutine[Any, Any, R]],
-    ) -> Callable[P, Coroutine[Any, Any, R]]:
+    def func_wrapper[
+        **P, R
+    ](func: Callable[P, Coroutine[Any, Any, R]],) -> Callable[P, Coroutine[Any, Any, R]]:
         @wraps(func)
         async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
             time = start_sleep_time
diff --git a/services/async-api/tests/functional/src/test_filmwork.py b/services/async-api/tests/functional/src/test_filmwork.py
index 6a202baa..86e75b8b 100644
--- a/services/async-api/tests/functional/src/test_filmwork.py
+++ b/services/async-api/tests/functional/src/test_filmwork.py
@@ -58,7 +58,7 @@ async def test_search_validation(
         create_user,
     ):
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         _, status = await make_get_request("/films/search", params=query_data, headers=headers)
         assert status == HTTPStatus.BAD_REQUEST, expected_answer["err_msg"]
 
@@ -93,7 +93,7 @@ async def test_search_limit_results(
         create_user,
     ):
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         await self.prepare_films_data(es_write_data, count=101)
         body, status = await make_get_request("/films/search", params=query_data, headers=headers)
         assert status == HTTPStatus.OK
@@ -142,7 +142,7 @@ async def test_search_by_phrase(
         create_user,
     ):
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
 
         await self.prepare_films_data(es_write_data, count=101)
 
@@ -233,7 +233,7 @@ async def test_films_validation(
         create_user,
     ):
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         _, status = await make_get_request("/films", params=query_data, headers=headers)
         assert status == HTTPStatus.BAD_REQUEST, expected_answer["err_msg"]
 
@@ -277,7 +277,7 @@ async def test_films_sort(
         """Проверка вывода всех фильмов"""
         await self.prepare_films_data(es_write_data, count=10)
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
 
         body, status = await make_get_request("/films", params=query_data, headers=headers)
 
@@ -327,7 +327,7 @@ async def test_films_genre(
     ):
         await self.prepare_films_data(es_write_data, count=10)
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
 
         body, status = await make_get_request("/films", params=query_data, headers=headers)
 
@@ -393,7 +393,7 @@ async def test_films_with_cache(
     ):
         await self.prepare_films_data(es_write_data, count=10)
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_get_request("/films", params=query_data, headers=headers)
 
         cache = await redis_test(
@@ -496,7 +496,7 @@ async def test_film_by_uuid(
         ]
         await es_write_data(es_data, test_conf.elastic.index_films, Mapping.films)
         tokens_auth = await create_user(superuser_flag=True)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_get_request(f"/films/{url_path['UUID']}", headers=headers)
 
         if expected_answer.get("invalid_uuid"):
diff --git a/services/auth-service/src/api/v1/role/schemas.py b/services/auth-service/src/api/v1/role/schemas.py
index ae106e05..c65248d2 100644
--- a/services/auth-service/src/api/v1/role/schemas.py
+++ b/services/auth-service/src/api/v1/role/schemas.py
@@ -1,11 +1,11 @@
-from models.models_types import PermissionEnum
+from auth_utils import Permissions
 from pydantic import BaseModel, Field
 
 
 class Permission(BaseModel):
     """Модель представления права доступа в системе"""
 
-    permission: PermissionEnum = Field(
+    permission: Permissions = Field(
         ..., description="Тип права доступа из предопределенного перечня", example="FREE_FILMS"
     )
     descriptions: str | None = Field(
diff --git a/services/auth-service/src/migration/env.py b/services/auth-service/src/migration/env.py
index 08dc1b5e..83a71db9 100644
--- a/services/auth-service/src/migration/env.py
+++ b/services/auth-service/src/migration/env.py
@@ -4,7 +4,7 @@
 from alembic import context
 from core.config import app_config
 from db.postgres import Base
-from models.models import User, UserCred  # noqa: F401
+from models import models
 from sqlalchemy import pool
 from sqlalchemy.engine import Connection
 from sqlalchemy.ext.asyncio import async_engine_from_config
@@ -24,6 +24,17 @@
 target_metadata = Base.metadata
 
 
+def include_object(object, name, type_, reflected, compare_to):
+    if type_ == "table" and name in {
+        "user_sessions_hist_p0",
+        "user_sessions_hist_p1",
+        "user_sessions_hist_p2",
+        "user_sessions_hist_p3",
+    }:
+        return False
+    return True
+
+
 def run_migrations_offline() -> None:
     """Run migrations in 'offline' mode.
 
@@ -50,7 +61,12 @@ def run_migrations_offline() -> None:
 
 
 def do_run_migrations(connection: Connection) -> None:
-    context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
+    context.configure(
+        connection=connection,
+        target_metadata=target_metadata,
+        include_schemas=True,
+        include_object=include_object,
+    )
 
     with context.begin_transaction():
         context.run_migrations()
diff --git a/services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py b/services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py
index 6393774b..1d7c57b8 100644
--- a/services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py
+++ b/services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py
@@ -20,7 +20,9 @@
 
 def upgrade() -> None:
     """Upgrade schema."""
-    # ### commands auto generated by Alembic - please adjust! ###
+    # Создание основной таблицы
+    op.execute("CREATE SCHEMA IF NOT EXISTS session")
+
     op.create_table(
         "user_sessions",
         sa.Column(
@@ -53,6 +55,7 @@ def upgrade() -> None:
         sa.PrimaryKeyConstraint("session_id"),
         schema="session",
     )
+
     op.create_table(
         "user_sessions_hist",
         sa.Column(
@@ -76,15 +79,73 @@ def upgrade() -> None:
         sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Дата истечения сессии"),
         sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
         sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.PrimaryKeyConstraint("session_id"),
+        sa.PrimaryKeyConstraint("user_id", "session_id"),
+        postgresql_partition_by="HASH (user_id)",
         schema="session",
     )
-    # ### end Alembic commands ###
+
+    # Создание партиций
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS session.user_sessions_hist_p0 PARTITION OF session.user_sessions_hist
+        FOR VALUES WITH (MODULUS 4, REMAINDER 0);
+        """
+    )
+    op.execute(
+        """
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_hist_p0_user_id
+        ON session.user_sessions_hist_p0 (user_id, session_id);
+        """
+    )
+
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS session.user_sessions_hist_p1 PARTITION OF session.user_sessions_hist
+        FOR VALUES WITH (MODULUS 4, REMAINDER 1);
+        """
+    )
+    op.execute(
+        """
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_hist_p1_user_id
+        ON session.user_sessions_hist_p1 (user_id, session_id);
+        """
+    )
+
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS session.user_sessions_hist_p2 PARTITION OF session.user_sessions_hist
+        FOR VALUES WITH (MODULUS 4, REMAINDER 2);
+        """
+    )
+    op.execute(
+        """
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_hist_p2_user_id
+        ON session.user_sessions_hist_p2 (user_id, session_id);
+        """
+    )
+
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS session.user_sessions_hist_p3 PARTITION OF session.user_sessions_hist
+        FOR VALUES WITH (MODULUS 4, REMAINDER 3);
+        """
+    )
+    op.execute(
+        """
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_hist_p3_user_id
+        ON session.user_sessions_hist_p3 (user_id, session_id);
+        """
+    )
 
 
 def downgrade() -> None:
     """Downgrade schema."""
-    # ### commands auto generated by Alembic - please adjust! ###
+    # Удаление партиций
+    op.execute("DROP TABLE IF EXISTS session.user_sessions_hist_p0")
+    op.execute("DROP TABLE IF EXISTS session.user_sessions_hist_p1")
+    op.execute("DROP TABLE IF EXISTS session.user_sessions_hist_p2")
+    op.execute("DROP TABLE IF EXISTS session.user_sessions_hist_p3")
+
+    # Удаление основных таблиц
     op.drop_table("user_sessions_hist", schema="session")
     op.drop_table("user_sessions", schema="session")
-    # ### end Alembic commands ###
diff --git a/services/auth-service/src/migration/versions/c30fd4a67156_check.py b/services/auth-service/src/migration/versions/c30fd4a67156_check.py
new file mode 100644
index 00000000..851f573e
--- /dev/null
+++ b/services/auth-service/src/migration/versions/c30fd4a67156_check.py
@@ -0,0 +1,34 @@
+"""check
+
+Revision ID: c30fd4a67156
+Revises: 715c40ff5e28
+Create Date: 2025-05-12 00:57:22.102030
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "c30fd4a67156"
+down_revision: Union[str, None] = "715c40ff5e28"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "user", sa.Column("menter", sa.String(length=50), nullable=True), schema="profile"
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("user", "menter", schema="profile")
+    # ### end Alembic commands ###
diff --git a/services/auth-service/src/models/models.py b/services/auth-service/src/models/models.py
index aba335ca..2b7b3069 100644
--- a/services/auth-service/src/models/models.py
+++ b/services/auth-service/src/models/models.py
@@ -1,3 +1,4 @@
+import logging
 import uuid
 from datetime import datetime
 
@@ -6,6 +7,8 @@
 from sqlalchemy import DateTime, ForeignKey, PrimaryKeyConstraint, String
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
+logger = logging.getLogger(__name__)
+
 
 class User(Base):
     __tablename__ = "user"
@@ -104,7 +107,7 @@ def __repr__(self):
         return f"<{self.__class__.__name__}(permission={self.permission})>"
 
     def __str__(self):
-        return f"Модель: {self.__class__.__name__}(permission={self.permission})"
+        return f"Модель: {self.__class__.__name__}(permission={self.permission})>"
 
 
 class UserSession(Base):
@@ -130,13 +133,18 @@ class UserSession(Base):
 
 class UserSessionsHist(Base):
     __tablename__ = "user_sessions_hist"
-    __table_args__ = {"schema": "session"}
+    __table_args__ = (
+        {
+            "schema": "session",
+            "postgresql_partition_by": "HASH (user_id)",
+        },
+    )
 
     session_id: Mapped[uuid.UUID] = mapped_column(
         primary_key=True, comment="Уникальный идентификатор сессии"
     )
     user_id: Mapped[uuid.UUID] = mapped_column(
-        nullable=False, comment="Уникальный идентификатор пользователя"
+        nullable=False, primary_key=True, comment="Уникальный идентификатор пользователя"
     )
     user_agent: Mapped[str | None] = mapped_column(
         String(255), comment="Клиентское устройство пользователя"
diff --git a/services/auth-service/src/models/models_types.py b/services/auth-service/src/models/models_types.py
index 23e02bc2..04bc0aef 100644
--- a/services/auth-service/src/models/models_types.py
+++ b/services/auth-service/src/models/models_types.py
@@ -4,10 +4,3 @@
 class GenderEnum(StrEnum):
     MALE = "MALE"
     FEMALE = "FEMALE"
-
-
-class PermissionEnum(StrEnum):
-    CRUD_ROLE = "CRUD_ROLE"
-    ASSIGN_ROLE = "ASSIGN_ROLE"
-    FREE_FILMS = "FREE_FILMS"
-    PAID_FILMS = "PAID_FILMS"
diff --git a/services/auth-service/src/utils/decorators.py b/services/auth-service/src/utils/decorators.py
index 1d24176a..ca92ea3c 100644
--- a/services/auth-service/src/utils/decorators.py
+++ b/services/auth-service/src/utils/decorators.py
@@ -12,9 +12,9 @@
 logger = logging.getLogger(__name__)
 
 
-def redis_handler_exeptions[**P, R](
-    func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Coroutine[Any, Any, R | None]]:
+def redis_handler_exeptions[
+    **P, R
+](func: Callable[P, Coroutine[Any, Any, R]],) -> Callable[P, Coroutine[Any, Any, R | None]]:
     async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
         try:
             return await func(*args, **kwargs)
@@ -42,9 +42,9 @@ def backoff(
     max_attempts: int = 5,
 ):
 
-    def func_wrapper[**P, R](
-        func: Callable[P, Coroutine[Any, Any, R]],
-    ) -> Callable[P, Coroutine[Any, Any, R]]:
+    def func_wrapper[
+        **P, R
+    ](func: Callable[P, Coroutine[Any, Any, R]],) -> Callable[P, Coroutine[Any, Any, R]]:
         @wraps(func)
         async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[AsyncSession, None]:
             time = start_sleep_time
diff --git a/services/auth-service/tests/functional/src/test_roles.py b/services/auth-service/tests/functional/src/test_roles.py
index b8e76e34..8849138b 100644
--- a/services/auth-service/tests/functional/src/test_roles.py
+++ b/services/auth-service/tests/functional/src/test_roles.py
@@ -126,8 +126,8 @@ async def test_get_role_detail(
         await self._create_role_in_db(pg_session=pg_session, role_detail=role_detail)
 
         tokens_auth = await create_user(superuser_flag=query_data.get("superuser"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
-        uri = f"/roles/{query_data.get("path_uuid")}"
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
+        uri = f'/roles/{query_data.get("path_uuid")}'
         body, status = await make_get_request(uri=uri, headers=headers)
         cache_data = await redis_test(
             key=f"role:{role_detail.role}", cached_data=query_data.get("cached_data")
@@ -230,7 +230,7 @@ async def test_get_list_role(
             )
 
         tokens_auth = await create_user(superuser_flag=query_data.get("superuser"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_get_request(uri="/roles", headers=headers)
         cache_data = await redis_test(key="role:all", cached_data=query_data.get("cached_data"))
 
@@ -280,7 +280,7 @@ async def test_create_role(
         role_dict = role_request.model_dump(mode="json")
 
         tokens_auth = await create_user(superuser_flag=query_data.get("superuser"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_post_request(uri="/roles", data=role_dict, headers=headers)
         await asyncio.sleep(0.3)  # делаем паузу чтобы кеш успел положится в redis
         cache_data = await redis_test(
@@ -339,7 +339,7 @@ async def test_update_role(
         ).model_dump(mode="json")
 
         tokens_auth = await create_user(superuser_flag=query_data.get("superuser"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_put_request(
             uri=f"/roles/{role_detail.role}", data=role_update_request, headers=headers
         )
@@ -393,7 +393,7 @@ async def test_delete_role(
         await self._create_role_in_db(pg_session=pg_session, role_detail=role_detail)
 
         tokens_auth = await create_user(superuser_flag=query_data.get("superuser"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
         body, status = await make_delete_request(uri=f"/roles/{role_detail.role}", headers=headers)
         cache_data = await redis_test(key=f"role:{role_detail.role}", cached_data=False)
 
diff --git a/services/auth-service/tests/functional/src/test_session.py b/services/auth-service/tests/functional/src/test_session.py
index dfeadbf8..7584d092 100644
--- a/services/auth-service/tests/functional/src/test_session.py
+++ b/services/auth-service/tests/functional/src/test_session.py
@@ -61,7 +61,7 @@ async def test_login_user(self, make_post_request, create_user):
 
     async def test_refresh(self, make_post_request, create_user):
         tokens_auth = await create_user(superuser_flag=False)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("refresh_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("refresh_token")}'}
 
         body, status = await make_post_request(uri="/sessions/refresh", headers=headers)
 
@@ -76,7 +76,7 @@ async def test_logout(
         pg_session: AsyncSession,
     ):
         tokens_auth = await create_user(superuser_flag=False)
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
 
         body, status = await make_post_request(uri="/sessions/logout", headers=headers)
 
@@ -98,7 +98,7 @@ async def test_logout_all(
         current, _ = await make_post_request(
             uri="/sessions/login", data=request_login.model_dump(mode="json")
         )
-        headers = {"Authorization": f"Bearer {current.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {current.get("access_token")}'}
 
         _, status = await make_post_request(uri="/sessions/logout-all", headers=headers)
 
@@ -118,7 +118,7 @@ async def test_entry_history(
         # cсоздаем множество сессий пользователя
         await make_post_request(uri="/sessions/login", data=request_login.model_dump(mode="json"))
         await make_post_request(uri="/sessions/login", data=request_login.model_dump(mode="json"))
-        headers = {"Authorization": f"Bearer {tokens_auth.get("access_token")}"}
+        headers = {"Authorization": f'Bearer {tokens_auth.get("access_token")}'}
 
         body, status = await make_get_request(uri="/sessions/entry-history", headers=headers)
 


Reviews:
user: login: taaylor
body: Все круто, пятерка с плюсом. 
Интересные фишки.
Спасибо за аналитику очень круто!
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-12T11:23:52Z
user: login: Kirill67tyar
body: Круто реализовано!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T16:56:18Z
user: login: Kirill67tyar
body: Сейчас внимательно ознакомился с PR, выглядит круто! Разобрался в логике добавления записей в партиционные таблицы.
Подскажешь пожалуйста, правильная последовательность действий для добавления партиционирования таблиц по HASH, через алембик:
1 - добавить `"postgresql_partition_by": "HASH (user_id)",` в `__table_args__` модели:
```python
    __table_args__ = (
        {
            "schema": "session",
            "postgresql_partition_by": "HASH (user_id)",
        },
    )
```
2 - сделать композитный первичный ключ для таблицы:
```python
    session_id: Mapped[uuid.UUID] = mapped_column(
        primary_key=True, comment="Уникальный идентификатор сессии"
    )
    user_id: Mapped[uuid.UUID] = mapped_column(
        nullable=False, primary_key=True, comment="Уникальный идентификатор пользователя"
    )

```
3 - сгенерировать ревизию (миграцию):
```bash
alembic revision --autogenerate -m "Add partition"
```
4 - после того как в миграции добавится эта строчка: `    postgresql_partition_by="HASH (user_id)",` написать создание партиций через:
```python
    op.execute(
        """
        CREATE TABLE IF NOT EXISTS session.user_sessions_hist_p0 PARTITION OF session.user_sessions_hist
        FOR VALUES WITH (MODULUS 4, REMAINDER 0);
        """
    )
    op.execute(
        """
        CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_hist_p0_user_id
        ON session.user_sessions_hist_p0 (user_id, session_id);
        """
    )
...
```
5 - добавить фильтр для исключения партиций из автогенераций в `.env.py`:
```python
def include_object(object, name, type_, reflected, compare_to):
    if type_ == "table" and name in {
        "user_sessions_hist_p0",
        "user_sessions_hist_p1",
        "user_sessions_hist_p2",
        "user_sessions_hist_p3",
    }:
        return False
    return True
```
6 - применить миграции:
```bash
alembic upgrade head
```
Это правильная последовательность действий, или я где то ошибся?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T21:58:51Z
user: login: potatoforge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T22:11:26Z

Review comments:
diff_hunk: @@ -76,15 +79,73 @@ def upgrade() -> None:
         sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Дата истечения сессии"),
         sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
         sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.PrimaryKeyConstraint("session_id"),
+        sa.PrimaryKeyConstraint("user_id", "session_id"),
+        postgresql_partition_by="HASH (user_id)",
         schema="session",
     )
-    # ### end Alembic commands ###
+
+    # Создание партиций
+    op.execute(
path: services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py
user: login: Kirill67tyar
body: @Potatoes3212 а ты вручную писал эти запросы, или алембик их сам сгенерировал?
Скорее всего эта часть в миграции чуть выше была сгенерирована автоматически:
```python
 postgresql_partition_by="HASH (user_id)",
```
Благодаря этой части в модели `UserSessionsHist`:
```python
    __table_args__ = (
        {
            "schema": "session",
            "postgresql_partition_by": "HASH (user_id)",
        },
    )
```
А создание партицированных таблиц и уникального индекса к ним, скорее всего в ручную, да?
created_at: 2025-05-16T21:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -76,15 +79,73 @@ def upgrade() -> None:
         sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Дата истечения сессии"),
         sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
         sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.PrimaryKeyConstraint("session_id"),
+        sa.PrimaryKeyConstraint("user_id", "session_id"),
+        postgresql_partition_by="HASH (user_id)",
         schema="session",
     )
-    # ### end Alembic commands ###
+
+    # Создание партиций
+    op.execute(
path: services/auth-service/src/migration/versions/715c40ff5e28_init_session_tabs.py
user: login: potatoforge
body: Да, создание партиций писал вручную. Алембик не умеет создавать партиционированные таблицы. 

Я модифицировал существующую миграцию. По этому не могу сказать добавится строчка про партиционирование автоматом или нет. Но по логике должна. 
created_at: 2025-05-16T22:11:26Z
author_association: COLLABORATOR

PR comments:
user: login: potatoforge
created_at: 2025-05-16T22:19:11Z
body: Ты совершенно верно всё описал. 
Уточню только пару моментов. 

Про добавление записей в партиционированные таблицы можно не думать, когда партиции уже созданы. Этим рулит БД. 

Основная сложность в этой задаче - это выбрать правильную стратегию партиционирования. 
На примере нашей задачи было 3 очевидных варианта разделения: 
1. По дате
2. По типу устройства web/app/tv...
3. По пользователю 

Варианты 1,2 не имеют особого смысла, т.к. мы ищем историю входов для конкретного пользователя в ручке, которая использует эту таблицу.  
Если реализовать варианты 1,2 то бд придётся перебрать все партиции, чтобы убедиться, что в них нет нашего пользователя. 



author_association: COLLABORATOR
user: login: potatoforge
created_at: 2025-05-16T22:20:49Z
body: По поводу пункта 2.
Композитный ключи как таковой не требуется. Требуется, чтобы ключ по которому выполняется партиционирование был PK. По этому в нашем случае получился композитный ключ. session_id + user_id. 
author_association: COLLABORATOR
user: login: potatoforge
created_at: 2025-05-16T22:22:04Z
body: Пункты 5 и 6 можно поменять местами. Разницы не будет. 
Партиционированные таблицы будут мешать при создании следующей автогенерируемой миграции. Т.к. алембик их найдёт и захочет удалить. 
author_association: COLLABORATOR
user: login: taaylor
created_at: 2025-05-17T06:33:49Z
body: Спасибо, очень круто и подробно описали, закрепил чуть лучше у себя в голове!
author_association: OWNER
user: login: Kirill67tyar
created_at: 2025-05-17T07:52:33Z
body: @Potatoes3212 Спасибо большое!
author_association: COLLABORATOR