fix: единый singleton engine в bd, убраны 17 локальных make_engine()

- 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
This commit is contained in:
jze9
2026-05-18 11:12:03 +00:00
parent 7d90737986
commit 94e78a9e77
18 changed files with 50 additions and 76 deletions

View File

@@ -47,7 +47,11 @@ def make_engine():
s.DATABASE_URL_syncpg, s.DATABASE_URL_syncpg,
connect_args=s.PG8000_CONNECT_ARGS, connect_args=s.PG8000_CONNECT_ARGS,
future=True, 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: if attempt < retries:
time.sleep(delay) time.sleep(delay)
raise RuntimeError("PostgreSQL did not become ready in time.") 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

View File

@@ -5,7 +5,7 @@ from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme 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") router = APIRouter(tags=["auth"], prefix="/auth")
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
class RegisterIn(BaseModel): class RegisterIn(BaseModel):

View File

@@ -9,7 +9,7 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from bd import make_engine from bd import get_session
SECRET_KEY: str = os.getenv("SECRET_KEY", "") SECRET_KEY: str = os.getenv("SECRET_KEY", "")
if not 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) 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)): def get_current_user(token: str = Depends(oauth2_scheme)):

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.choice import Choice from bd.tables.choice import Choice
from bd.tables.question import Question from bd.tables.question import Question
@@ -23,9 +23,6 @@ class ChoiceUpdate(BaseModel):
position: Optional[int] 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)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -24,7 +24,7 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.choice import Choice from bd.tables.choice import Choice
@@ -372,9 +372,6 @@ CHOICE_SCORES: dict[int, float] = {
CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"] 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) @router.post("/glomshtok", status_code=201)

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.group import Group from bd.tables.group import Group
from route.auth_utils import require_admin_key from route.auth_utils import require_admin_key
@@ -19,9 +19,6 @@ class GroupUpdate(BaseModel):
name_group: Optional[str] = None 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: def _group_dict(grp: Group) -> dict:

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question 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) @router.post("/holland", status_code=201)

View File

@@ -4,7 +4,7 @@ import importlib
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from bd import Settings, make_engine from bd import Settings, get_engine
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel from pydantic import BaseModel
@@ -48,7 +48,7 @@ async def create_tables():
Endpoint вызывается по нажатию кнопки в UI (POST). Endpoint вызывается по нажатию кнопки в UI (POST).
""" """
engine = make_engine() engine = get_engine()
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:
@@ -73,7 +73,7 @@ async def create_tables():
@router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)])
async def migrate_cascade_user(): async def migrate_cascade_user():
"""Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует.""" """Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует."""
engine = make_engine() engine = get_engine()
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text(""" conn.execute(text("""
@@ -97,7 +97,7 @@ async def migrate_cascade_user():
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
async def migrate_users(): async def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют.""" """Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
engine = make_engine() engine = get_engine()
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text(""" conn.execute(text("""
@@ -118,7 +118,7 @@ class ClearDBIn(BaseModel):
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
async def migrate_tables(): async def migrate_tables():
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые.""" """Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
engine = make_engine() engine = get_engine()
migrations = [ migrations = [
# Убираем старый FK и колонку group.user_id (если остался от прежней схемы) # Убираем старый FK и колонку group.user_id (если остался от прежней схемы)
'ALTER TABLE "group" DROP COLUMN IF EXISTS 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: if not payload.confirm:
raise HTTPException(status_code=400, detail="Confirmation required") raise HTTPException(status_code=400, detail="Confirmation required")
engine = make_engine() engine = get_engine()
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.choice import Choice 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) @router.post("/klimov", status_code=201)

View File

@@ -4,7 +4,7 @@ from typing import Optional
import uuid import uuid
import io import io
from bd import make_engine from bd import get_session
from bd.tables.organization import Organization from bd.tables.organization import Organization
from route.auth_utils import require_admin_key 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: def _org_dict(org: Organization) -> dict:

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
@@ -31,9 +31,6 @@ class PollIn(BaseModel):
questions: Optional[List[QuestionIn]] 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)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.poll import Poll from bd.tables.poll import Poll
@@ -25,9 +25,6 @@ class QuestionUpdate(BaseModel):
position: Optional[int] 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)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as SASession 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.response import Response, Answer
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.scale import ScaleDimension, ChoiceScore from bd.tables.scale import ScaleDimension, ChoiceScore
@@ -32,9 +32,6 @@ from route.radar_svg_gen import save_radar_svg
router = APIRouter(tags=["radar"]) router = APIRouter(tags=["radar"])
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
@@ -26,9 +26,6 @@ class ResponseIn(BaseModel):
answers: List[AnswerIn] 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) @router.post("/{poll_id}/responses", status_code=201)

View File

@@ -19,7 +19,7 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.choice import Choice from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore from bd.tables.scale import ScaleDimension, ChoiceScore
@@ -27,9 +27,6 @@ from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["scale"]) router = APIRouter(tags=["scale"])
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@ from sqlalchemy import func
import json import json
import hashlib import hashlib
from bd import make_engine from bd import get_session
from bd.tables.response import Response from bd.tables.response import Response
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.group import Group from bd.tables.group import Group
@@ -26,9 +26,6 @@ def _cache_key(*parts) -> str:
return hashlib.md5(raw.encode()).hexdigest() return hashlib.md5(raw.encode()).hexdigest()
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.get("/summary") @router.get("/summary")

View File

@@ -24,9 +24,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import sessionmaker from bd import get_session
from bd import make_engine
from bd.tables.organization import Organization from bd.tables.organization import Organization
from bd.tables.group import Group from bd.tables.group import Group
from bd.tables.poll import Poll 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: def _row_to_dict(row) -> dict:
@@ -89,7 +85,7 @@ def _row_to_dict(row) -> dict:
dependencies=[Depends(require_admin_key)], dependencies=[Depends(require_admin_key)],
) )
def export_data(): def export_data():
Session = _get_session() Session = get_session()
buf = io.BytesIO() buf = io.BytesIO()
counts = {} counts = {}
@@ -147,7 +143,7 @@ def import_data(file: UploadFile = File(...)):
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session() Session = get_session()
result = {"inserted": {}, "skipped": {}} result = {"inserted": {}, "skipped": {}}
with Session() as session: with Session() as session:
@@ -218,7 +214,7 @@ backup_router = APIRouter(prefix="/backup", tags=["backup"])
) )
def backup_export(): def backup_export():
"""Идентично /transfer/export, но в имени файла указан префикс backup_.""" """Идентично /transfer/export, но в имени файла указан префикс backup_."""
Session = _get_session() Session = get_session()
buf = io.BytesIO() buf = io.BytesIO()
counts = {} counts = {}
@@ -279,7 +275,7 @@ def backup_restore(file: UploadFile = File(...)):
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session() Session = get_session()
deleted_counts: dict[str, int] = {} deleted_counts: dict[str, int] = {}
inserted_counts: dict[str, int] = {} inserted_counts: dict[str, int] = {}

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel, field_validator
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import get_current_user, require_admin_key, hash_password 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 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: def _user_dict(user: User) -> dict: