Spaces:
Running
Running
File size: 26,909 Bytes
ee7d7b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 | from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
from pydantic import BaseModel
import asyncio
import logging
logger = logging.getLogger(__name__)
import json
from pathlib import Path
from datetime import datetime
from uuid import uuid4
from typing import Dict, Any
from core.connectors.postgres import PostgresConnector
from core.connectors.snowflake_connector import SnowflakeConnector
from core.connectors.kafka_connector import KafkaConnector
# In-memory store for streaming sessions (will be replaced with PostgreSQL persistence)
# In prod, this interacts with public.data_connections
MOCK_DB_CONNECTIONS: Dict[str, Dict[str, Any]] = {}
router = APIRouter()
from core.auth import get_current_user, AuthenticatedUser
from database.db import get_db
from database.orm import DataConnection
from sqlalchemy.ext.asyncio import AsyncSession
class ConnectionRequest(BaseModel):
source_type: str # 'postgres', 'snowflake', 'kafka'
host: str
database_name: str
target_table: str
credentials: str
def connection_target(connection: DataConnection) -> str:
"""Compatibility accessor for the unified DataConnection schema."""
return (connection.connection_params or {}).get("target_table", "")
@router.post("/connections")
async def create_connection(
req: ConnectionRequest,
user: AuthenticatedUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Securely store user connection credentials in the native postgres DB.
Returns a connection_id that the WebSocket can use to authenticate and stream.
"""
import uuid
conn_id = str(uuid.uuid4())
is_guest = str(user.id).startswith('guest_')
if req.source_type == "api_push":
if is_guest:
# Embed the guest user_id into the connection_id so the push endpoint knows who it is!
conn_id = f"{user.id}_push_{conn_id}"
else:
# For authenticated users, keep it as a raw UUID so it can be saved in the database
pass
# If guest user, bypass DB to avoid UUID errors and return mock connection for localStorage
if is_guest:
return {
"connection_id": conn_id,
"status": "success",
"message": "Guest connection created.",
"is_guest": True,
"connection": {
"id": conn_id,
"source_type": req.source_type,
"host": req.host,
"database_name": req.database_name,
"target_table": req.target_table,
"created_at": datetime.utcnow().isoformat()
}
}
# 🔍 Deduplication / Conflict Logic
from sqlalchemy import select
existing_result = await db.execute(select(DataConnection).where(DataConnection.user_id == user.id))
existing_connections = existing_result.scalars().all()
# 1. Check if exact same connection exists (Same Data = Don't Delete)
for conn in existing_connections:
if (conn.source_type == req.source_type and
conn.host == req.host and
conn.database_name == req.database_name and
connection_target(conn) == req.target_table):
return {"connection_id": str(conn.id), "status": "success", "message": "Connection already exists. Data kept."}
# 2. Check if DIFFERENT connections exist (Different Data = Tell User to Delete)
if len(existing_connections) > 0:
raise HTTPException(
status_code=409,
detail="⚠️ Different dataset detected! Please delete your previous data connections first to avoid AI context conflicts."
)
# Store in the native Datavision PostgreSQL database (data_connections table)
new_connection = DataConnection(
id=conn_id,
user_id=user.id,
name=f"{req.source_type}: {req.target_table}",
source_type=req.source_type,
host=req.host,
database_name=req.database_name,
encrypted_credentials=req.credentials,
connection_params={"target_table": req.target_table, "telemetry": {}}
)
db.add(new_connection)
await db.commit()
# Clear the user's DataFrame cache so AI tools pull the new live stream
from api.v1.endpoints.charts import clear_user_cache
clear_user_cache(user.id)
return {"connection_id": conn_id, "status": "success", "is_guest": False}
@router.get("/connections")
async def get_connections(
user: AuthenticatedUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all active live connections for the user."""
if str(user.id).startswith('guest_'):
return {"connections": []}
from sqlalchemy import select
result = await db.execute(select(DataConnection).where(DataConnection.user_id == user.id).order_by(DataConnection.created_at.desc()))
connections = result.scalars().all()
return {
"connections": [
{
"id": str(conn.id),
"source_type": conn.source_type,
"host": conn.host,
"database_name": conn.database_name,
"target_table": connection_target(conn),
"created_at": conn.created_at.isoformat()
}
for conn in connections
]
}
@router.delete("/connections/{connection_id}")
async def delete_connection(
connection_id: str,
user: AuthenticatedUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete a live connection."""
if str(user.id).startswith('guest_'):
return {"status": "success"}
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError
clean_id = connection_id
if clean_id.startswith("push_"):
clean_id = clean_id[5:]
elif "_push_" in clean_id:
clean_id = clean_id.split("_push_")[1]
try:
result = await db.execute(select(DataConnection).where(DataConnection.id == clean_id, DataConnection.user_id == user.id))
conn = result.scalar_one_or_none()
except DBAPIError:
# If it's completely unparseable as UUID, it doesn't exist in DB
raise HTTPException(status_code=404, detail="Connection not found")
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
await db.delete(conn)
await db.commit()
# Clear the user's DataFrame cache so AI tools update immediately
from api.v1.endpoints.charts import clear_user_cache
clear_user_cache(user.id)
return {"status": "success"}
@router.post("/connections/adopt")
async def adopt_guest_connection(
payload: dict,
user: AuthenticatedUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Adopt a guest localStorage connection into the authenticated user's account.
This creates a real DB entry and copies any existing CSV data.
"""
if str(user.id).startswith('guest_'):
raise HTTPException(status_code=400, detail="Guest users cannot adopt connections")
guest_conn_id = payload.get("guest_connection_id", "")
source_type = payload.get("source_type", "api_push")
host = payload.get("host", "localhost")
database_name = payload.get("database_name", "")
target_table = payload.get("target_table", "live_data")
if not guest_conn_id:
raise HTTPException(status_code=400, detail="guest_connection_id is required")
import uuid as uuid_mod
# Extract the clean UUID from the guest connection ID
clean_uuid = guest_conn_id
if "_push_" in guest_conn_id:
clean_uuid = guest_conn_id.split("_push_")[1]
elif guest_conn_id.startswith("push_"):
clean_uuid = guest_conn_id[5:]
# Validate it's a proper UUID
try:
uuid_mod.UUID(clean_uuid)
except ValueError:
# Generate a new UUID if the old one isn't valid
clean_uuid = str(uuid_mod.uuid4())
# Check if this exact connection already exists for the user
from sqlalchemy import select
existing = await db.execute(
select(DataConnection).where(DataConnection.user_id == user.id)
)
existing_conns = existing.scalars().all()
for ec in existing_conns:
if (ec.source_type == source_type and ec.host == host and
ec.database_name == database_name and connection_target(ec) == target_table):
# Already adopted — just return the existing connection
return {"status": "success", "connection_id": str(ec.id), "message": "Already adopted"}
if len(existing_conns) > 0:
# Different data exists — don't conflict
raise HTTPException(status_code=409, detail="Delete existing connections first")
# Create the DB entry
new_conn = DataConnection(
id=clean_uuid,
user_id=user.id,
name=f"{source_type}: {target_table}",
source_type=source_type,
host=host,
database_name=database_name,
encrypted_credentials="adopted",
connection_params={"target_table": target_table, "telemetry": {}}
)
db.add(new_conn)
await db.commit()
# Copy any existing CSV from guest folder to user folder
try:
from utils.paths import get_user_paths
import shutil
guest_user_id = guest_conn_id.split("_push_")[0] if "_push_" in guest_conn_id else None
if guest_user_id:
guest_paths = get_user_paths(guest_user_id)
user_paths = get_user_paths(str(user.id))
# Look for any CSV files in the guest folder matching this connection
if guest_paths["files"].exists():
for csv_file in guest_paths["files"].glob("live_stream_*.csv"):
dest = user_paths["files"] / f"live_stream_{clean_uuid[:12]}.csv"
shutil.copy2(str(csv_file), str(dest))
logger.info(f"Migrated CSV {csv_file} -> {dest}")
break # Only copy the first match
except Exception as e:
logger.warning(f"Failed to copy guest CSV: {e}")
# Clear the user's DataFrame cache
from api.v1.endpoints.charts import clear_user_cache
clear_user_cache(user.id)
return {
"status": "success",
"connection_id": clean_uuid,
"message": "Guest connection adopted to your account"
}
import time
PUSH_TELEMETRY: Dict[str, Dict[str, Any]] = {}
def _telemetry_state_path(user_id: str, connection_id: str) -> Path:
from utils.paths import get_user_paths
return get_user_paths(user_id)["files"] / f"live_stream_{connection_id[:12]}.telemetry.json"
def _read_telemetry_state(user_id: str, connection_id: str) -> dict:
try:
with _telemetry_state_path(user_id, connection_id).open("r", encoding="utf-8") as state_file:
return json.load(state_file)
except (OSError, ValueError, TypeError):
return {}
def _write_telemetry_state(user_id: str, connection_id: str, telemetry: dict) -> None:
"""Durable state lets a restarted API resume its displayed counters."""
state_path = _telemetry_state_path(user_id, connection_id)
temporary_path = state_path.with_suffix(".tmp")
with temporary_path.open("w", encoding="utf-8") as state_file:
json.dump(telemetry, state_file)
temporary_path.replace(state_path)
class ConnectionManager:
def __init__(self):
# Maps connection_id to a list of active WebSockets
self.active_connections: dict[str, list[WebSocket]] = {}
async def connect(self, websocket: WebSocket, connection_id: str):
await websocket.accept()
if connection_id not in self.active_connections:
self.active_connections[connection_id] = []
self.active_connections[connection_id].append(websocket)
def disconnect(self, websocket: WebSocket, connection_id: str):
if connection_id in self.active_connections:
if websocket in self.active_connections[connection_id]:
self.active_connections[connection_id].remove(websocket)
if not self.active_connections[connection_id]:
del self.active_connections[connection_id]
async def push_data(self, connection_id: str, data: dict) -> int:
target_sockets = set()
clean_target = connection_id.split("_push_")[-1] if "_push_" in connection_id else connection_id
if clean_target.startswith("push_"):
clean_target = clean_target[5:]
# Match exact connection_id or partial token
for cid, sockets in list(self.active_connections.items()):
clean_cid = cid.split("_push_")[-1] if "_push_" in cid else cid
if clean_cid.startswith("push_"):
clean_cid = clean_cid[5:]
if cid == connection_id or clean_cid == clean_target or clean_target in cid or clean_cid in connection_id:
for ws in sockets:
target_sockets.add(ws)
# Fallback: if user is listening in modal on any socket, broadcast telemetry to open sockets
if not target_sockets:
for sockets in self.active_connections.values():
for ws in sockets:
target_sockets.add(ws)
sent_count = 0
for ws in list(target_sockets):
try:
await ws.send_text(json.dumps(data))
sent_count += 1
except Exception as e:
logger.error(f"Failed to push data to websocket: {e}")
return max(sent_count, 1)
manager = ConnectionManager()
from database.db import AsyncSessionLocal
from sqlalchemy import select
@router.websocket("/ws/live-data/{connection_id}")
async def websocket_live_data(websocket: WebSocket, connection_id: str):
"""
WebSocket endpoint for streaming real telemetry using actual connection classes.
"""
await manager.connect(websocket, connection_id)
# Fetch credentials
conn_data = None
if "_push_" in connection_id or connection_id.startswith("push_"):
conn_data = {
"source_type": "api_push",
"host": "datavision",
"database_name": "push",
"target_table": "push",
"credentials": "none"
}
else:
# Check database. If it's a UUID, it might be an authenticated user's api_push or postgres/snowflake
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.id == connection_id))
db_conn = result.scalar_one_or_none()
if db_conn:
conn_data = {
"source_type": db_conn.source_type,
"host": db_conn.host,
"database_name": db_conn.database_name,
"target_table": connection_target(db_conn),
"credentials": db_conn.encrypted_credentials
}
if not conn_data:
await websocket.send_text(json.dumps({"error": "Invalid connection ID or unauthorized."}))
manager.disconnect(websocket, connection_id)
return
# Handle DataVision API Push (Passive receiver)
# Guest connections migrated from the browser keep their original connector
# label (for example api_push_postgresql). They are still API-push streams.
if conn_data['source_type'].lower().startswith('api_push'):
try:
clean_id = connection_id.split("_push_")[-1] if "_push_" in connection_id else connection_id.removeprefix("push_")
state_owner = connection_id.split("_push_")[0] if connection_id.startswith("guest_") and "_push_" in connection_id else None
if not state_owner:
try:
async with AsyncSessionLocal() as state_db:
state_result = await state_db.execute(select(DataConnection).where(DataConnection.id == clean_id))
state_connection = state_result.scalar_one_or_none()
state_owner = str(state_connection.user_id) if state_connection else None
except Exception:
pass
persisted = _read_telemetry_state(state_owner, clean_id) if state_owner else {}
# Send initial confirmation
await websocket.send_text(json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"total_rows": persisted.get("total_rows", 0),
"rows_per_sec": 0,
"cpu_usage": persisted.get("cpu_usage", 0.0),
"error_rate": persisted.get("error_rate", 0.0),
"connector_source": "DataVision API",
"status": "Waiting for data pushes..." if not persisted else "Restored last durable stream state."
}))
# Keep connection alive indefinitely (or until client closes it)
# The actual data will be sent via `manager.push_data` from the POST endpoint.
while True:
# Keep alive ping
await asyncio.sleep(30)
except WebSocketDisconnect:
manager.disconnect(websocket, connection_id)
except Exception as e:
print(f"WebSocket API Push Error: {e}")
manager.disconnect(websocket, connection_id)
return
# Handle Active Polling Connectors
connector = None
if conn_data['source_type'].lower() in ('postgres', 'postgresql'):
connector = PostgresConnector(conn_data['host'], conn_data['database_name'], conn_data['credentials'], conn_data['target_table'])
elif conn_data['source_type'].lower() == 'snowflake':
connector = SnowflakeConnector(conn_data['host'], conn_data['database_name'], conn_data['credentials'], conn_data['target_table'])
elif conn_data['source_type'].lower() == 'kafka':
connector = KafkaConnector(conn_data['host'], conn_data['database_name'], conn_data['credentials'], conn_data['target_table'])
else:
await websocket.send_text(json.dumps({"error": "Unsupported source type."}))
manager.disconnect(websocket, connection_id)
return
try:
# Stream metrics indefinitely
async for metric in connector.get_metrics_stream():
await websocket.send_text(json.dumps(metric))
except WebSocketDisconnect:
manager.disconnect(websocket, connection_id)
except Exception as e:
print(f"WebSocket Streaming Error: {e}")
manager.disconnect(websocket, connection_id)
@router.post("/push/{connection_id}")
async def push_live_data(connection_id: str, payload: dict):
"""
Endpoint for users to push data directly into DataVision.
This bypasses the need for tunnels or local databases.
Also saves data as CSV so it appears in Uploaded Files and feeds AI/ML/Dashboard.
"""
# Calculate telemetry stats and row totals
clean_key = connection_id.split("_push_")[-1] if "_push_" in connection_id else connection_id
owner_user_id = None
clean_uuid = connection_id.split("_push_")[-1] if "_push_" in connection_id else connection_id.removeprefix("push_")
if connection_id.startswith("guest_") and "_push_" in connection_id:
owner_user_id = connection_id.split("_push_")[0]
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.id == clean_uuid))
connection = result.scalar_one_or_none()
if connection:
owner_user_id = str(connection.user_id)
except Exception as exc:
logger.warning("Unable to find live-stream owner: %s", exc)
if clean_key not in PUSH_TELEMETRY:
saved = _read_telemetry_state(owner_user_id, clean_uuid) if owner_user_id else {}
PUSH_TELEMETRY[clean_key] = {
"total_rows": int(saved.get("total_rows", 0) or 0),
"last_time": time.time(),
"rows_per_sec": 0,
}
# Never create a fake data row for a status/error-only heartbeat.
pushed_rows = 0
if isinstance(payload, dict):
if "rows" in payload and isinstance(payload["rows"], int):
pushed_rows = payload["rows"]
elif "data" in payload and isinstance(payload["data"], list):
pushed_rows = len(payload["data"])
elif "batch_size" in payload and isinstance(payload["batch_size"], int):
pushed_rows = payload["batch_size"]
elif "batch" in payload and isinstance(payload["batch"], int):
pushed_rows = payload["batch"]
elif isinstance(payload, list):
pushed_rows = len(payload)
# `total_rows` from the supplied clients is an absolute source count, not an
# increment. Adding it on every poll was the cause of bad totals.
reported_total = payload.get("total_rows") if isinstance(payload, dict) else None
if isinstance(reported_total, (int, float)) and reported_total >= 0:
PUSH_TELEMETRY[clean_key]["total_rows"] = max(PUSH_TELEMETRY[clean_key]["total_rows"], int(reported_total))
else:
PUSH_TELEMETRY[clean_key]["total_rows"] += pushed_rows
now = time.time()
dt = max(now - PUSH_TELEMETRY[clean_key]["last_time"], 0.1)
PUSH_TELEMETRY[clean_key]["rows_per_sec"] = int(pushed_rows / dt)
PUSH_TELEMETRY[clean_key]["last_time"] = now
telemetry_packet = {
"timestamp": datetime.utcnow().isoformat(),
"total_rows": PUSH_TELEMETRY[clean_key]["total_rows"],
"rows_per_sec": PUSH_TELEMETRY[clean_key]["rows_per_sec"],
"cpu_usage": 14.2,
"error_rate": 0.0,
"connector_source": payload.get("connector_source", "DataVision API"),
"status": f"Telemetry OK. Rows: {PUSH_TELEMETRY[clean_key]['total_rows']}, Velocity: {PUSH_TELEMETRY[clean_key]['rows_per_sec']}/s"
}
if owner_user_id:
try:
_write_telemetry_state(owner_user_id, clean_uuid, telemetry_packet)
except OSError as exc:
logger.warning("Could not persist telemetry: %s", exc)
# Save data as CSV for Uploaded Files integration
try:
import pandas as pd
from utils.paths import get_user_paths
if owner_user_id:
paths = get_user_paths(owner_user_id)
csv_path = paths["files"] / f"live_stream_{clean_uuid[:12]}.csv"
raw_rows = payload.get("data")
if isinstance(raw_rows, list):
new_df = pd.DataFrame([row for row in raw_rows if isinstance(row, dict)])
elif isinstance(raw_rows, dict):
new_df = pd.DataFrame([raw_rows])
else:
new_df = pd.DataFrame()
if new_df.empty:
# Metrics-only polls are represented by the live connection, not
# falsely presented as source rows in Data Hub.
new_df = None
if new_df is None:
raise StopIteration
if csv_path.exists():
try:
existing = pd.read_csv(csv_path)
combined = pd.concat([existing, new_df], ignore_index=True)
# Connector clients may replay a poll after a restart. Exact
# record de-duplication makes reconnects idempotent while new
# source rows continue to append.
combined = combined.drop_duplicates().tail(10000)
combined.to_csv(csv_path, index=False)
except Exception:
new_df.to_csv(csv_path, index=False)
else:
new_df.to_csv(csv_path, index=False)
try:
from api.v1.endpoints.charts import clear_user_cache
clear_user_cache(owner_user_id)
except Exception as cache_e:
logger.warning(f"Could not clear cache for {owner_user_id}: {cache_e}")
except StopIteration:
pass
except Exception as e:
logger.warning(f"Failed to save push data as CSV: {e}")
# Broadcast telemetry packet to all websockets listening
broadcast_count = await manager.push_data(connection_id, telemetry_packet)
return {"status": "success", "broadcast_count": broadcast_count, "total_rows": PUSH_TELEMETRY[clean_key]["total_rows"]}
@router.get("/delta")
async def check_live_delta(user_id: str = Header(None)):
"""
Check the total row count across all active live pipelines for auto-regeneration.
"""
if not user_id:
from config.settings import settings
user_id = settings.DEFAULT_USER_ID
try:
total_rows = 0
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.user_id == user_id))
connections = result.scalars().all()
# Since live row count counting across massive tables can be slow,
# we do a quick count heuristic or just query it if it's indexed.
import psycopg2
for conn in connections:
if conn.source_type.lower() in ('postgres', 'postgresql'):
try:
import urllib.parse
safe_creds = urllib.parse.quote_plus(conn.encrypted_credentials)
conn_str = f"postgresql://postgres:{safe_creds}@{conn.host}/{conn.database_name}"
target_table = connection_target(conn)
if not target_table:
continue
with psycopg2.connect(conn_str) as pg_conn:
with pg_conn.cursor() as cur:
# Reltuples is instant (approximate, but good enough for deltas)
cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = %s", (target_table,))
row = cur.fetchone()
if row and row[0]:
total_rows += row[0]
else:
# Fallback to exact count if reltuples fails
cur.execute(f"SELECT COUNT(*) FROM {target_table}")
total_rows += cur.fetchone()[0]
except Exception as e:
logger.error(f"Delta check failed for connection {conn.id}: {e}")
return {"total_rows": total_rows}
except Exception as e:
logger.error(f"Delta endpoint error: {e}")
return {"error": str(e)}
|