Soumik Bose commited on
Commit
615412d
·
1 Parent(s): 8775295
Files changed (2) hide show
  1. controller.py +229 -90
  2. mongo_service.py +51 -30
controller.py CHANGED
@@ -5,10 +5,11 @@ 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
- from typing import List, Optional, Dict, Any, TypeVar, Generic # Added Generic/TypeVar
11
- from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
 
12
 
13
  # --- FastAPI & Core ---
14
  from fastapi import FastAPI, HTTPException, Depends
@@ -16,15 +17,20 @@ from fastapi.encoders import jsonable_encoder
16
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from starlette.concurrency import run_in_threadpool
19
- from anyio import to_thread # Added for AnyIO 4.x concurrency tuning
20
  from dotenv import load_dotenv
21
  from pydantic import BaseModel
22
 
23
  # --- Database Drivers ---
24
  from bson import ObjectId
25
- import mysql.connector
26
- import psycopg2
27
  from psycopg2.extras import RealDictCursor
 
 
 
 
 
 
 
28
  import uvicorn
29
 
30
  # --- Existing Services ---
@@ -53,7 +59,6 @@ async def lifespan(app: FastAPI):
53
  to_thread.current_default_thread_limiter().total_tokens = 2000
54
  logger.info("Worker Process Started: Thread pool capacity set to 2000.")
55
  yield
56
- # Shutdown logic (if any) goes here
57
 
58
  app = FastAPI(
59
  title="Unified Data Executor API (Mongo, SQL, CSV)",
@@ -104,7 +109,6 @@ async def validate_token(credentials: HTTPAuthorizationCredentials = Depends(sec
104
  # PYDANTIC MODELS
105
  # ==============================================================================
106
 
107
- # --- MySQL Models ---
108
  class SqlQueryRequest(BaseModel):
109
  database_url: str
110
  sql_query: str
@@ -123,7 +127,6 @@ class SqlQueryResponse(BaseModel):
123
  limited: bool = False
124
  message: Optional[str] = None
125
 
126
- # --- PostgreSQL Models ---
127
  class PgQueryRequest(BaseModel):
128
  database_url: str
129
  sql_query: str
@@ -141,14 +144,107 @@ class PgQueryResponse(BaseModel):
141
  is_aggregate: bool = False
142
  limited: bool = False
143
  message: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
 
146
  # ==============================================================================
147
- # SHARED HELPER FUNCTIONS
148
  # ==============================================================================
149
 
150
  def is_aggregate_query(query: str) -> bool:
151
- """Checks for aggregate keywords."""
152
  query_lower = query.lower()
153
  aggregate_patterns = [
154
  r'\bcount\s*\(', r'\bsum\s*\(', r'\bavg\s*\(',
@@ -160,10 +256,6 @@ def is_aggregate_query(query: str) -> bool:
160
  return True
161
  return False
162
 
163
- # ==============================================================================
164
- # MYSQL LOGIC
165
- # ==============================================================================
166
-
167
  def normalize_mysql_uri(uri: str) -> str:
168
  try:
169
  parsed_uri = urlparse(uri)
@@ -175,6 +267,15 @@ def normalize_mysql_uri(uri: str) -> str:
175
  except Exception:
176
  return uri
177
 
 
 
 
 
 
 
 
 
 
178
  def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
179
  start_time = time.time()
180
  connection = None
@@ -182,13 +283,7 @@ def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, li
182
  response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
183
 
184
  try:
185
- parsed = urlparse(db_url)
186
- db_config = {
187
- "user": parsed.username, "password": parsed.password,
188
- "host": parsed.hostname, "port": parsed.port or 3306,
189
- "database": parsed.path.lstrip("/"), "connect_timeout": 5
190
- }
191
- connection = mysql.connector.connect(**db_config)
192
  cursor = connection.cursor(dictionary=True)
193
 
194
  clean_query = sql_query.strip()
@@ -219,7 +314,6 @@ def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, li
219
 
220
  cursor.execute(final_query)
221
  results = cursor.fetchall()
222
-
223
  is_limited_result = (len(results) == max_rows)
224
  response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
225
  response["limited"] = is_limited_result
@@ -234,20 +328,7 @@ def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, li
234
  return response
235
  finally:
236
  if cursor: cursor.close()
237
- if connection and connection.is_connected(): connection.close()
238
-
239
- # ==============================================================================
240
- # POSTGRES LOGIC
241
- # ==============================================================================
242
-
243
- def normalize_postgres_uri(uri: str) -> str:
244
- try:
245
- parsed_uri = urlparse(uri)
246
- if parsed_uri.scheme == 'postgres':
247
- parsed_uri = parsed_uri._replace(scheme='postgresql')
248
- return urlunparse(parsed_uri)
249
- except Exception:
250
- return uri
251
 
252
  def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
253
  start_time = time.time()
@@ -255,19 +336,17 @@ def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20,
255
  cursor = None
256
  response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
257
  try:
258
- parsed = urlparse(db_url)
259
- qs = parse_qs(parsed.query)
260
- sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
261
- db_config = {"host": parsed.hostname, "port": parsed.port or 5432, "database": parsed.path.lstrip("/"), "user": parsed.username, "password": parsed.password, "sslmode": sslmode, "connect_timeout": 5}
262
- connection = psycopg2.connect(**db_config)
263
  cursor = connection.cursor(cursor_factory=RealDictCursor)
264
  clean_query = sql_query.strip()
265
  query_lower = clean_query.lower()
 
266
  if not query_lower.startswith(("select", "show", "explain", "with")):
267
  cursor.execute(clean_query)
268
  connection.commit()
269
  response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
270
  return response
 
271
  if not limited:
272
  cursor.execute(clean_query)
273
  results = cursor.fetchall()
@@ -291,6 +370,7 @@ def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20,
291
  response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
292
  response["limited"] = is_limited_result
293
  response["is_aggregate"] = False
 
294
  columns = [desc[0] for desc in cursor.description] if cursor.description else []
295
  clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
296
  response.update({"success": True, "results": clean_results, "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
@@ -301,18 +381,7 @@ def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20,
301
  return response
302
  finally:
303
  if cursor: cursor.close()
304
- if connection: connection.close()
305
-
306
- # ==============================================================================
307
- # KEEP-ALIVE ROUTE
308
- # ==============================================================================
309
- @app.get("/")
310
- async def root():
311
- return {"message": "Python Code Execution Server is running"}
312
-
313
- @app.get("/ping")
314
- async def ping():
315
- return {"message": "I am alive!"}
316
 
317
 
318
  # ==============================================================================
@@ -324,9 +393,43 @@ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(
324
  request_id = str(uuid.uuid4())[:8]
325
  start_time = time.time()
326
  try:
 
327
  parsed_query = parse_query_input(payload.generated_query)
328
- result_data = await run_in_threadpool(execute_mongo_operation, mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name, query=parsed_query, limited=payload.limited, limit_rows=payload.limit_rows)
329
- 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  except Exception as e:
331
  raise HTTPException(status_code=500, detail=str(e))
332
 
@@ -334,33 +437,18 @@ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(
334
  async def execute_chart_endpoint(payload: ChartExecutionPayload, token: str = Depends(validate_token)):
335
  request_id = str(uuid.uuid4())[:8]
336
  try:
337
- # 1. Execute Code
338
  image_bytes, error_msg, logs = await run_in_threadpool(execute_python_code, code=payload.code, csv_url=payload.csv_url)
339
 
340
  if error_msg:
341
  return ChartExecutionResponse(status="error", error=error_msg, output_log=logs, request_id=request_id)
342
 
343
- # 2. Handle Output Format
344
  if payload.return_base64:
345
- # OPTION A: Return Base64 (No Supabase Upload)
346
  base64_str = base64.b64encode(image_bytes).decode('utf-8')
347
- return ChartExecutionResponse(
348
- status="success",
349
- base64_image=base64_str,
350
- output_log=logs,
351
- request_id=request_id
352
- )
353
  else:
354
- # OPTION B: Upload to Supabase (Standard behavior)
355
  unique_name = f"{uuid.uuid4()}.png"
356
  public_url = await run_in_threadpool(upload_bytes_to_supabase, image_bytes=image_bytes, file_name=unique_name, chat_id=payload.chat_id)
357
- return ChartExecutionResponse(
358
- status="success",
359
- image_url=public_url,
360
- output_log=logs,
361
- request_id=request_id
362
- )
363
-
364
  except Exception as e:
365
  raise HTTPException(status_code=500, detail=str(e))
366
 
@@ -370,10 +458,34 @@ async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(va
370
  try:
371
  normalized_url = normalize_mysql_uri(query.database_url)
372
  limit_val = query.limit_rows if query.limit_rows is not None else 20
373
- result_dict = await run_in_threadpool(_run_mysql_synchronously, db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited)
374
- result_dict["request_id"] = request_id
375
- return SqlQueryResponse(**result_dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  except Exception as e:
 
 
377
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
378
 
379
  @app.post("/api/execute_postgres_query", response_model=PgQueryResponse)
@@ -382,12 +494,34 @@ async def execute_postgres_endpoint(query: PgQueryRequest, token: str = Depends(
382
  try:
383
  clean_url = normalize_postgres_uri(query.database_url)
384
  limit_val = query.limit_rows if query.limit_rows is not None else 20
385
- result_dict = await run_in_threadpool(_run_postgres_synchronously, db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited)
386
- result_dict["request_id"] = request_id
387
- return PgQueryResponse(**result_dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  except Exception as e:
 
389
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
390
 
 
391
  @app.post("/api/execute_csv_analysis", response_model=AnalysisResponse)
392
  async def execute_analysis_endpoint(payload: AnalysisRequest, token: str = Depends(validate_token)):
393
  request_id = str(uuid.uuid4())[:8]
@@ -435,15 +569,8 @@ async def execute_python_endpoint(payload: PythonExecutionRequest, token: str =
435
  except Exception as e:
436
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
437
 
438
- # ==============================================================================
439
- # NEW: BATCH HANDLER LOGIC (Scaling for 1000+ Requests)
440
- # ==============================================================================
441
-
442
  async def batch_parallel_handler(func, requests: List[Any], token: str):
443
- """
444
- Executes multiple requests in parallel within a single worker process
445
- using asyncio.gather, utilizing the high-token thread pool.
446
- """
447
  tasks = [func(req, token) for req in requests]
448
  results = await asyncio.gather(*tasks, return_exceptions=True)
449
  return [res if not isinstance(res, Exception) else {"success": False, "error": str(res)} for res in results]
@@ -463,6 +590,20 @@ async def batch_execute_mongo(payload: BatchRequest[ExecutorPayload], token: str
463
  responses = await batch_parallel_handler(execute_mongo_endpoint, payload.requests, token)
464
  return BatchResponse(responses=responses)
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  # ==============================================================================
467
  # HIGH PERFORMANCE SERVER EXECUTION
468
  # ==============================================================================
@@ -484,6 +625,4 @@ if __name__ == "__main__":
484
  port=port,
485
  workers=num_workers,
486
  loop="asyncio",
487
- log_level="info",
488
- access_log=False
489
  )
 
5
  import uuid
6
  import os
7
  import re
8
+ import asyncio
9
+ import multiprocessing
10
+ import hashlib
11
+ import json
12
+ from typing import List, Optional, Dict, Any, TypeVar, Generic
13
 
14
  # --- FastAPI & Core ---
15
  from fastapi import FastAPI, HTTPException, Depends
 
17
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from starlette.concurrency import run_in_threadpool
20
+ from anyio import to_thread
21
  from dotenv import load_dotenv
22
  from pydantic import BaseModel
23
 
24
  # --- Database Drivers ---
25
  from bson import ObjectId
 
 
26
  from psycopg2.extras import RealDictCursor
27
+ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
28
+
29
+ # --- Database Pool ---
30
+ from mysql.connector import pooling
31
+ from psycopg2 import pool as pg_pool
32
+ from threading import Lock
33
+
34
  import uvicorn
35
 
36
  # --- Existing Services ---
 
59
  to_thread.current_default_thread_limiter().total_tokens = 2000
60
  logger.info("Worker Process Started: Thread pool capacity set to 2000.")
61
  yield
 
62
 
63
  app = FastAPI(
64
  title="Unified Data Executor API (Mongo, SQL, CSV)",
 
109
  # PYDANTIC MODELS
110
  # ==============================================================================
111
 
 
112
  class SqlQueryRequest(BaseModel):
113
  database_url: str
114
  sql_query: str
 
127
  limited: bool = False
128
  message: Optional[str] = None
129
 
 
130
  class PgQueryRequest(BaseModel):
131
  database_url: str
132
  sql_query: str
 
144
  is_aggregate: bool = False
145
  limited: bool = False
146
  message: Optional[str] = None
147
+
148
+ # ==============================================================================
149
+ # CONNECTION POOL MANAGER
150
+ # ==============================================================================
151
+ class ConnectionPoolManager:
152
+ def __init__(self):
153
+ self._mysql_pools = {}
154
+ self._pg_pools = {}
155
+ self._lock = Lock()
156
+
157
+ def get_mysql_connection(self, db_url: str):
158
+ with self._lock:
159
+ if db_url not in self._mysql_pools:
160
+ logger.info(f"Creating new MySQL pool for: {db_url}")
161
+ parsed = urlparse(db_url)
162
+ db_config = {
163
+ "user": parsed.username,
164
+ "password": parsed.password,
165
+ "host": parsed.hostname,
166
+ "port": parsed.port or 3306,
167
+ "database": parsed.path.lstrip("/"),
168
+ "connect_timeout": 5
169
+ }
170
+ self._mysql_pools[db_url] = pooling.MySQLConnectionPool(pool_name=str(uuid.uuid4()), pool_size=10, **db_config)
171
+ return self._mysql_pools[db_url].get_connection()
172
+
173
+ def get_postgres_connection(self, db_url: str):
174
+ with self._lock:
175
+ if db_url not in self._pg_pools:
176
+ logger.info(f"Creating new Postgres pool for: {db_url}")
177
+ parsed = urlparse(db_url)
178
+ qs = parse_qs(parsed.query)
179
+ sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
180
+ db_config = {
181
+ "host": parsed.hostname,
182
+ "port": parsed.port or 5432,
183
+ "database": parsed.path.lstrip("/"),
184
+ "user": parsed.username,
185
+ "password": parsed.password,
186
+ "sslmode": sslmode,
187
+ "connect_timeout": 5
188
+ }
189
+ self._pg_pools[db_url] = pg_pool.ThreadedConnectionPool(1, 10, **db_config)
190
+ return self._pg_pools[db_url].getconn()
191
+
192
+ def return_postgres_connection(self, db_url, conn):
193
+ if db_url in self._pg_pools and conn:
194
+ self._pg_pools[db_url].putconn(conn)
195
+
196
+ pool_manager = ConnectionPoolManager()
197
+
198
+
199
+ # ==============================================================================
200
+ # REQUEST COALESCER (SINGLEFLIGHT)
201
+ # ==============================================================================
202
+ class RequestCoalescer:
203
+ def __init__(self):
204
+ self._active_requests: Dict[str, asyncio.Future] = {}
205
+ self._lock = asyncio.Lock()
206
+
207
+ def _generate_key(self, prefix: str, data: dict) -> str:
208
+ json_str = json.dumps(data, sort_keys=True, default=str)
209
+ raw_str = f"{prefix}:{json_str}"
210
+ return hashlib.md5(raw_str.encode()).hexdigest()
211
+
212
+ async def execute(self, prefix: str, unique_params: dict, func, *args, **kwargs):
213
+ key = self._generate_key(prefix, unique_params)
214
+
215
+ async with self._lock:
216
+ if key in self._active_requests:
217
+ return await self._active_requests[key]
218
+
219
+ loop = asyncio.get_running_loop()
220
+ future = loop.create_future()
221
+ self._active_requests[key] = future
222
+
223
+ try:
224
+ # Execute the function (run_in_threadpool) with args/kwargs
225
+ result = await func(*args, **kwargs)
226
+
227
+ if not future.done():
228
+ future.set_result(result)
229
+ return result
230
+
231
+ except Exception as e:
232
+ if not future.done():
233
+ future.set_exception(e)
234
+ raise e
235
+ finally:
236
+ async with self._lock:
237
+ if key in self._active_requests:
238
+ del self._active_requests[key]
239
+
240
+ coalescer = RequestCoalescer()
241
 
242
 
243
  # ==============================================================================
244
+ # DB EXECUTION LOGIC
245
  # ==============================================================================
246
 
247
  def is_aggregate_query(query: str) -> bool:
 
248
  query_lower = query.lower()
249
  aggregate_patterns = [
250
  r'\bcount\s*\(', r'\bsum\s*\(', r'\bavg\s*\(',
 
256
  return True
257
  return False
258
 
 
 
 
 
259
  def normalize_mysql_uri(uri: str) -> str:
260
  try:
261
  parsed_uri = urlparse(uri)
 
267
  except Exception:
268
  return uri
269
 
270
+ def normalize_postgres_uri(uri: str) -> str:
271
+ try:
272
+ parsed_uri = urlparse(uri)
273
+ if parsed_uri.scheme == 'postgres':
274
+ parsed_uri = parsed_uri._replace(scheme='postgresql')
275
+ return urlunparse(parsed_uri)
276
+ except Exception:
277
+ return uri
278
+
279
  def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
280
  start_time = time.time()
281
  connection = None
 
283
  response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
284
 
285
  try:
286
+ connection = pool_manager.get_mysql_connection(db_url)
 
 
 
 
 
 
287
  cursor = connection.cursor(dictionary=True)
288
 
289
  clean_query = sql_query.strip()
 
314
 
315
  cursor.execute(final_query)
316
  results = cursor.fetchall()
 
317
  is_limited_result = (len(results) == max_rows)
318
  response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
319
  response["limited"] = is_limited_result
 
328
  return response
329
  finally:
330
  if cursor: cursor.close()
331
+ if connection: connection.close() # Returns to pool
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
334
  start_time = time.time()
 
336
  cursor = None
337
  response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
338
  try:
339
+ connection = pool_manager.get_postgres_connection(db_url)
 
 
 
 
340
  cursor = connection.cursor(cursor_factory=RealDictCursor)
341
  clean_query = sql_query.strip()
342
  query_lower = clean_query.lower()
343
+
344
  if not query_lower.startswith(("select", "show", "explain", "with")):
345
  cursor.execute(clean_query)
346
  connection.commit()
347
  response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
348
  return response
349
+
350
  if not limited:
351
  cursor.execute(clean_query)
352
  results = cursor.fetchall()
 
370
  response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
371
  response["limited"] = is_limited_result
372
  response["is_aggregate"] = False
373
+
374
  columns = [desc[0] for desc in cursor.description] if cursor.description else []
375
  clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
376
  response.update({"success": True, "results": clean_results, "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
 
381
  return response
382
  finally:
383
  if cursor: cursor.close()
384
+ if connection: pool_manager.return_postgres_connection(db_url, connection)
 
 
 
 
 
 
 
 
 
 
 
385
 
386
 
387
  # ==============================================================================
 
393
  request_id = str(uuid.uuid4())[:8]
394
  start_time = time.time()
395
  try:
396
+ # Parse logic is fast, can stay outside threadpool
397
  parsed_query = parse_query_input(payload.generated_query)
398
+
399
+ # --- COALESCING MAGIC ---
400
+ # Create a unique signature for this request
401
+ unique_params = {
402
+ "uri": payload.mongo_uri,
403
+ "db": payload.db_name,
404
+ "col": payload.collection_name,
405
+ "q": parsed_query, # parsed_query is a Dict or List, json.dumps handles it
406
+ "lim": payload.limited,
407
+ "lrows": payload.limit_rows
408
+ }
409
+
410
+ # Use the coalescer to prevent duplicate simultaneous DB hits
411
+ result_data = await coalescer.execute(
412
+ "mongo", # Prefix
413
+ unique_params, # Unique params dict
414
+ run_in_threadpool, # Runner
415
+ execute_mongo_operation, # The function to run
416
+ # Arguments for execute_mongo_operation:
417
+ mongo_uri=payload.mongo_uri,
418
+ db_name=payload.db_name,
419
+ collection_name=payload.collection_name,
420
+ query=parsed_query,
421
+ limited=payload.limited,
422
+ limit_rows=payload.limit_rows
423
+ )
424
+ # ------------------------
425
+
426
+ return ExecutorResponse(
427
+ status="success",
428
+ count=len(result_data),
429
+ data=jsonable_encoder(result_data, custom_encoder={ObjectId: str}),
430
+ duration_seconds=round(time.time() - start_time, 4),
431
+ request_id=request_id
432
+ )
433
  except Exception as e:
434
  raise HTTPException(status_code=500, detail=str(e))
435
 
 
437
  async def execute_chart_endpoint(payload: ChartExecutionPayload, token: str = Depends(validate_token)):
438
  request_id = str(uuid.uuid4())[:8]
439
  try:
 
440
  image_bytes, error_msg, logs = await run_in_threadpool(execute_python_code, code=payload.code, csv_url=payload.csv_url)
441
 
442
  if error_msg:
443
  return ChartExecutionResponse(status="error", error=error_msg, output_log=logs, request_id=request_id)
444
 
 
445
  if payload.return_base64:
 
446
  base64_str = base64.b64encode(image_bytes).decode('utf-8')
447
+ return ChartExecutionResponse(status="success", base64_image=base64_str, output_log=logs, request_id=request_id)
 
 
 
 
 
448
  else:
 
449
  unique_name = f"{uuid.uuid4()}.png"
450
  public_url = await run_in_threadpool(upload_bytes_to_supabase, image_bytes=image_bytes, file_name=unique_name, chat_id=payload.chat_id)
451
+ return ChartExecutionResponse(status="success", image_url=public_url, output_log=logs, request_id=request_id)
 
 
 
 
 
 
452
  except Exception as e:
453
  raise HTTPException(status_code=500, detail=str(e))
454
 
 
458
  try:
459
  normalized_url = normalize_mysql_uri(query.database_url)
460
  limit_val = query.limit_rows if query.limit_rows is not None else 20
461
+
462
+ # Unique ID for Coalescing
463
+ unique_params = {
464
+ "db": normalized_url,
465
+ "q": query.sql_query,
466
+ "l": limit_val,
467
+ "lim": query.limited
468
+ }
469
+
470
+ # FIXED: Pass _run_mysql_synchronously as the first POSITIONAL argument after run_in_threadpool
471
+ result_dict = await coalescer.execute(
472
+ "mysql", # Prefix
473
+ unique_params, # Unique Params
474
+ run_in_threadpool, # Runner
475
+ _run_mysql_synchronously, # Arg 1 (The Function)
476
+ db_url=normalized_url, # Kwargs
477
+ sql_query=query.sql_query,
478
+ max_rows=limit_val,
479
+ limited=query.limited
480
+ )
481
+
482
+ final_result = result_dict.copy()
483
+ final_result["request_id"] = request_id
484
+ return SqlQueryResponse(**final_result)
485
+
486
  except Exception as e:
487
+ # If run_in_threadpool fails (e.g. TypeError) it ends up here
488
+ logger.error(f"MySQL Endpoint Error: {str(e)}")
489
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
490
 
491
  @app.post("/api/execute_postgres_query", response_model=PgQueryResponse)
 
494
  try:
495
  clean_url = normalize_postgres_uri(query.database_url)
496
  limit_val = query.limit_rows if query.limit_rows is not None else 20
497
+
498
+ unique_params = {
499
+ "db": clean_url,
500
+ "q": query.sql_query,
501
+ "l": limit_val,
502
+ "lim": query.limited
503
+ }
504
+
505
+ # FIXED: Pass _run_postgres_synchronously as the first POSITIONAL argument
506
+ result_dict = await coalescer.execute(
507
+ "postgres",
508
+ unique_params,
509
+ run_in_threadpool,
510
+ _run_postgres_synchronously, # Arg 1 (The Function)
511
+ db_url=clean_url,
512
+ sql_query=query.sql_query,
513
+ max_rows=limit_val,
514
+ limited=query.limited
515
+ )
516
+
517
+ final_result = result_dict.copy()
518
+ final_result["request_id"] = request_id
519
+ return PgQueryResponse(**final_result)
520
  except Exception as e:
521
+ logger.error(f"Postgres Endpoint Error: {str(e)}")
522
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
523
 
524
+ # ... (CSV and Report endpoints remain unchanged) ...
525
  @app.post("/api/execute_csv_analysis", response_model=AnalysisResponse)
526
  async def execute_analysis_endpoint(payload: AnalysisRequest, token: str = Depends(validate_token)):
527
  request_id = str(uuid.uuid4())[:8]
 
569
  except Exception as e:
570
  raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
571
 
572
+ # --- Batch Handlers ---
 
 
 
573
  async def batch_parallel_handler(func, requests: List[Any], token: str):
 
 
 
 
574
  tasks = [func(req, token) for req in requests]
575
  results = await asyncio.gather(*tasks, return_exceptions=True)
576
  return [res if not isinstance(res, Exception) else {"success": False, "error": str(res)} for res in results]
 
590
  responses = await batch_parallel_handler(execute_mongo_endpoint, payload.requests, token)
591
  return BatchResponse(responses=responses)
592
 
593
+ # --- Test Endpoint ---
594
+
595
+ # ==============================================================================
596
+ # KEEP-ALIVE ROUTE
597
+ # ==============================================================================
598
+ @app.get("/")
599
+ async def root():
600
+ return {"message": "Python Code Execution Server is running"}
601
+
602
+ @app.get("/ping")
603
+ async def ping():
604
+ return {"message": "I am alive!"}
605
+
606
+
607
  # ==============================================================================
608
  # HIGH PERFORMANCE SERVER EXECUTION
609
  # ==============================================================================
 
625
  port=port,
626
  workers=num_workers,
627
  loop="asyncio",
 
 
628
  )
mongo_service.py CHANGED
@@ -1,12 +1,11 @@
1
-
2
  import logging
3
  from typing import Dict, List, Union
4
  from bson import ObjectId
5
  import ast
6
  import json
7
  import re
8
- from pymongo.collation import Collation
9
  from pymongo import MongoClient
 
10
 
11
  logging.basicConfig(
12
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -14,6 +13,42 @@ logging.basicConfig(
14
  )
15
  logger = logging.getLogger("mongo_service")
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def convert_oid(obj):
18
  """Recursively convert $oid format or ObjectId objects to strings."""
19
  if isinstance(obj, ObjectId):
@@ -34,23 +69,19 @@ def parse_query_input(query_input: Union[str, Dict, List]) -> Union[Dict, List]:
34
 
35
  query_str = str(query_input).strip()
36
 
37
- # 1. Try JSON
38
  try:
39
  return json.loads(query_str)
40
  except json.JSONDecodeError:
41
  pass
42
 
43
- # 2. Extract code block
44
  match = re.search(r"```(?:python|json|javascript)?\s*(.*?)\s*```", query_str, re.DOTALL)
45
  if match:
46
  query_str = match.group(1).strip()
47
 
48
- # 3. Clean up MongoDB shell syntax
49
  query_str = re.sub(r'^db\.\w+\.\w+\(', '', query_str)
50
  if query_str.endswith(')'):
51
  query_str = query_str[:-1]
52
 
53
- # 4. AST Literal Eval
54
  try:
55
  parsed = ast.literal_eval(query_str)
56
  return convert_oid(parsed)
@@ -59,28 +90,30 @@ def parse_query_input(query_input: Union[str, Dict, List]) -> Union[Dict, List]:
59
  raise ValueError(f"Could not parse query string: {str(e)}")
60
 
61
  def get_value_ignore_case(d: Dict, keys: List[str], default=None):
62
- """Helper to get value checking multiple key variations (camelCase/snake_case)."""
63
  for k in keys:
64
  if k in d:
65
  return d[k]
66
  return default
67
 
 
 
 
 
68
  def execute_mongo_operation(
69
  mongo_uri: str,
70
  db_name: str,
71
  collection_name: str,
72
  query: Union[Dict, List],
73
- limited: bool = False, # <--- Added
74
- limit_rows: int = 20 # <--- Added
75
  ):
76
  """
77
- Generic MongoDB Executor.
78
- Supports: Aggregation Pipelines (List) and Find Operations (Dict).
79
- Now supports explicit row limiting.
80
  """
81
- client = None
 
 
82
  try:
83
- client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000)
84
  db = client[db_name]
85
  collection = db[collection_name]
86
 
@@ -88,13 +121,9 @@ def execute_mongo_operation(
88
 
89
  # --- Aggregation ---
90
  if isinstance(query, list):
91
- logger.info("Executing Aggregation Pipeline")
92
-
93
  # Apply Limit if requested and not already present at the end
94
  if limited:
95
- # Check if the last stage is already a $limit
96
  if not (query and "$limit" in query[-1]):
97
- logger.info(f"Injecting $limit: {limit_rows} to pipeline")
98
  query.append({"$limit": limit_rows})
99
 
100
  cursor = collection.aggregate(query, allowDiskUse=True)
@@ -102,20 +131,16 @@ def execute_mongo_operation(
102
 
103
  # --- Find / Command ---
104
  elif isinstance(query, dict):
105
- logger.info("Executing Structured Find Query")
106
-
107
  command_keys = {'filter', 'query', '$query', 'projection', 'sort', 'limit', 'skip'}
108
  has_command_keys = bool(set(query.keys()) & command_keys)
109
 
110
  cursor = None
111
 
112
  if has_command_keys and ('filter' in query or 'query' in query or '$query' in query):
113
- # It is a Structured Command
114
  query_filter = get_value_ignore_case(query, ['filter', 'query', '$query'], {})
115
  projection = get_value_ignore_case(query, ['projection', 'fields'], None)
116
  if projection == {}: projection = None
117
 
118
- # Check internal limit
119
  internal_limit = int(get_value_ignore_case(query, ['limit'], 0))
120
  skip = int(get_value_ignore_case(query, ['skip'], 0))
121
  sort_val = get_value_ignore_case(query, ['sort', '$orderby'], None)
@@ -129,18 +154,13 @@ def execute_mongo_operation(
129
 
130
  if skip > 0: cursor = cursor.skip(skip)
131
 
132
- # Logic: If 'limited' is forced (Chat Mode), use limit_rows.
133
- # Otherwise use the query's internal limit if it exists.
134
  if limited:
135
  cursor = cursor.limit(limit_rows)
136
  elif internal_limit > 0:
137
  cursor = cursor.limit(internal_limit)
138
 
139
  else:
140
- # It is a Raw Filter
141
- logger.info("Treating input dictionary as Raw Filter")
142
  cursor = collection.find(query)
143
-
144
  if limited:
145
  cursor = cursor.limit(limit_rows)
146
 
@@ -153,6 +173,7 @@ def execute_mongo_operation(
153
  except Exception as e:
154
  logger.error(f"DB Execution Error: {e}")
155
  raise e
156
- finally:
157
- if client:
158
- client.close()
 
 
 
1
  import logging
2
  from typing import Dict, List, Union
3
  from bson import ObjectId
4
  import ast
5
  import json
6
  import re
 
7
  from pymongo import MongoClient
8
+ from threading import Lock # Needed for thread-safe pooling
9
 
10
  logging.basicConfig(
11
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
 
13
  )
14
  logger = logging.getLogger("mongo_service")
15
 
16
+ # ==============================================================================
17
+ # MONGO CONNECTION POOL MANAGER
18
+ # ==============================================================================
19
+ class MongoConnectionManager:
20
+ def __init__(self):
21
+ self._clients: Dict[str, MongoClient] = {}
22
+ self._lock = Lock()
23
+
24
+ def get_client(self, uri: str) -> MongoClient:
25
+ """
26
+ Returns a cached MongoClient instance.
27
+ If it doesn't exist, creates one with a connection pool.
28
+ """
29
+ # Double-checked locking for performance
30
+ if uri not in self._clients:
31
+ with self._lock:
32
+ if uri not in self._clients:
33
+ logger.info(f"Initialize new MongoDB Client for URI: {uri[:20]}...")
34
+ # maxPoolSize=50 allows 50 concurrent ops per URI.
35
+ # The rest will wait in queue automatically.
36
+ self._clients[uri] = MongoClient(
37
+ uri,
38
+ serverSelectionTimeoutMS=5000,
39
+ connectTimeoutMS=5000,
40
+ maxPoolSize=50,
41
+ minPoolSize=1
42
+ )
43
+ return self._clients[uri]
44
+
45
+ # Global Manager Instance
46
+ mongo_manager = MongoConnectionManager()
47
+
48
+ # ==============================================================================
49
+ # HELPER FUNCTIONS (Unchanged)
50
+ # ==============================================================================
51
+
52
  def convert_oid(obj):
53
  """Recursively convert $oid format or ObjectId objects to strings."""
54
  if isinstance(obj, ObjectId):
 
69
 
70
  query_str = str(query_input).strip()
71
 
 
72
  try:
73
  return json.loads(query_str)
74
  except json.JSONDecodeError:
75
  pass
76
 
 
77
  match = re.search(r"```(?:python|json|javascript)?\s*(.*?)\s*```", query_str, re.DOTALL)
78
  if match:
79
  query_str = match.group(1).strip()
80
 
 
81
  query_str = re.sub(r'^db\.\w+\.\w+\(', '', query_str)
82
  if query_str.endswith(')'):
83
  query_str = query_str[:-1]
84
 
 
85
  try:
86
  parsed = ast.literal_eval(query_str)
87
  return convert_oid(parsed)
 
90
  raise ValueError(f"Could not parse query string: {str(e)}")
91
 
92
  def get_value_ignore_case(d: Dict, keys: List[str], default=None):
 
93
  for k in keys:
94
  if k in d:
95
  return d[k]
96
  return default
97
 
98
+ # ==============================================================================
99
+ # MAIN EXECUTION LOGIC (Modified)
100
+ # ==============================================================================
101
+
102
  def execute_mongo_operation(
103
  mongo_uri: str,
104
  db_name: str,
105
  collection_name: str,
106
  query: Union[Dict, List],
107
+ limited: bool = False,
108
+ limit_rows: int = 20
109
  ):
110
  """
111
+ Executes MongoDB operations using the shared connection pool.
 
 
112
  """
113
+ # 1. GET CLIENT FROM MANAGER (Do NOT create new MongoClient here)
114
+ client = mongo_manager.get_client(mongo_uri)
115
+
116
  try:
 
117
  db = client[db_name]
118
  collection = db[collection_name]
119
 
 
121
 
122
  # --- Aggregation ---
123
  if isinstance(query, list):
 
 
124
  # Apply Limit if requested and not already present at the end
125
  if limited:
 
126
  if not (query and "$limit" in query[-1]):
 
127
  query.append({"$limit": limit_rows})
128
 
129
  cursor = collection.aggregate(query, allowDiskUse=True)
 
131
 
132
  # --- Find / Command ---
133
  elif isinstance(query, dict):
 
 
134
  command_keys = {'filter', 'query', '$query', 'projection', 'sort', 'limit', 'skip'}
135
  has_command_keys = bool(set(query.keys()) & command_keys)
136
 
137
  cursor = None
138
 
139
  if has_command_keys and ('filter' in query or 'query' in query or '$query' in query):
 
140
  query_filter = get_value_ignore_case(query, ['filter', 'query', '$query'], {})
141
  projection = get_value_ignore_case(query, ['projection', 'fields'], None)
142
  if projection == {}: projection = None
143
 
 
144
  internal_limit = int(get_value_ignore_case(query, ['limit'], 0))
145
  skip = int(get_value_ignore_case(query, ['skip'], 0))
146
  sort_val = get_value_ignore_case(query, ['sort', '$orderby'], None)
 
154
 
155
  if skip > 0: cursor = cursor.skip(skip)
156
 
 
 
157
  if limited:
158
  cursor = cursor.limit(limit_rows)
159
  elif internal_limit > 0:
160
  cursor = cursor.limit(internal_limit)
161
 
162
  else:
 
 
163
  cursor = collection.find(query)
 
164
  if limited:
165
  cursor = cursor.limit(limit_rows)
166
 
 
173
  except Exception as e:
174
  logger.error(f"DB Execution Error: {e}")
175
  raise e
176
+
177
+ # IMPORTANT: DO NOT CLOSE THE CLIENT
178
+ # finally:
179
+ # if client: client.close() <-- REMOVED