From 94e78a9e77a9c13c50634b4059fee61144882227 Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 18 May 2026 11:12:03 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20s?= =?UTF-8?q?ingleton=20engine=20=D0=B2=20bd,=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=2017=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20make=5Fengine()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bd/__init__.py: добавлены get_engine() и get_session() — один SQLAlchemy engine на весь процесс - make_engine(): pool_size=5, max_overflow=5, pool_timeout=30, pool_recycle=1800 - Все 17 route-файлов: убраны локальные get_session()/make_engine(), импорт из bd - transfer_crud.py: _get_session() → get_session() из bd - init_data_base.py: get_engine() вместо make_engine() - Результат: 78 idle соединений → 1 --- bd/__init__.py | 25 ++++++++++++++++++++++++- route/auth.py | 5 +---- route/auth_utils.py | 5 +---- route/choice_crud.py | 5 +---- route/glomshtok_crud.py | 5 +---- route/groups_crud.py | 5 +---- route/holland_crud.py | 5 +---- route/init_data_base.py | 12 ++++++------ route/klimov_crud.py | 5 +---- route/organizations_crud.py | 5 +---- route/poll_crud.py | 5 +---- route/question_crud.py | 5 +---- route/radar_crud.py | 5 +---- route/response_crud.py | 5 +---- route/scale_crud.py | 5 +---- route/stats_crud.py | 5 +---- route/transfer_crud.py | 14 +++++--------- route/users_crud.py | 5 +---- 18 files changed, 50 insertions(+), 76 deletions(-) diff --git a/bd/__init__.py b/bd/__init__.py index ba28834..2f56e6c 100644 --- a/bd/__init__.py +++ b/bd/__init__.py @@ -47,7 +47,11 @@ def make_engine(): s.DATABASE_URL_syncpg, connect_args=s.PG8000_CONNECT_ARGS, future=True, - pool_pre_ping=True, # проверяет соединение перед каждым запросом + pool_pre_ping=True, + pool_size=5, + max_overflow=5, + pool_timeout=30, + pool_recycle=1800, ) @@ -70,3 +74,22 @@ def wait_for_db(retries: int = 15, delay: float = 2.0) -> None: if attempt < retries: time.sleep(delay) raise RuntimeError("PostgreSQL did not become ready in time.") + +# ── Singleton engine + session factory (один движок на весь процесс) ─────── +_engine = None +_SessionFactory = None + + +def get_engine(): + global _engine + if _engine is None: + _engine = make_engine() + return _engine + + +def get_session(): + global _SessionFactory + if _SessionFactory is None: + from sqlalchemy.orm import sessionmaker + _SessionFactory = sessionmaker(bind=get_engine(), future=True) + return _SessionFactory diff --git a/route/auth.py b/route/auth.py index 747172b..aa82783 100644 --- a/route/auth.py +++ b/route/auth.py @@ -5,7 +5,7 @@ from typing import Optional from datetime import datetime, timezone import uuid -from bd import make_engine +from bd import get_session from bd.tables.users import User from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme @@ -15,9 +15,6 @@ from redis_db import blacklist_token router = APIRouter(tags=["auth"], prefix="/auth") -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) class RegisterIn(BaseModel): diff --git a/route/auth_utils.py b/route/auth_utils.py index 0e7b1d0..7093b70 100644 --- a/route/auth_utils.py +++ b/route/auth_utils.py @@ -9,7 +9,7 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext -from bd import make_engine +from bd import get_session SECRET_KEY: str = os.getenv("SECRET_KEY", "") if not SECRET_KEY: @@ -45,9 +45,6 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) def get_current_user(token: str = Depends(oauth2_scheme)): diff --git a/route/choice_crud.py b/route/choice_crud.py index 07bf2fe..4d4f310 100644 --- a/route/choice_crud.py +++ b/route/choice_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.choice import Choice from bd.tables.question import Question @@ -23,9 +23,6 @@ class ChoiceUpdate(BaseModel): position: Optional[int] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) diff --git a/route/glomshtok_crud.py b/route/glomshtok_crud.py index 5bcc87d..70b93f9 100644 --- a/route/glomshtok_crud.py +++ b/route/glomshtok_crud.py @@ -24,7 +24,7 @@ from fastapi import APIRouter, HTTPException import uuid -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.question import Question from bd.tables.choice import Choice @@ -372,9 +372,6 @@ CHOICE_SCORES: dict[int, float] = { CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/glomshtok", status_code=201) diff --git a/route/groups_crud.py b/route/groups_crud.py index 5afa5a7..5dd2cc2 100644 --- a/route/groups_crud.py +++ b/route/groups_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.group import Group from route.auth_utils import require_admin_key @@ -19,9 +19,6 @@ class GroupUpdate(BaseModel): name_group: Optional[str] = None -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) def _group_dict(grp: Group) -> dict: diff --git a/route/holland_crud.py b/route/holland_crud.py index c5d9fcd..44e7d4a 100644 --- a/route/holland_crud.py +++ b/route/holland_crud.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.question import Question @@ -141,9 +141,6 @@ DIMENSIONS = [ ] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/holland", status_code=201) diff --git a/route/init_data_base.py b/route/init_data_base.py index 5542382..a76dc77 100644 --- a/route/init_data_base.py +++ b/route/init_data_base.py @@ -4,7 +4,7 @@ import importlib from pathlib import Path from typing import List -from bd import Settings, make_engine +from bd import Settings, get_engine from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from pydantic import BaseModel @@ -48,7 +48,7 @@ async def create_tables(): Endpoint вызывается по нажатию кнопки в UI (POST). """ - engine = make_engine() + engine = get_engine() metadatas = collect_metadatas() if not metadatas: @@ -73,7 +73,7 @@ async def create_tables(): @router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)]) async def migrate_cascade_user(): """Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует.""" - engine = make_engine() + engine = get_engine() try: with engine.connect() as conn: conn.execute(text(""" @@ -97,7 +97,7 @@ async def migrate_cascade_user(): @router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)]) async def migrate_users(): """Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют.""" - engine = make_engine() + engine = get_engine() try: with engine.connect() as conn: conn.execute(text(""" @@ -118,7 +118,7 @@ class ClearDBIn(BaseModel): @router.post("/db/migrate", dependencies=[Depends(require_admin_key)]) async def migrate_tables(): """Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые.""" - engine = make_engine() + engine = get_engine() migrations = [ # Убираем старый FK и колонку group.user_id (если остался от прежней схемы) 'ALTER TABLE "group" DROP COLUMN IF EXISTS user_id', @@ -150,7 +150,7 @@ async def clear_tables(payload: ClearDBIn): if not payload.confirm: raise HTTPException(status_code=400, detail="Confirmation required") - engine = make_engine() + engine = get_engine() metadatas = collect_metadatas() if not metadatas: diff --git a/route/klimov_crud.py b/route/klimov_crud.py index 0d8bd3b..68633e7 100644 --- a/route/klimov_crud.py +++ b/route/klimov_crud.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.question import Question from bd.tables.choice import Choice @@ -159,9 +159,6 @@ DIMENSIONS = [ ] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/klimov", status_code=201) diff --git a/route/organizations_crud.py b/route/organizations_crud.py index d96f8e9..2e60004 100644 --- a/route/organizations_crud.py +++ b/route/organizations_crud.py @@ -4,7 +4,7 @@ from typing import Optional import uuid import io -from bd import make_engine +from bd import get_session from bd.tables.organization import Organization from route.auth_utils import require_admin_key @@ -20,9 +20,6 @@ class OrganizationUpdate(BaseModel): -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) def _org_dict(org: Organization) -> dict: diff --git a/route/poll_crud.py b/route/poll_crud.py index 40566e7..304bd2f 100644 --- a/route/poll_crud.py +++ b/route/poll_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from typing import List, Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.question import Question @@ -31,9 +31,6 @@ class PollIn(BaseModel): questions: Optional[List[QuestionIn]] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) diff --git a/route/question_crud.py b/route/question_crud.py index 67194a2..d9506b4 100644 --- a/route/question_crud.py +++ b/route/question_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.question import Question from bd.tables.poll import Poll @@ -25,9 +25,6 @@ class QuestionUpdate(BaseModel): position: Optional[int] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) diff --git a/route/radar_crud.py b/route/radar_crud.py index efc7feb..9826623 100644 --- a/route/radar_crud.py +++ b/route/radar_crud.py @@ -22,7 +22,7 @@ from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.orm import Session as SASession -from bd import make_engine +from bd import get_session from bd.tables.response import Response, Answer from bd.tables.poll import Poll from bd.tables.scale import ScaleDimension, ChoiceScore @@ -32,9 +32,6 @@ from route.radar_svg_gen import save_radar_svg router = APIRouter(tags=["radar"]) -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) # --------------------------------------------------------------------------- diff --git a/route/response_crud.py b/route/response_crud.py index 944221e..aa08e9a 100644 --- a/route/response_crud.py +++ b/route/response_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from typing import List, Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.question import Question @@ -26,9 +26,6 @@ class ResponseIn(BaseModel): answers: List[AnswerIn] -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.post("/{poll_id}/responses", status_code=201) diff --git a/route/scale_crud.py b/route/scale_crud.py index f1efb3c..6a5dbb3 100644 --- a/route/scale_crud.py +++ b/route/scale_crud.py @@ -19,7 +19,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from bd import make_engine +from bd import get_session from bd.tables.poll import Poll from bd.tables.choice import Choice from bd.tables.scale import ScaleDimension, ChoiceScore @@ -27,9 +27,6 @@ from bd.tables.scale import ScaleDimension, ChoiceScore router = APIRouter(tags=["scale"]) -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) # --------------------------------------------------------------------------- diff --git a/route/stats_crud.py b/route/stats_crud.py index ee25764..65ba0ca 100644 --- a/route/stats_crud.py +++ b/route/stats_crud.py @@ -4,7 +4,7 @@ from sqlalchemy import func import json import hashlib -from bd import make_engine +from bd import get_session from bd.tables.response import Response from bd.tables.poll import Poll from bd.tables.group import Group @@ -26,9 +26,6 @@ def _cache_key(*parts) -> str: return hashlib.md5(raw.encode()).hexdigest() -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) @router.get("/summary") diff --git a/route/transfer_crud.py b/route/transfer_crud.py index 37c13db..ce613c0 100644 --- a/route/transfer_crud.py +++ b/route/transfer_crud.py @@ -24,9 +24,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.dialects.postgresql import insert as pg_insert -from sqlalchemy.orm import sessionmaker - -from bd import make_engine +from bd import get_session from bd.tables.organization import Organization from bd.tables.group import Group from bd.tables.poll import Poll @@ -61,8 +59,6 @@ _TABLES: list[tuple[str, object]] = [ ] -def _get_session(): - return sessionmaker(bind=make_engine(), future=True) def _row_to_dict(row) -> dict: @@ -89,7 +85,7 @@ def _row_to_dict(row) -> dict: dependencies=[Depends(require_admin_key)], ) def export_data(): - Session = _get_session() + Session = get_session() buf = io.BytesIO() counts = {} @@ -147,7 +143,7 @@ def import_data(file: UploadFile = File(...)): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") - Session = _get_session() + Session = get_session() result = {"inserted": {}, "skipped": {}} with Session() as session: @@ -218,7 +214,7 @@ backup_router = APIRouter(prefix="/backup", tags=["backup"]) ) def backup_export(): """Идентично /transfer/export, но в имени файла указан префикс backup_.""" - Session = _get_session() + Session = get_session() buf = io.BytesIO() counts = {} @@ -279,7 +275,7 @@ def backup_restore(file: UploadFile = File(...)): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") - Session = _get_session() + Session = get_session() deleted_counts: dict[str, int] = {} inserted_counts: dict[str, int] = {} diff --git a/route/users_crud.py b/route/users_crud.py index 89a2601..8e52097 100644 --- a/route/users_crud.py +++ b/route/users_crud.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, field_validator from typing import Optional import uuid -from bd import make_engine +from bd import get_session from bd.tables.users import User from route.auth_utils import get_current_user, require_admin_key, hash_password @@ -42,9 +42,6 @@ class UserUpdate(BaseModel): organization_id: Optional[str] = None -def get_session(): - from sqlalchemy.orm import sessionmaker - return sessionmaker(bind=make_engine(), future=True) def _user_dict(user: User) -> dict: