Soumik Bose commited on
Commit ·
95d1612
1
Parent(s): 8bb62e2
do
Browse files- controller.py +30 -19
controller.py
CHANGED
|
@@ -10,6 +10,7 @@ import multiprocessing
|
|
| 10 |
import hashlib
|
| 11 |
import json
|
| 12 |
import psutil
|
|
|
|
| 13 |
from typing import List, Optional, Dict, Any, TypeVar, Generic
|
| 14 |
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
| 15 |
from threading import Lock
|
|
@@ -29,7 +30,7 @@ import asyncpg
|
|
| 29 |
import asyncmy
|
| 30 |
from asyncmy.cursors import DictCursor
|
| 31 |
|
| 32 |
-
# --- Database Drivers (
|
| 33 |
from bson import ObjectId
|
| 34 |
import mysql.connector
|
| 35 |
import psycopg2
|
|
@@ -68,7 +69,7 @@ class AsyncPoolManager:
|
|
| 68 |
async def get_pg_pool(self, db_url: str) -> asyncpg.Pool:
|
| 69 |
async with self._lock:
|
| 70 |
if db_url not in self._pg_pools:
|
| 71 |
-
logger.info(f"Creating new AsyncPG pool for: {db_url[:
|
| 72 |
try:
|
| 73 |
# asyncpg handles Keepalives and SSL automatically better than psycopg2
|
| 74 |
pool = await asyncpg.create_pool(
|
|
@@ -85,13 +86,27 @@ class AsyncPoolManager:
|
|
| 85 |
return self._pg_pools[db_url]
|
| 86 |
|
| 87 |
async def get_mysql_pool(self, db_url: str) -> asyncmy.Pool:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
async with self._lock:
|
|
|
|
| 89 |
if db_url not in self._mysql_pools:
|
| 90 |
-
logger.info(f"Creating new AsyncMy pool for: {db_url[:
|
| 91 |
parsed = urlparse(db_url)
|
| 92 |
-
# Check for SSL requirement in query params
|
| 93 |
qs = parse_qs(parsed.query)
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
try:
|
| 97 |
pool = await asyncmy.create_pool(
|
|
@@ -103,12 +118,14 @@ class AsyncPoolManager:
|
|
| 103 |
minsize=1,
|
| 104 |
maxsize=20,
|
| 105 |
autocommit=True,
|
| 106 |
-
pool_recycle=280,
|
|
|
|
| 107 |
)
|
| 108 |
self._mysql_pools[db_url] = pool
|
| 109 |
except Exception as e:
|
| 110 |
logger.error(f"Failed to create MySQL pool: {e}")
|
| 111 |
raise e
|
|
|
|
| 112 |
return self._mysql_pools[db_url]
|
| 113 |
|
| 114 |
async def close_all(self):
|
|
@@ -119,11 +136,9 @@ class AsyncPoolManager:
|
|
| 119 |
pool.close()
|
| 120 |
await pool.wait_closed()
|
| 121 |
|
| 122 |
-
# Initialize Global Async Manager
|
| 123 |
async_pool_manager = AsyncPoolManager()
|
| 124 |
|
| 125 |
def get_dynamic_thread_limit():
|
| 126 |
-
"""Calculates a safe thread limit based on available RAM."""
|
| 127 |
try:
|
| 128 |
total_ram_bytes = psutil.virtual_memory().total
|
| 129 |
total_cores = multiprocessing.cpu_count()
|
|
@@ -141,12 +156,10 @@ def get_dynamic_thread_limit():
|
|
| 141 |
|
| 142 |
@asynccontextmanager
|
| 143 |
async def lifespan(app: FastAPI):
|
| 144 |
-
# Startup
|
| 145 |
safe_limit = get_dynamic_thread_limit()
|
| 146 |
to_thread.current_default_thread_limiter().total_tokens = safe_limit
|
| 147 |
logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}. Async Drivers Ready.")
|
| 148 |
yield
|
| 149 |
-
# Shutdown
|
| 150 |
await async_pool_manager.close_all()
|
| 151 |
|
| 152 |
app = FastAPI(title="Unified Data Executor API", lifespan=lifespan)
|
|
@@ -203,7 +216,6 @@ class RequestCoalescer:
|
|
| 203 |
self._active_requests[key] = future
|
| 204 |
|
| 205 |
try:
|
| 206 |
-
# Works for both async functions and run_in_threadpool
|
| 207 |
result = await func(*args, **kwargs)
|
| 208 |
if not future.done(): future.set_result(result)
|
| 209 |
return result
|
|
@@ -230,10 +242,15 @@ def is_aggregate_query(query: str) -> bool:
|
|
| 230 |
return False
|
| 231 |
|
| 232 |
def normalize_mysql_uri(uri: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
try:
|
| 234 |
parsed_uri = urlparse(uri)
|
| 235 |
query_params = parse_qs(parsed_uri.query)
|
| 236 |
-
|
|
|
|
| 237 |
new_query = urlencode(query_params, doseq=True)
|
| 238 |
parsed_uri = parsed_uri._replace(query=new_query)
|
| 239 |
return urlunparse(parsed_uri)
|
|
@@ -253,7 +270,6 @@ def normalize_postgres_uri(uri: str) -> str:
|
|
| 253 |
async def _execute_async_mysql(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
|
| 254 |
start_time = time.time()
|
| 255 |
try:
|
| 256 |
-
# Get pool
|
| 257 |
pool = await async_pool_manager.get_mysql_pool(db_url)
|
| 258 |
|
| 259 |
async with pool.acquire() as conn:
|
|
@@ -285,7 +301,6 @@ async def _execute_async_mysql(db_url: str, sql_query: str, max_rows: int = 20,
|
|
| 285 |
await cursor.execute(final_query)
|
| 286 |
results = await cursor.fetchall()
|
| 287 |
|
| 288 |
-
# Check actual length
|
| 289 |
if is_limited_result and len(results) < max_rows:
|
| 290 |
is_limited_result = False
|
| 291 |
message = f"Returned {len(results)} rows."
|
|
@@ -333,7 +348,6 @@ async def _execute_async_postgres(db_url: str, sql_query: str, max_rows: int = 2
|
|
| 333 |
is_limited_result = True
|
| 334 |
message = f"Showing first {max_rows} rows."
|
| 335 |
|
| 336 |
-
# Fetch results
|
| 337 |
records = await conn.fetch(final_query)
|
| 338 |
results = [dict(r) for r in records]
|
| 339 |
|
|
@@ -405,7 +419,6 @@ async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(va
|
|
| 405 |
unique_params = {
|
| 406 |
"db": normalized_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
|
| 407 |
}
|
| 408 |
-
# Direct Async Call (No Threadpool)
|
| 409 |
result_dict = await coalescer.execute(
|
| 410 |
"mysql", unique_params, _execute_async_mysql,
|
| 411 |
db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
|
|
@@ -425,7 +438,6 @@ async def execute_postgres_endpoint(query: PgQueryRequest, token: str = Depends(
|
|
| 425 |
unique_params = {
|
| 426 |
"db": clean_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
|
| 427 |
}
|
| 428 |
-
# Direct Async Call (No Threadpool)
|
| 429 |
result_dict = await coalescer.execute(
|
| 430 |
"postgres", unique_params, _execute_async_postgres,
|
| 431 |
db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
|
|
@@ -446,7 +458,6 @@ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(
|
|
| 446 |
"uri": payload.mongo_uri, "db": payload.db_name, "col": payload.collection_name,
|
| 447 |
"q": parsed_query, "lim": payload.limited, "lrows": payload.limit_rows
|
| 448 |
}
|
| 449 |
-
# Mongo is Sync via PyMongo -> Uses run_in_threadpool
|
| 450 |
result_data = await coalescer.execute(
|
| 451 |
"mongo", unique_params, run_in_threadpool, execute_mongo_operation,
|
| 452 |
mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name,
|
|
@@ -558,7 +569,7 @@ async def test_parallel_calc_endpoint(payload: TestCalcRequest):
|
|
| 558 |
|
| 559 |
@app.get("/")
|
| 560 |
async def root():
|
| 561 |
-
return {"message": "Code Execution Server is running"}
|
| 562 |
|
| 563 |
@app.get("/ping")
|
| 564 |
async def ping():
|
|
|
|
| 10 |
import hashlib
|
| 11 |
import json
|
| 12 |
import psutil
|
| 13 |
+
import ssl
|
| 14 |
from typing import List, Optional, Dict, Any, TypeVar, Generic
|
| 15 |
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
| 16 |
from threading import Lock
|
|
|
|
| 30 |
import asyncmy
|
| 31 |
from asyncmy.cursors import DictCursor
|
| 32 |
|
| 33 |
+
# --- Database Drivers (Sync - unused but kept for imports) ---
|
| 34 |
from bson import ObjectId
|
| 35 |
import mysql.connector
|
| 36 |
import psycopg2
|
|
|
|
| 69 |
async def get_pg_pool(self, db_url: str) -> asyncpg.Pool:
|
| 70 |
async with self._lock:
|
| 71 |
if db_url not in self._pg_pools:
|
| 72 |
+
logger.info(f"Creating new AsyncPG pool for: {db_url[:25]}...")
|
| 73 |
try:
|
| 74 |
# asyncpg handles Keepalives and SSL automatically better than psycopg2
|
| 75 |
pool = await asyncpg.create_pool(
|
|
|
|
| 86 |
return self._pg_pools[db_url]
|
| 87 |
|
| 88 |
async def get_mysql_pool(self, db_url: str) -> asyncmy.Pool:
|
| 89 |
+
# Check first without lock for speed
|
| 90 |
+
if db_url in self._mysql_pools:
|
| 91 |
+
return self._mysql_pools[db_url]
|
| 92 |
+
|
| 93 |
async with self._lock:
|
| 94 |
+
# Check again inside lock
|
| 95 |
if db_url not in self._mysql_pools:
|
| 96 |
+
logger.info(f"Creating new AsyncMy pool for: {db_url[:25]}...")
|
| 97 |
parsed = urlparse(db_url)
|
|
|
|
| 98 |
qs = parse_qs(parsed.query)
|
| 99 |
+
|
| 100 |
+
# Determine SSL settings
|
| 101 |
+
# asyncmy needs an SSL context or ssl=True/False
|
| 102 |
+
ssl_ctx = None
|
| 103 |
+
if 'ssl-mode' in qs:
|
| 104 |
+
mode = qs['ssl-mode'][0].upper()
|
| 105 |
+
if mode != 'DISABLED':
|
| 106 |
+
# Create a default SSL context that allows self-signed certs (common in cloud DBs)
|
| 107 |
+
ssl_ctx = ssl.create_default_context()
|
| 108 |
+
ssl_ctx.check_hostname = False
|
| 109 |
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
| 110 |
|
| 111 |
try:
|
| 112 |
pool = await asyncmy.create_pool(
|
|
|
|
| 118 |
minsize=1,
|
| 119 |
maxsize=20,
|
| 120 |
autocommit=True,
|
| 121 |
+
pool_recycle=280,
|
| 122 |
+
ssl=ssl_ctx # Pass the SSL context explicitly
|
| 123 |
)
|
| 124 |
self._mysql_pools[db_url] = pool
|
| 125 |
except Exception as e:
|
| 126 |
logger.error(f"Failed to create MySQL pool: {e}")
|
| 127 |
raise e
|
| 128 |
+
|
| 129 |
return self._mysql_pools[db_url]
|
| 130 |
|
| 131 |
async def close_all(self):
|
|
|
|
| 136 |
pool.close()
|
| 137 |
await pool.wait_closed()
|
| 138 |
|
|
|
|
| 139 |
async_pool_manager = AsyncPoolManager()
|
| 140 |
|
| 141 |
def get_dynamic_thread_limit():
|
|
|
|
| 142 |
try:
|
| 143 |
total_ram_bytes = psutil.virtual_memory().total
|
| 144 |
total_cores = multiprocessing.cpu_count()
|
|
|
|
| 156 |
|
| 157 |
@asynccontextmanager
|
| 158 |
async def lifespan(app: FastAPI):
|
|
|
|
| 159 |
safe_limit = get_dynamic_thread_limit()
|
| 160 |
to_thread.current_default_thread_limiter().total_tokens = safe_limit
|
| 161 |
logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}. Async Drivers Ready.")
|
| 162 |
yield
|
|
|
|
| 163 |
await async_pool_manager.close_all()
|
| 164 |
|
| 165 |
app = FastAPI(title="Unified Data Executor API", lifespan=lifespan)
|
|
|
|
| 216 |
self._active_requests[key] = future
|
| 217 |
|
| 218 |
try:
|
|
|
|
| 219 |
result = await func(*args, **kwargs)
|
| 220 |
if not future.done(): future.set_result(result)
|
| 221 |
return result
|
|
|
|
| 242 |
return False
|
| 243 |
|
| 244 |
def normalize_mysql_uri(uri: str) -> str:
|
| 245 |
+
"""
|
| 246 |
+
Normalizes the URI to ensure consistent cache keys.
|
| 247 |
+
DOES NOT REMOVE parameters (like ssl-mode) blindly, but sorts them.
|
| 248 |
+
"""
|
| 249 |
try:
|
| 250 |
parsed_uri = urlparse(uri)
|
| 251 |
query_params = parse_qs(parsed_uri.query)
|
| 252 |
+
# Re-encode with sorting to ensure ?a=1&b=2 is same as ?b=2&a=1
|
| 253 |
+
# We do NOT remove ssl-mode here anymore, so the pool manager knows about it
|
| 254 |
new_query = urlencode(query_params, doseq=True)
|
| 255 |
parsed_uri = parsed_uri._replace(query=new_query)
|
| 256 |
return urlunparse(parsed_uri)
|
|
|
|
| 270 |
async def _execute_async_mysql(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
|
| 271 |
start_time = time.time()
|
| 272 |
try:
|
|
|
|
| 273 |
pool = await async_pool_manager.get_mysql_pool(db_url)
|
| 274 |
|
| 275 |
async with pool.acquire() as conn:
|
|
|
|
| 301 |
await cursor.execute(final_query)
|
| 302 |
results = await cursor.fetchall()
|
| 303 |
|
|
|
|
| 304 |
if is_limited_result and len(results) < max_rows:
|
| 305 |
is_limited_result = False
|
| 306 |
message = f"Returned {len(results)} rows."
|
|
|
|
| 348 |
is_limited_result = True
|
| 349 |
message = f"Showing first {max_rows} rows."
|
| 350 |
|
|
|
|
| 351 |
records = await conn.fetch(final_query)
|
| 352 |
results = [dict(r) for r in records]
|
| 353 |
|
|
|
|
| 419 |
unique_params = {
|
| 420 |
"db": normalized_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
|
| 421 |
}
|
|
|
|
| 422 |
result_dict = await coalescer.execute(
|
| 423 |
"mysql", unique_params, _execute_async_mysql,
|
| 424 |
db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
|
|
|
|
| 438 |
unique_params = {
|
| 439 |
"db": clean_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
|
| 440 |
}
|
|
|
|
| 441 |
result_dict = await coalescer.execute(
|
| 442 |
"postgres", unique_params, _execute_async_postgres,
|
| 443 |
db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
|
|
|
|
| 458 |
"uri": payload.mongo_uri, "db": payload.db_name, "col": payload.collection_name,
|
| 459 |
"q": parsed_query, "lim": payload.limited, "lrows": payload.limit_rows
|
| 460 |
}
|
|
|
|
| 461 |
result_data = await coalescer.execute(
|
| 462 |
"mongo", unique_params, run_in_threadpool, execute_mongo_operation,
|
| 463 |
mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name,
|
|
|
|
| 569 |
|
| 570 |
@app.get("/")
|
| 571 |
async def root():
|
| 572 |
+
return {"message": "Python Code Execution Server is running"}
|
| 573 |
|
| 574 |
@app.get("/ping")
|
| 575 |
async def ping():
|