Soumik Bose commited on
Commit
299cfb5
·
1 Parent(s): 693fb3b
Files changed (1) hide show
  1. controller.py +261 -354
controller.py CHANGED
@@ -5,13 +5,14 @@ import time
5
  import uuid
6
  import os
7
  import re
8
- import asyncio # Added for parallel batching
9
- import multiprocessing # Added for worker calculation
10
- import hashlib # Added for Coalescing
11
- import json # Added for Coalescing
12
- import psutil # Added for Dynamic RAM calculation
13
- from typing import List, Optional, Dict, Any, TypeVar, Generic # Added Generic/TypeVar
14
  from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
 
15
 
16
  # --- FastAPI & Core ---
17
  from fastapi import FastAPI, HTTPException, Depends
@@ -19,18 +20,17 @@ from fastapi.encoders import jsonable_encoder
19
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from starlette.concurrency import run_in_threadpool
22
- from anyio import to_thread # Added for AnyIO 4.x concurrency tuning
23
  from dotenv import load_dotenv
24
  from pydantic import BaseModel
25
 
26
- # --- Database Drivers & Pooling ---
27
  from bson import ObjectId
28
  import mysql.connector
29
- from mysql.connector import pooling # For MySQL Pooling
30
  import psycopg2
31
- from psycopg2 import pool as pg_pool # For Postgres Pooling
32
  from psycopg2.extras import RealDictCursor
33
- from threading import Lock # To handle concurrency safely
34
  import uvicorn
35
 
36
  # --- Existing Services ---
@@ -54,56 +54,34 @@ logging.basicConfig(
54
  logger = logging.getLogger("API_Controller")
55
 
56
  def get_dynamic_thread_limit():
57
- """
58
- Calculates a safe thread limit based on available RAM and CPU cores.
59
- Returns an integer safe for to_thread.current_default_thread_limiter().total_tokens
60
- """
61
  try:
62
- # 1. Get System Resources
63
  total_ram_bytes = psutil.virtual_memory().total
64
  total_cores = multiprocessing.cpu_count()
65
-
66
- # 2. Identify how many Uvicorn Workers are running
67
- # Assuming production setup uses: max(1, total_cores - 2)
68
  num_workers = max(1, total_cores - 2)
69
-
70
- # 3. Calculate RAM available PER WORKER
71
  ram_per_worker = total_ram_bytes / num_workers
72
-
73
- # 4. Reserve Buffer (Keep 30% for OS/Python overhead, use 70% for threads)
74
  safe_ram_pool = ram_per_worker * 0.70
75
-
76
- # 5. Estimate Thread Cost (~8MB conservative safety margin)
77
  BYTES_PER_THREAD = 8 * 1024 * 1024
78
-
79
  calculated_limit = int(safe_ram_pool / BYTES_PER_THREAD)
80
-
81
- # 6. Apply Reasonable Hard Caps (Min 100, Max 3000)
82
  final_limit = max(100, min(calculated_limit, 3000))
83
 
84
  logger.info(f"Dynamic Limit Config: {total_ram_bytes/(1024**3):.2f}GB RAM / {num_workers} Workers. Limit: {final_limit}")
85
  return final_limit
86
-
87
  except Exception as e:
88
  logger.warning(f"Failed to calculate dynamic threads ({e}). Fallback to 1000.")
89
  return 1000
90
 
91
  @asynccontextmanager
92
  async def lifespan(app: FastAPI):
93
- # Startup logic: Calculate and set dynamic thread limit
94
  safe_limit = get_dynamic_thread_limit()
95
  to_thread.current_default_thread_limiter().total_tokens = safe_limit
96
  logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}.")
97
  yield
98
- # Shutdown logic (if any) goes here
99
 
100
- app = FastAPI(
101
- title="Unified Data Executor API (Mongo, SQL, CSV)",
102
- lifespan=lifespan
103
- )
104
 
105
  # ==============================================================================
106
- # HIGH-CONCURRENCY BATCH MODELS & STARTUP
107
  # ==============================================================================
108
  T = TypeVar("T")
109
 
@@ -113,11 +91,10 @@ class BatchRequest(BaseModel, Generic[T]):
113
  class BatchResponse(BaseModel, Generic[T]):
114
  responses: List[T]
115
 
116
- # --- Directory Setup ---
117
  CHART_DIR = "generated_charts"
118
  os.makedirs(CHART_DIR, exist_ok=True)
119
 
120
- # --- CORS ---
121
  origins_env = os.getenv("ALLOWED_ORIGINS", "*")
122
  ORIGINS = [origin.strip() for origin in origins_env.split(",")]
123
 
@@ -133,57 +110,13 @@ app.add_middleware(
133
  security = HTTPBearer()
134
  API_SECRET_TOKEN = os.getenv("API_BEARER_TOKEN")
135
 
136
- if not API_SECRET_TOKEN:
137
- logger.warning("WARNING: API_BEARER_TOKEN not set in .env file! Security is compromised.")
138
-
139
  async def validate_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
140
  if credentials.credentials != API_SECRET_TOKEN:
141
  raise HTTPException(status_code=403, detail="Invalid Authentication Token")
142
  return credentials.credentials
143
 
144
-
145
- # ==============================================================================
146
- # PYDANTIC MODELS
147
- # ==============================================================================
148
-
149
- class SqlQueryRequest(BaseModel):
150
- database_url: str
151
- sql_query: str
152
- limit_rows: Optional[int] = 20
153
- limited: bool = False
154
-
155
- class SqlQueryResponse(BaseModel):
156
- success: bool
157
- results: Optional[List[Dict[str, Any]]] = None
158
- columns: Optional[List[str]] = None
159
- rowCount: Optional[int] = 0
160
- executionTime: Optional[float] = 0.0
161
- error: Optional[str] = None
162
- request_id: str
163
- is_aggregate: bool = False
164
- limited: bool = False
165
- message: Optional[str] = None
166
-
167
- class PgQueryRequest(BaseModel):
168
- database_url: str
169
- sql_query: str
170
- limit_rows: Optional[int] = 20
171
- limited: bool = False
172
-
173
- class PgQueryResponse(BaseModel):
174
- success: bool
175
- results: Optional[List[Dict[str, Any]]] = None
176
- columns: Optional[List[str]] = None
177
- rowCount: Optional[int] = 0
178
- executionTime: Optional[float] = 0.0
179
- error: Optional[str] = None
180
- request_id: str
181
- is_aggregate: bool = False
182
- limited: bool = False
183
- message: Optional[str] = None
184
-
185
  # ==============================================================================
186
- # CONNECTION POOL MANAGER
187
  # ==============================================================================
188
  class ConnectionPoolManager:
189
  def __init__(self):
@@ -202,9 +135,14 @@ class ConnectionPoolManager:
202
  "host": parsed.hostname,
203
  "port": parsed.port or 3306,
204
  "database": parsed.path.lstrip("/"),
205
- "connect_timeout": 5
 
206
  }
207
- self._mysql_pools[db_url] = pooling.MySQLConnectionPool(pool_name=str(uuid.uuid4()), pool_size=10, **db_config)
 
 
 
 
208
  return self._mysql_pools[db_url].get_connection()
209
 
210
  def get_postgres_connection(self, db_url: str):
@@ -214,6 +152,7 @@ class ConnectionPoolManager:
214
  parsed = urlparse(db_url)
215
  qs = parse_qs(parsed.query)
216
  sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
 
217
  db_config = {
218
  "host": parsed.hostname,
219
  "port": parsed.port or 5432,
@@ -221,24 +160,25 @@ class ConnectionPoolManager:
221
  "user": parsed.username,
222
  "password": parsed.password,
223
  "sslmode": sslmode,
224
- "connect_timeout": 5
 
 
 
 
 
225
  }
226
  self._pg_pools[db_url] = pg_pool.ThreadedConnectionPool(1, 10, **db_config)
227
  return self._pg_pools[db_url].getconn()
228
 
229
  def return_postgres_connection(self, db_url, conn, close=False):
230
- """
231
- Returns connection to pool.
232
- If close=True, the connection is discarded (used for dead connections).
233
- """
234
  if db_url in self._pg_pools and conn:
235
  self._pg_pools[db_url].putconn(conn, close=close)
236
 
237
  pool_manager = ConnectionPoolManager()
238
 
239
-
240
  # ==============================================================================
241
- # REQUEST COALESCER (SINGLEFLIGHT)
242
  # ==============================================================================
243
  class RequestCoalescer:
244
  def __init__(self):
@@ -252,26 +192,19 @@ class RequestCoalescer:
252
 
253
  async def execute(self, prefix: str, unique_params: dict, func, *args, **kwargs):
254
  key = self._generate_key(prefix, unique_params)
255
-
256
  async with self._lock:
257
  if key in self._active_requests:
258
  return await self._active_requests[key]
259
-
260
  loop = asyncio.get_running_loop()
261
  future = loop.create_future()
262
  self._active_requests[key] = future
263
 
264
  try:
265
- # Execute the function (run_in_threadpool) with args/kwargs
266
  result = await func(*args, **kwargs)
267
-
268
- if not future.done():
269
- future.set_result(result)
270
  return result
271
-
272
  except Exception as e:
273
- if not future.done():
274
- future.set_exception(e)
275
  raise e
276
  finally:
277
  async with self._lock:
@@ -280,18 +213,13 @@ class RequestCoalescer:
280
 
281
  coalescer = RequestCoalescer()
282
 
283
-
284
  # ==============================================================================
285
- # DB EXECUTION LOGIC
286
  # ==============================================================================
287
 
288
  def is_aggregate_query(query: str) -> bool:
289
  query_lower = query.lower()
290
- aggregate_patterns = [
291
- r'\bcount\s*\(', r'\bsum\s*\(', r'\bavg\s*\(',
292
- r'\bmin\s*\(', r'\bmax\s*\(', r'\bgroup\s+by\b',
293
- r'\bdistinct\b', r'\bhaving\b'
294
- ]
295
  for pattern in aggregate_patterns:
296
  if re.search(pattern, query_lower):
297
  return True
@@ -317,218 +245,200 @@ def normalize_postgres_uri(uri: str) -> str:
317
  except Exception:
318
  return uri
319
 
 
320
  def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
321
  start_time = time.time()
322
- connection = None
323
- cursor = None
324
- response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
325
 
326
- # Flag for bad connections
327
- connection_broken = False
 
 
 
 
 
 
 
 
328
 
329
- try:
330
- connection = pool_manager.get_mysql_connection(db_url)
331
- cursor = connection.cursor(dictionary=True)
332
-
333
- clean_query = sql_query.strip()
334
- query_lower = clean_query.lower()
335
-
336
- if not query_lower.startswith("select"):
337
- cursor.execute(clean_query)
338
- connection.commit()
339
- response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
340
- return response
341
-
342
- if not limited:
343
- cursor.execute(clean_query)
344
- results = cursor.fetchall()
345
- response["message"] = f"Raw query executed. Returned {len(results)} row(s)."
346
- response["limited"] = False
347
- response["is_aggregate"] = is_aggregate_query(clean_query)
348
- else:
349
- if is_aggregate_query(clean_query):
350
  cursor.execute(clean_query)
351
  results = cursor.fetchall()
352
- response["message"] = f"Aggregate query completed. Returned {len(results)} row(s)."
353
- response["is_aggregate"] = True
 
354
  else:
355
- final_query = clean_query.rstrip(';').strip()
356
- if not re.search(r'\blimit\s+\d+', query_lower):
357
- final_query = f"{final_query} LIMIT {max_rows}"
358
-
359
- cursor.execute(final_query)
360
- results = cursor.fetchall()
361
- is_limited_result = (len(results) == max_rows)
362
- response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
363
- response["limited"] = is_limited_result
364
- response["is_aggregate"] = False
365
-
366
- columns = [col[0] for col in cursor.description] if cursor.description else []
367
- response.update({"success": True, "results": jsonable_encoder(results), "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
368
- return response
369
-
370
- except Exception as e:
371
- # Detect broken pipe or connection lost errors in MySQL
372
- err_str = str(e).lower()
373
- if "lost connection" in err_str or "gone away" in err_str or isinstance(e, mysql.connector.errors.OperationalError):
374
- connection_broken = True
375
-
376
- response["error"] = str(e)
377
- return response
378
- finally:
379
- try:
380
- if cursor: cursor.close()
381
- except: pass
382
-
383
- if connection:
384
- if connection_broken:
385
- # Discard dead connection (do NOT return to pool)
386
- try: connection.close()
387
  except: pass
388
- else:
389
- # Return healthy connection to pool
390
- connection.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
 
392
  def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
393
  start_time = time.time()
394
- connection = None
395
- cursor = None
396
- response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
397
 
398
- # Flag to determine if connection is dead
399
- connection_broken = False
400
-
401
- try:
402
- connection = pool_manager.get_postgres_connection(db_url)
403
- cursor = connection.cursor(cursor_factory=RealDictCursor)
404
- clean_query = sql_query.strip()
405
- query_lower = clean_query.lower()
406
-
407
- if not query_lower.startswith(("select", "show", "explain", "with")):
408
- cursor.execute(clean_query)
409
- connection.commit()
410
- response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
411
- return response
412
 
413
- if not limited:
414
- cursor.execute(clean_query)
415
- results = cursor.fetchall()
416
- response["message"] = f"Raw query executed. Returned {len(results)} row(s)."
417
- response["limited"] = False
418
- response["is_aggregate"] = is_aggregate_query(clean_query)
419
- else:
420
- if is_aggregate_query(clean_query):
 
 
 
 
421
  cursor.execute(clean_query)
422
  results = cursor.fetchall()
423
- response["message"] = f"Aggregate query completed. Returned {len(results)} row(s)."
424
- response["is_aggregate"] = True
425
- response["limited"] = False
426
  else:
427
- final_query = clean_query.rstrip(';').strip()
428
- if not re.search(r'\blimit\s+\d+', query_lower):
429
- final_query = f"{final_query} LIMIT {max_rows}"
430
- cursor.execute(final_query)
431
- results = cursor.fetchall()
432
- is_limited_result = (len(results) == max_rows)
433
- response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
434
- response["limited"] = is_limited_result
435
- response["is_aggregate"] = False
436
-
437
- columns = [desc[0] for desc in cursor.description] if cursor.description else []
438
- clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
439
- response.update({"success": True, "results": clean_results, "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
440
- return response
441
- except Exception as e:
442
- # Detect fatal connection errors
443
- err_msg = str(e).lower()
444
- if "closed" in err_msg or "terminat" in err_msg or isinstance(e, (psycopg2.InterfaceError, psycopg2.OperationalError)):
445
- connection_broken = True
446
-
447
- if connection and not connection_broken:
448
- try:
449
- connection.rollback()
450
- except Exception:
451
- connection_broken = True
452
-
453
- response["error"] = str(e)
454
- return response
455
- finally:
456
- try:
457
- if cursor: cursor.close()
458
- except: pass
459
-
460
- if connection:
461
- # If broken, set close=True to discard it from pool
462
- pool_manager.return_postgres_connection(db_url, connection, close=connection_broken)
463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
  # ==============================================================================
466
  # API ROUTES
467
  # ==============================================================================
468
 
469
- @app.post("/api/execute_mongo", response_model=ExecutorResponse)
470
- async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(validate_token)):
471
- request_id = str(uuid.uuid4())[:8]
472
- start_time = time.time()
473
- try:
474
- # Parse logic is fast, can stay outside threadpool
475
- parsed_query = parse_query_input(payload.generated_query)
476
-
477
- # --- COALESCING MAGIC ---
478
- # Create a unique signature for this request
479
- unique_params = {
480
- "uri": payload.mongo_uri,
481
- "db": payload.db_name,
482
- "col": payload.collection_name,
483
- "q": parsed_query, # parsed_query is a Dict or List, json.dumps handles it
484
- "lim": payload.limited,
485
- "lrows": payload.limit_rows
486
- }
487
 
488
- # Use the coalescer to prevent duplicate simultaneous DB hits
489
- result_data = await coalescer.execute(
490
- "mongo", # Prefix
491
- unique_params, # Unique params dict
492
- run_in_threadpool, # Runner
493
- execute_mongo_operation, # The function to run
494
- # Arguments for execute_mongo_operation:
495
- mongo_uri=payload.mongo_uri,
496
- db_name=payload.db_name,
497
- collection_name=payload.collection_name,
498
- query=parsed_query,
499
- limited=payload.limited,
500
- limit_rows=payload.limit_rows
501
- )
502
- # ------------------------
503
-
504
- return ExecutorResponse(
505
- status="success",
506
- count=len(result_data),
507
- data=jsonable_encoder(result_data, custom_encoder={ObjectId: str}),
508
- duration_seconds=round(time.time() - start_time, 4),
509
- request_id=request_id
510
- )
511
- except Exception as e:
512
- raise HTTPException(status_code=500, detail=str(e))
513
 
514
- @app.post("/api/execute_chart", response_model=ChartExecutionResponse)
515
- async def execute_chart_endpoint(payload: ChartExecutionPayload, token: str = Depends(validate_token)):
516
- request_id = str(uuid.uuid4())[:8]
517
- try:
518
- image_bytes, error_msg, logs = await run_in_threadpool(execute_python_code, code=payload.code, csv_url=payload.csv_url)
519
-
520
- if error_msg:
521
- return ChartExecutionResponse(status="error", error=error_msg, output_log=logs, request_id=request_id)
522
 
523
- if payload.return_base64:
524
- base64_str = base64.b64encode(image_bytes).decode('utf-8')
525
- return ChartExecutionResponse(status="success", base64_image=base64_str, output_log=logs, request_id=request_id)
526
- else:
527
- unique_name = f"{uuid.uuid4()}.png"
528
- public_url = await run_in_threadpool(upload_bytes_to_supabase, image_bytes=image_bytes, file_name=unique_name, chat_id=payload.chat_id)
529
- return ChartExecutionResponse(status="success", image_url=public_url, output_log=logs, request_id=request_id)
530
- except Exception as e:
531
- raise HTTPException(status_code=500, detail=str(e))
 
 
532
 
533
  @app.post("/api/execute_sql_query", response_model=SqlQueryResponse)
534
  async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(validate_token)):
@@ -536,34 +446,17 @@ async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(va
536
  try:
537
  normalized_url = normalize_mysql_uri(query.database_url)
538
  limit_val = query.limit_rows if query.limit_rows is not None else 20
539
-
540
- # Unique ID for Coalescing
541
  unique_params = {
542
- "db": normalized_url,
543
- "q": query.sql_query,
544
- "l": limit_val,
545
- "lim": query.limited
546
  }
547
-
548
- # FIXED: Pass _run_mysql_synchronously as the first POSITIONAL argument after run_in_threadpool
549
  result_dict = await coalescer.execute(
550
- "mysql", # Prefix
551
- unique_params, # Unique Params
552
- run_in_threadpool, # Runner
553
- _run_mysql_synchronously, # Arg 1 (The Function)
554
- db_url=normalized_url, # Kwargs
555
- sql_query=query.sql_query,
556
- max_rows=limit_val,
557
- limited=query.limited
558
  )
559
-
560
  final_result = result_dict.copy()
561
  final_result["request_id"] = request_id
562
  return SqlQueryResponse(**final_result)
563
-
564
  except Exception as e:
565
- # If run_in_threadpool fails (e.g. TypeError) it ends up here
566
- logger.error(f"MySQL Endpoint Error: {str(e)}")
567
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
568
 
569
  @app.post("/api/execute_postgres_query", response_model=PgQueryResponse)
@@ -572,34 +465,54 @@ async def execute_postgres_endpoint(query: PgQueryRequest, token: str = Depends(
572
  try:
573
  clean_url = normalize_postgres_uri(query.database_url)
574
  limit_val = query.limit_rows if query.limit_rows is not None else 20
575
-
576
  unique_params = {
577
- "db": clean_url,
578
- "q": query.sql_query,
579
- "l": limit_val,
580
- "lim": query.limited
581
  }
582
-
583
- # FIXED: Pass _run_postgres_synchronously as the first POSITIONAL argument
584
  result_dict = await coalescer.execute(
585
- "postgres",
586
- unique_params,
587
- run_in_threadpool,
588
- _run_postgres_synchronously, # Arg 1 (The Function)
589
- db_url=clean_url,
590
- sql_query=query.sql_query,
591
- max_rows=limit_val,
592
- limited=query.limited
593
  )
594
-
595
  final_result = result_dict.copy()
596
  final_result["request_id"] = request_id
597
  return PgQueryResponse(**final_result)
598
  except Exception as e:
599
- logger.error(f"Postgres Endpoint Error: {str(e)}")
600
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
601
 
602
- # ... (CSV and Report endpoints remain unchanged) ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  @app.post("/api/execute_csv_analysis", response_model=AnalysisResponse)
604
  async def execute_analysis_endpoint(payload: AnalysisRequest, token: str = Depends(validate_token)):
605
  request_id = str(uuid.uuid4())[:8]
@@ -669,10 +582,21 @@ async def batch_execute_mongo(payload: BatchRequest[ExecutorPayload], token: str
669
  return BatchResponse(responses=responses)
670
 
671
  # --- Test Endpoint ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
 
673
- # ==============================================================================
674
- # KEEP-ALIVE ROUTE
675
- # ==============================================================================
676
  @app.get("/")
677
  async def root():
678
  return {"message": "Python Code Execution Server is running"}
@@ -681,26 +605,9 @@ async def root():
681
  async def ping():
682
  return {"message": "I am alive!"}
683
 
684
-
685
- # ==============================================================================
686
- # HIGH PERFORMANCE SERVER EXECUTION
687
- # ==============================================================================
688
-
689
  if __name__ == "__main__":
690
  host = os.getenv("HOST", "0.0.0.0")
691
  port = int(os.getenv("PORT", 7860))
692
-
693
- # Calculate workers (save 2 cores for system overhead)
694
- # Ensure at least 1 worker exists
695
  num_workers = max(1, multiprocessing.cpu_count() - 2)
696
-
697
  print(f"Starting production server on {host}:{port} with {num_workers} workers...")
698
- print("Using 'asyncio' loop to prevent Pandas/Numpy segfaults.")
699
-
700
- uvicorn.run(
701
- "controller:app",
702
- host=host,
703
- port=port,
704
- workers=num_workers,
705
- loop="asyncio",
706
- )
 
5
  import uuid
6
  import os
7
  import re
8
+ import asyncio
9
+ 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
16
 
17
  # --- FastAPI & Core ---
18
  from fastapi import FastAPI, HTTPException, Depends
 
20
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from starlette.concurrency import run_in_threadpool
23
+ from anyio import to_thread
24
  from dotenv import load_dotenv
25
  from pydantic import BaseModel
26
 
27
+ # --- Database Drivers ---
28
  from bson import ObjectId
29
  import mysql.connector
30
+ from mysql.connector import pooling
31
  import psycopg2
32
+ from psycopg2 import pool as pg_pool
33
  from psycopg2.extras import RealDictCursor
 
34
  import uvicorn
35
 
36
  # --- Existing Services ---
 
54
  logger = logging.getLogger("API_Controller")
55
 
56
  def get_dynamic_thread_limit():
57
+ """Calculates a safe thread limit based on available RAM."""
 
 
 
58
  try:
 
59
  total_ram_bytes = psutil.virtual_memory().total
60
  total_cores = multiprocessing.cpu_count()
 
 
 
61
  num_workers = max(1, total_cores - 2)
 
 
62
  ram_per_worker = total_ram_bytes / num_workers
 
 
63
  safe_ram_pool = ram_per_worker * 0.70
 
 
64
  BYTES_PER_THREAD = 8 * 1024 * 1024
 
65
  calculated_limit = int(safe_ram_pool / BYTES_PER_THREAD)
 
 
66
  final_limit = max(100, min(calculated_limit, 3000))
67
 
68
  logger.info(f"Dynamic Limit Config: {total_ram_bytes/(1024**3):.2f}GB RAM / {num_workers} Workers. Limit: {final_limit}")
69
  return final_limit
 
70
  except Exception as e:
71
  logger.warning(f"Failed to calculate dynamic threads ({e}). Fallback to 1000.")
72
  return 1000
73
 
74
  @asynccontextmanager
75
  async def lifespan(app: FastAPI):
 
76
  safe_limit = get_dynamic_thread_limit()
77
  to_thread.current_default_thread_limiter().total_tokens = safe_limit
78
  logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}.")
79
  yield
 
80
 
81
+ app = FastAPI(title="Unified Data Executor API", lifespan=lifespan)
 
 
 
82
 
83
  # ==============================================================================
84
+ # BATCH MODELS
85
  # ==============================================================================
86
  T = TypeVar("T")
87
 
 
91
  class BatchResponse(BaseModel, Generic[T]):
92
  responses: List[T]
93
 
94
+ # --- Directory & CORS ---
95
  CHART_DIR = "generated_charts"
96
  os.makedirs(CHART_DIR, exist_ok=True)
97
 
 
98
  origins_env = os.getenv("ALLOWED_ORIGINS", "*")
99
  ORIGINS = [origin.strip() for origin in origins_env.split(",")]
100
 
 
110
  security = HTTPBearer()
111
  API_SECRET_TOKEN = os.getenv("API_BEARER_TOKEN")
112
 
 
 
 
113
  async def validate_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
114
  if credentials.credentials != API_SECRET_TOKEN:
115
  raise HTTPException(status_code=403, detail="Invalid Authentication Token")
116
  return credentials.credentials
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  # ==============================================================================
119
+ # CONNECTION POOL MANAGER (With Keepalives)
120
  # ==============================================================================
121
  class ConnectionPoolManager:
122
  def __init__(self):
 
135
  "host": parsed.hostname,
136
  "port": parsed.port or 3306,
137
  "database": parsed.path.lstrip("/"),
138
+ "connect_timeout": 5,
139
+ "pool_reset_session": True # Validates connection on checkout
140
  }
141
+ self._mysql_pools[db_url] = pooling.MySQLConnectionPool(
142
+ pool_name=str(uuid.uuid4()),
143
+ pool_size=10,
144
+ **db_config
145
+ )
146
  return self._mysql_pools[db_url].get_connection()
147
 
148
  def get_postgres_connection(self, db_url: str):
 
152
  parsed = urlparse(db_url)
153
  qs = parse_qs(parsed.query)
154
  sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
155
+
156
  db_config = {
157
  "host": parsed.hostname,
158
  "port": parsed.port or 5432,
 
160
  "user": parsed.username,
161
  "password": parsed.password,
162
  "sslmode": sslmode,
163
+ "connect_timeout": 5,
164
+ # --- TCP Keepalives (Prevents SSL Closed Unexpectedly) ---
165
+ "keepalives": 1,
166
+ "keepalives_idle": 30,
167
+ "keepalives_interval": 10,
168
+ "keepalives_count": 5
169
  }
170
  self._pg_pools[db_url] = pg_pool.ThreadedConnectionPool(1, 10, **db_config)
171
  return self._pg_pools[db_url].getconn()
172
 
173
  def return_postgres_connection(self, db_url, conn, close=False):
174
+ """Returns connection to pool. If close=True, discards it."""
 
 
 
175
  if db_url in self._pg_pools and conn:
176
  self._pg_pools[db_url].putconn(conn, close=close)
177
 
178
  pool_manager = ConnectionPoolManager()
179
 
 
180
  # ==============================================================================
181
+ # REQUEST COALESCER
182
  # ==============================================================================
183
  class RequestCoalescer:
184
  def __init__(self):
 
192
 
193
  async def execute(self, prefix: str, unique_params: dict, func, *args, **kwargs):
194
  key = self._generate_key(prefix, unique_params)
 
195
  async with self._lock:
196
  if key in self._active_requests:
197
  return await self._active_requests[key]
 
198
  loop = asyncio.get_running_loop()
199
  future = loop.create_future()
200
  self._active_requests[key] = future
201
 
202
  try:
 
203
  result = await func(*args, **kwargs)
204
+ if not future.done(): future.set_result(result)
 
 
205
  return result
 
206
  except Exception as e:
207
+ if not future.done(): future.set_exception(e)
 
208
  raise e
209
  finally:
210
  async with self._lock:
 
213
 
214
  coalescer = RequestCoalescer()
215
 
 
216
  # ==============================================================================
217
+ # DB EXECUTION LOGIC (WITH RETRY & SELF-HEALING)
218
  # ==============================================================================
219
 
220
  def is_aggregate_query(query: str) -> bool:
221
  query_lower = query.lower()
222
+ aggregate_patterns = [r'\bcount\s*\(', r'\bsum\s*\(', r'\bavg\s*\(', r'\bmin\s*\(', r'\bmax\s*\(', r'\bgroup\s+by\b', r'\bdistinct\b', r'\bhaving\b']
 
 
 
 
223
  for pattern in aggregate_patterns:
224
  if re.search(pattern, query_lower):
225
  return True
 
245
  except Exception:
246
  return uri
247
 
248
+ # --- SELF-HEALING MYSQL EXECUTION ---
249
  def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
250
  start_time = time.time()
 
 
 
251
 
252
+ # Retry Loop: If connection is dead, we retry once
253
+ for attempt in range(2):
254
+ connection = None
255
+ cursor = None
256
+ try:
257
+ connection = pool_manager.get_mysql_connection(db_url)
258
+ cursor = connection.cursor(dictionary=True)
259
+
260
+ clean_query = sql_query.strip()
261
+ query_lower = clean_query.lower()
262
 
263
+ if not query_lower.startswith("select"):
264
+ cursor.execute(clean_query)
265
+ connection.commit()
266
+ return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
267
+
268
+ if not limited:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  cursor.execute(clean_query)
270
  results = cursor.fetchall()
271
+ is_aggregate = is_aggregate_query(clean_query)
272
+ message = f"Raw query executed. Returned {len(results)} row(s)."
273
+ is_limited_result = False
274
  else:
275
+ if is_aggregate_query(clean_query):
276
+ cursor.execute(clean_query)
277
+ results = cursor.fetchall()
278
+ is_aggregate = True
279
+ message = f"Aggregate query completed. Returned {len(results)} row(s)."
280
+ is_limited_result = False
281
+ else:
282
+ final_query = clean_query.rstrip(';').strip()
283
+ if not re.search(r'\blimit\s+\d+', query_lower):
284
+ final_query = f"{final_query} LIMIT {max_rows}"
285
+ cursor.execute(final_query)
286
+ results = cursor.fetchall()
287
+ is_limited_result = (len(results) == max_rows)
288
+ message = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
289
+ is_aggregate = False
290
+
291
+ columns = [col[0] for col in cursor.description] if cursor.description else []
292
+ return {
293
+ "success": True, "results": jsonable_encoder(results), "columns": columns,
294
+ "rowCount": len(results), "executionTime": time.time() - start_time,
295
+ "is_aggregate": is_aggregate, "limited": is_limited_result, "message": message, "error": None
296
+ }
297
+
298
+ except (mysql.connector.errors.OperationalError, mysql.connector.errors.DatabaseError) as e:
299
+ # Fatal connection error
300
+ if connection:
301
+ try: connection.close() # Close properly so pool knows it's bad (or discards it)
 
 
 
 
 
302
  except: pass
303
+
304
+ # If this was the first attempt, try again with a fresh connection
305
+ if attempt == 0:
306
+ logger.warning(f"MySQL connection lost. Retrying... Error: {e}")
307
+ continue
308
+
309
+ return {"success": False, "error": f"MySQL Error: {str(e)}", "executionTime": 0.0}
310
+
311
+ except Exception as e:
312
+ # Logic error (Syntax, etc). Do not retry.
313
+ if connection: connection.close()
314
+ return {"success": False, "error": str(e), "executionTime": 0.0}
315
+
316
+ finally:
317
+ if cursor:
318
+ try: cursor.close()
319
+ except: pass
320
+ # Connection close handled in blocks above to handle "poisoned" logic correctly
321
 
322
+ # --- SELF-HEALING POSTGRES EXECUTION ---
323
  def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
324
  start_time = time.time()
 
 
 
325
 
326
+ # Retry Loop for Dead Connections
327
+ for attempt in range(2):
328
+ connection = None
329
+ cursor = None
330
+ try:
331
+ connection = pool_manager.get_postgres_connection(db_url)
332
+ cursor = connection.cursor(cursor_factory=RealDictCursor)
 
 
 
 
 
 
 
333
 
334
+ clean_query = sql_query.strip()
335
+ query_lower = clean_query.lower()
336
+
337
+ if not query_lower.startswith(("select", "show", "explain", "with")):
338
+ cursor.execute(clean_query)
339
+ connection.commit()
340
+ # Return Success
341
+ pool_manager.return_postgres_connection(db_url, connection)
342
+ return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
343
+
344
+ # Read Logic
345
+ if not limited:
346
  cursor.execute(clean_query)
347
  results = cursor.fetchall()
348
+ is_aggregate = is_aggregate_query(clean_query)
349
+ message = f"Raw query executed. Returned {len(results)} row(s)."
350
+ is_limited_result = False
351
  else:
352
+ if is_aggregate_query(clean_query):
353
+ cursor.execute(clean_query)
354
+ results = cursor.fetchall()
355
+ is_aggregate = True
356
+ message = f"Aggregate query completed. Returned {len(results)} row(s)."
357
+ is_limited_result = False
358
+ else:
359
+ final_query = clean_query.rstrip(';').strip()
360
+ if not re.search(r'\blimit\s+\d+', query_lower):
361
+ final_query = f"{final_query} LIMIT {max_rows}"
362
+ cursor.execute(final_query)
363
+ results = cursor.fetchall()
364
+ is_limited_result = (len(results) == max_rows)
365
+ message = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
366
+ is_aggregate = False
367
+
368
+ columns = [desc[0] for desc in cursor.description] if cursor.description else []
369
+ clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
370
+
371
+ # Success! Return conn
372
+ pool_manager.return_postgres_connection(db_url, connection)
373
+ return {
374
+ "success": True, "results": clean_results, "columns": columns,
375
+ "rowCount": len(results), "executionTime": time.time() - start_time,
376
+ "is_aggregate": is_aggregate, "limited": is_limited_result, "message": message, "error": None
377
+ }
378
+
379
+ except (psycopg2.InterfaceError, psycopg2.OperationalError) as e:
380
+ # Bad Connection. DISCARD IT.
381
+ if connection:
382
+ pool_manager.return_postgres_connection(db_url, connection, close=True)
383
+
384
+ if attempt == 0:
385
+ logger.warning(f"Postgres connection dead ({e}). Retrying with fresh connection...")
386
+ continue
387
+ return {"success": False, "error": f"DB Connection Failed: {str(e)}", "executionTime": 0.0}
388
 
389
+ except Exception as e:
390
+ # Logic/SQL Error. Rollback and return to pool.
391
+ if connection:
392
+ try: connection.rollback()
393
+ except: pass # If rollback fails, pool.putconn might fail next, but we try
394
+ pool_manager.return_postgres_connection(db_url, connection, close=False)
395
+
396
+ return {"success": False, "error": str(e), "executionTime": 0.0}
397
+
398
+ finally:
399
+ if cursor:
400
+ try: cursor.close()
401
+ except: pass
402
 
403
  # ==============================================================================
404
  # API ROUTES
405
  # ==============================================================================
406
 
407
+ class SqlQueryRequest(BaseModel):
408
+ database_url: str
409
+ sql_query: str
410
+ limit_rows: Optional[int] = 20
411
+ limited: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
413
+ class SqlQueryResponse(BaseModel):
414
+ success: bool
415
+ results: Optional[List[Dict[str, Any]]] = None
416
+ columns: Optional[List[str]] = None
417
+ rowCount: Optional[int] = 0
418
+ executionTime: Optional[float] = 0.0
419
+ error: Optional[str] = None
420
+ request_id: str
421
+ is_aggregate: bool = False
422
+ limited: bool = False
423
+ message: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
+ class PgQueryRequest(BaseModel):
426
+ database_url: str
427
+ sql_query: str
428
+ limit_rows: Optional[int] = 20
429
+ limited: bool = False
 
 
 
430
 
431
+ class PgQueryResponse(BaseModel):
432
+ success: bool
433
+ results: Optional[List[Dict[str, Any]]] = None
434
+ columns: Optional[List[str]] = None
435
+ rowCount: Optional[int] = 0
436
+ executionTime: Optional[float] = 0.0
437
+ error: Optional[str] = None
438
+ request_id: str
439
+ is_aggregate: bool = False
440
+ limited: bool = False
441
+ message: Optional[str] = None
442
 
443
  @app.post("/api/execute_sql_query", response_model=SqlQueryResponse)
444
  async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(validate_token)):
 
446
  try:
447
  normalized_url = normalize_mysql_uri(query.database_url)
448
  limit_val = query.limit_rows if query.limit_rows is not None else 20
 
 
449
  unique_params = {
450
+ "db": normalized_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
 
 
 
451
  }
 
 
452
  result_dict = await coalescer.execute(
453
+ "mysql", unique_params, run_in_threadpool, _run_mysql_synchronously,
454
+ db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
 
 
 
 
 
 
455
  )
 
456
  final_result = result_dict.copy()
457
  final_result["request_id"] = request_id
458
  return SqlQueryResponse(**final_result)
 
459
  except Exception as e:
 
 
460
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
461
 
462
  @app.post("/api/execute_postgres_query", response_model=PgQueryResponse)
 
465
  try:
466
  clean_url = normalize_postgres_uri(query.database_url)
467
  limit_val = query.limit_rows if query.limit_rows is not None else 20
 
468
  unique_params = {
469
+ "db": clean_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
 
 
 
470
  }
 
 
471
  result_dict = await coalescer.execute(
472
+ "postgres", unique_params, run_in_threadpool, _run_postgres_synchronously,
473
+ db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
 
 
 
 
 
 
474
  )
 
475
  final_result = result_dict.copy()
476
  final_result["request_id"] = request_id
477
  return PgQueryResponse(**final_result)
478
  except Exception as e:
 
479
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
480
 
481
+ @app.post("/api/execute_mongo", response_model=ExecutorResponse)
482
+ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(validate_token)):
483
+ request_id = str(uuid.uuid4())[:8]
484
+ start_time = time.time()
485
+ try:
486
+ parsed_query = parse_query_input(payload.generated_query)
487
+ unique_params = {
488
+ "uri": payload.mongo_uri, "db": payload.db_name, "col": payload.collection_name,
489
+ "q": parsed_query, "lim": payload.limited, "lrows": payload.limit_rows
490
+ }
491
+ result_data = await coalescer.execute(
492
+ "mongo", unique_params, run_in_threadpool, execute_mongo_operation,
493
+ mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name,
494
+ query=parsed_query, limited=payload.limited, limit_rows=payload.limit_rows
495
+ )
496
+ return ExecutorResponse(status="success", count=len(result_data), data=jsonable_encoder(result_data, custom_encoder={ObjectId: str}), duration_seconds=round(time.time() - start_time, 4), request_id=request_id)
497
+ except Exception as e:
498
+ raise HTTPException(status_code=500, detail=str(e))
499
+
500
+ @app.post("/api/execute_chart", response_model=ChartExecutionResponse)
501
+ async def execute_chart_endpoint(payload: ChartExecutionPayload, token: str = Depends(validate_token)):
502
+ request_id = str(uuid.uuid4())[:8]
503
+ try:
504
+ image_bytes, error_msg, logs = await run_in_threadpool(execute_python_code, code=payload.code, csv_url=payload.csv_url)
505
+ if error_msg: return ChartExecutionResponse(status="error", error=error_msg, output_log=logs, request_id=request_id)
506
+ if payload.return_base64:
507
+ base64_str = base64.b64encode(image_bytes).decode('utf-8')
508
+ return ChartExecutionResponse(status="success", base64_image=base64_str, output_log=logs, request_id=request_id)
509
+ else:
510
+ unique_name = f"{uuid.uuid4()}.png"
511
+ public_url = await run_in_threadpool(upload_bytes_to_supabase, image_bytes=image_bytes, file_name=unique_name, chat_id=payload.chat_id)
512
+ return ChartExecutionResponse(status="success", image_url=public_url, output_log=logs, request_id=request_id)
513
+ except Exception as e:
514
+ raise HTTPException(status_code=500, detail=str(e))
515
+
516
  @app.post("/api/execute_csv_analysis", response_model=AnalysisResponse)
517
  async def execute_analysis_endpoint(payload: AnalysisRequest, token: str = Depends(validate_token)):
518
  request_id = str(uuid.uuid4())[:8]
 
582
  return BatchResponse(responses=responses)
583
 
584
  # --- Test Endpoint ---
585
+ class TestCalcRequest(BaseModel):
586
+ value: int
587
+
588
+ def _heavy_calculation_task(value: int) -> int:
589
+ time.sleep(0.1)
590
+ return value * 2
591
+
592
+ @app.post("/api/test/parallel_calc_no_auth")
593
+ async def test_parallel_calc_endpoint(payload: TestCalcRequest):
594
+ try:
595
+ result = await run_in_threadpool(_heavy_calculation_task, value=payload.value)
596
+ return {"success": True, "result": result, "process_id": os.getpid()}
597
+ except Exception as e:
598
+ raise HTTPException(status_code=500, detail=str(e))
599
 
 
 
 
600
  @app.get("/")
601
  async def root():
602
  return {"message": "Python Code Execution Server is running"}
 
605
  async def ping():
606
  return {"message": "I am alive!"}
607
 
 
 
 
 
 
608
  if __name__ == "__main__":
609
  host = os.getenv("HOST", "0.0.0.0")
610
  port = int(os.getenv("PORT", 7860))
 
 
 
611
  num_workers = max(1, multiprocessing.cpu_count() - 2)
 
612
  print(f"Starting production server on {host}:{port} with {num_workers} workers...")
613
+ uvicorn.run("controller:app", host=host, port=port, workers=num_workers, loop="asyncio")