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,
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

View File

@@ -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):

View File

@@ -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)):

View File

@@ -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)])

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)])

View File

@@ -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)])

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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")

View File

@@ -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] = {}

View File

@@ -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: