Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/db/session.py
2026-04-14 19:34:55 +03:30

427 lines
15 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from collections.abc import Generator
from contextlib import contextmanager
from typing import Callable, Any, Optional
import time
import logging
import traceback
import inspect
from contextvars import ContextVar
from sqlalchemy import create_engine, text, event
from sqlalchemy.exc import OperationalError, TimeoutError as SQLTimeoutError
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session
from sqlalchemy.pool import QueuePool
from app.core.settings import get_settings
logger = logging.getLogger(__name__)
# Context variable برای ذخیره اطلاعات request
_request_context: ContextVar[Optional[dict]] = ContextVar('request_context', default=None)
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_engine(
settings.postgresql_dsn,
echo=settings.sqlalchemy_echo,
poolclass=QueuePool, # استفاده از QueuePool برای بهتر control
pool_pre_ping=True, # بررسی سلامت اتصالات قبل از استفاده
pool_recycle=getattr(settings, 'db_pool_recycle', 1800), # Recycle هر 30 دقیقه - بهینه برای جلوگیری از connection leak و بهبود performance
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
# تنظیمات اضافی برای PostgreSQL
connect_args={
"connect_timeout": 10,
"options": "-c statement_timeout=60000", # Timeout برای query ها (60 ثانیه)
},
# بهینه‌سازی برای Performance
# استفاده از 'rollback' به جای 'commit' برای جلوگیری از connection leak
# 'rollback' اطمینان می‌دهد که transaction های باز بسته می‌شوند
pool_reset_on_return='rollback', # Reset connection بعد از return - جلوگیری از connection leak
isolation_level="READ COMMITTED", # برای بهتر Concurrency
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
# Event Listener برای تنظیمات PostgreSQL Session
@event.listens_for(engine, "connect")
def set_postgresql_session_params(dbapi_conn, connection_record):
"""تنظیمات PostgreSQL برای هر Connection جدید"""
try:
with dbapi_conn.cursor() as cursor:
# تنظیم isolation level برای بهتر Concurrency
cursor.execute("SET SESSION transaction_isolation = 'READ COMMITTED'")
# تنظیم timeout برای query ها (60 ثانیه)
# این باعث می‌شود query های طولانی‌تر از 1 دقیقه timeout شوند
cursor.execute("SET SESSION statement_timeout = 60000")
except Exception as e:
logger.warning(f"Error setting PostgreSQL session variables: {e}")
# Event Listeners برای Monitoring Pool Statistics
@event.listens_for(engine.pool, "connect")
def receive_connect(dbapi_conn, connection_record):
"""Log ایجاد اتصال جدید (فقط در صورت نیاز)"""
# فقط در حالت debug لاگ می‌کنیم
if logger.isEnabledFor(logging.DEBUG):
pool = engine.pool
logger.debug(
f"New database connection created. "
f"Pool size: {pool.size()}, "
f"Checked out: {pool.checkedout()}, "
f"Overflow: {pool.overflow()}"
)
# Rate limiting برای لاگ‌های pool usage
_last_pool_warning_time = 0
_pool_warning_interval = 30 # کاهش به 30 ثانیه برای monitoring بهتر
_last_critical_warning_time = 0
_critical_warning_interval = 10 # برای critical warnings، هر 10 ثانیه
@event.listens_for(engine.pool, "checkout")
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
"""Log checkout اتصال از Pool و ثبت زمان برای leak detection"""
global _last_pool_warning_time, _last_critical_warning_time
pool = engine.pool
total_capacity = pool.size() + pool.overflow()
checked_out = pool.checkedout()
usage_percent = (checked_out / total_capacity * 100) if total_capacity > 0 else 0
# ثبت زمان checkout برای leak detection با اطلاعات کامل
connection_id = id(dbapi_conn)
current_time = time.time()
# گرفتن stack trace برای تشخیص محل checkout
stack_frames = []
try:
stack = inspect.stack()
# گرفتن 15 فریم اول برای پیدا کردن فریم‌های مربوط به کد ما
for frame_info in stack[1:16]:
filename = frame_info.filename
lineno = frame_info.lineno
function = frame_info.function
# فقط فریم‌های مربوط به کد ما را نگه دار (بدون site-packages و __pycache__)
if 'hesabixAPI' in filename and 'site-packages' not in filename and '__pycache__' not in filename:
# تبدیل مسیر کامل به مسیر نسبی
relative_path = filename
if '/var/www/ark/hesabixAPI/' in filename:
relative_path = filename.split('/var/www/ark/hesabixAPI/')[-1]
stack_frames.append(f"{relative_path}:{lineno} in {function}")
stack_trace = "\n".join(stack_frames[:5]) if stack_frames else "No stack frames found"
except Exception as e:
stack_trace = f"Error getting stack trace: {e}"
# گرفتن اطلاعات request از context (اگر در دسترس باشد)
request_info = {}
try:
request_ctx = _request_context.get()
if request_ctx:
request_info = request_ctx.copy()
except Exception:
pass
# ذخیره اطلاعات کامل checkout
_connection_checkout_times[connection_id] = {
'checkout_time': current_time,
'stack_trace': stack_trace,
'request_info': request_info,
'checked_out_count': checked_out,
'usage_percent': usage_percent
}
# Critical warning فقط اگر Pool واقعاً پر باشد (بیش از 95% و حداقل 475 connection)
# این از warning های غیرضروری جلوگیری می‌کند
if usage_percent > 95 and checked_out > 475:
if current_time - _last_critical_warning_time >= _critical_warning_interval:
_last_critical_warning_time = current_time
logger.error(
f"🚨 CRITICAL: Connection pool nearly exhausted! "
f"Usage: {usage_percent:.1f}%. "
f"Pool size: {pool.size()}, "
f"Checked out: {checked_out}, "
f"Overflow: {pool.overflow()}, "
f"Total capacity: {total_capacity}. "
f"Possible connection leak detected!"
)
# Warning اگر Pool بیش از 80% استفاده شده باشد و حداقل 400 connection (با rate limiting)
elif usage_percent > 80 and checked_out > 400:
if current_time - _last_pool_warning_time >= _pool_warning_interval:
_last_pool_warning_time = current_time
logger.warning(
f"⚠️ High connection pool usage: {usage_percent:.1f}%. "
f"Pool size: {pool.size()}, "
f"Checked out: {checked_out}, "
f"Overflow: {pool.overflow()}, "
f"Total capacity: {total_capacity}"
)
# لاگ debug فقط در صورت فعال بودن
# else:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug(
# f"Connection checked out. "
# f"Pool usage: {usage_percent:.1f}%, "
# f"Checked out: {checked_out}/{total_capacity}"
# )
# Monitoring برای connection leak detection
_connection_checkout_times = {} # {connection_id: {'checkout_time': float, 'stack_trace': str, 'request_info': dict}}
_connection_leak_threshold = 120 # 2 دقیقه - برای عملیات طولانی مثل AI streaming که می‌تواند چند دقیقه طول بکشد
_last_leak_check_time = 0
_leak_check_interval = 30 # بررسی leak ها هر 30 ثانیه
def _check_connection_leaks():
"""بررسی connection leak برای اتصالاتی که هنوز checkout هستند"""
global _last_leak_check_time
current_time = time.time()
# فقط هر 30 ثانیه یکبار بررسی کن
if current_time - _last_leak_check_time < _leak_check_interval:
return
_last_leak_check_time = current_time
pool = engine.pool
leaked_connections = []
for connection_id, checkout_info in list(_connection_checkout_times.items()):
if isinstance(checkout_info, dict):
checkout_time = checkout_info.get('checkout_time', current_time)
else:
# برای سازگاری با داده‌های قدیمی
checkout_time = checkout_info if isinstance(checkout_info, (int, float)) else current_time
connection_duration = current_time - checkout_time
if connection_duration > _connection_leak_threshold:
leaked_connections.append((connection_id, checkout_info, connection_duration))
if leaked_connections:
logger.warning(
f"⚠️ Connection leak detected! "
f"Found {len(leaked_connections)} connections checked out for more than {_connection_leak_threshold}s. "
f"Pool size: {pool.size()}, Checked out: {pool.checkedout()}, "
f"Usage: {(pool.checkedout() / (pool.size() + pool.overflow()) * 100) if (pool.size() + pool.overflow()) > 0 else 0:.1f}%"
)
# لاگ جزئیات هر connection leak
for idx, (conn_id, checkout_info, duration) in enumerate(leaked_connections[:5], 1): # فقط 5 مورد اول
if isinstance(checkout_info, dict):
stack_trace = checkout_info.get('stack_trace', 'N/A')
request_info = checkout_info.get('request_info', {})
checked_out_at = checkout_info.get('checked_out_count', 'N/A')
usage_at = checkout_info.get('usage_percent', 'N/A')
logger.warning(
f" 🔍 Leaked Connection #{idx} (ID: {conn_id}):\n"
f" Duration: {duration:.1f}s\n"
f" Checked out when: {checked_out_at} connections active ({usage_at:.1f}% usage)\n"
f" Request: {request_info.get('path', 'N/A')} [{request_info.get('method', 'N/A')}]\n"
f" User ID: {request_info.get('user_id', 'N/A')}\n"
f" Stack trace:\n{stack_trace}"
)
else:
logger.warning(
f" 🔍 Leaked Connection #{idx} (ID: {conn_id}): Duration: {duration:.1f}s (legacy format)"
)
@event.listens_for(engine.pool, "checkin")
def receive_checkin(dbapi_conn, connection_record):
"""Log checkin اتصال به Pool و بررسی connection leak"""
pool = engine.pool
connection_id = id(dbapi_conn)
# بررسی connection leak
if connection_id in _connection_checkout_times:
checkout_info = _connection_checkout_times.pop(connection_id)
# استخراج checkout_time
if isinstance(checkout_info, dict):
checkout_time = checkout_info.get('checkout_time', time.time())
stack_trace = checkout_info.get('stack_trace', 'N/A')
request_info = checkout_info.get('request_info', {})
else:
# برای سازگاری با داده‌های قدیمی
checkout_time = checkout_info if isinstance(checkout_info, (int, float)) else time.time()
stack_trace = 'N/A'
request_info = {}
connection_duration = time.time() - checkout_time
if connection_duration > _connection_leak_threshold:
logger.warning(
f"⚠️ Long-running connection detected! "
f"Connection was checked out for {connection_duration:.1f} seconds "
f"(threshold: {_connection_leak_threshold}s). "
f"Pool size: {pool.size()}, Checked out: {pool.checkedout()}\n"
f" Request: {request_info.get('path', 'N/A')} [{request_info.get('method', 'N/A')}]\n"
f" User ID: {request_info.get('user_id', 'N/A')}\n"
f" Stack trace:\n{stack_trace}"
)
# بررسی leak های موجود
_check_connection_leaks()
# لاگ debug فقط در صورت فعال بودن
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug(
# f"Connection checked in. "
# f"Pool size: {pool.size()}, "
# f"Checked out: {pool.checkedout()}"
# )
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
@contextmanager
def get_db_session(retries: int = 3, delay: float = 1.0):
"""
Context manager برای مدیریت session در background jobs
اطمینان می‌دهد که session همیشه بسته می‌شود حتی در صورت exception
همچنین retry logic برای اتصالات timeout شده دارد
Args:
retries: تعداد تلاش‌های مجدد در صورت خطای اتصال
delay: تأخیر بین تلاش‌ها (ثانیه)
"""
db = None
last_exception = None
for attempt in range(retries):
db = None
try:
# ایجاد session جدید
db = SessionLocal()
# تست اتصال قبل از yield
try:
db.execute(text("SELECT 1"))
except (OperationalError, SQLTimeoutError) as e:
# اگر اتصال برقرار نشد، session را ببند و retry کن
if db:
try:
db.close()
except Exception:
pass
db = None
last_exception = e
if attempt < retries - 1:
wait_time = delay * (2 ** attempt) # exponential backoff
logger.warning(
f"Database connection error (attempt {attempt + 1}/{retries}): {e}. "
f"Retrying in {wait_time:.2f}s..."
)
time.sleep(wait_time)
continue
else:
logger.error(f"Database connection failed after {retries} attempts: {e}")
if db:
try:
db.close()
except Exception:
pass
raise
# اگر اتصال برقرار شد، yield می‌کنیم
try:
yield db
# اگر به اینجا رسیدیم، همه چیز خوب است - commit کن
try:
db.commit()
except Exception:
db.rollback()
raise
# در صورت موفقیت، session را ببند و return کن
try:
db.close()
except Exception:
pass
db = None
return
except (OperationalError, SQLTimeoutError) as e:
# در صورت خطای اتصال در حین اجرا، rollback و retry می‌کنیم
if db:
try:
db.rollback()
except Exception:
pass
try:
db.close()
except Exception:
pass
db = None
last_exception = e
if attempt < retries - 1:
wait_time = delay * (2 ** attempt)
logger.warning(
f"Database operation error (attempt {attempt + 1}/{retries}): {e}. "
f"Retrying in {wait_time:.2f}s..."
)
time.sleep(wait_time)
continue
else:
logger.error(f"Database operation failed after {retries} attempts: {e}")
raise
except Exception as e:
# برای سایر exception ها، rollback و session را ببند
if db:
try:
db.rollback()
except Exception:
pass
try:
db.close()
except Exception:
pass
db = None
raise
except (OperationalError, SQLTimeoutError):
# این exception ها قبلاً handle شده‌اند
if attempt == retries - 1:
# آخرین تلاش ناموفق بود
if db:
try:
db.close()
except Exception:
pass
raise
# برای تلاش‌های بعدی، continue می‌کنیم
continue
except Exception:
# برای سایر exception ها، session را بسته و raise می‌کنیم
if db:
try:
db.close()
except Exception:
pass
raise
finally:
# اطمینان از بسته شدن session در هر صورت
if db:
try:
db.close()
except Exception:
pass
db = None
# اگر به اینجا رسیدیم، همه تلاش‌ها ناموفق بودند
if last_exception:
raise last_exception