"""DuckDB Embedded Analytics - RMI v5 §T13 (P2). Per RMIV5: small analytics queries (<1 GB) don't need ClickHouse. DuckDB is in-process, 10x faster, zero infrastructure. Drop-in for ad-hoc queries on: - Exported Parquet/CSV from MinIO (S3-compatible) - Catalog CSV exports - Cross-source joins (Postgres + Parquet) Why DuckDB: - No server to operate (in-process, like SQLite but columnar) - Native Parquet/CSV/JSON readers - no ETL needed - Postgres wire protocol compatible (could expose as service later) - Vectorized execution, ~10x faster than ClickHouse for small queries - Can ATTACH Postgres as a read source for cross-DB joins Architecture: - DuckDBAnalytics(): main entry point, in-memory by default - query(sql, params): run arbitrary SQL, return rows as list[dict] - query_postgres(sql): attach Postgres as READ_ONLY, run join query - query_parquet(path, sql): query exported Parquet files - register_dataframe(name, df): register a pandas DataFrame as a table - export_to_parquet(sql, path): export query results to Parquet - close(): close the connection Thread safety: each DuckDBAnalytics instance is owned by one caller. For concurrent use, create separate instances or use a connection pool. Per RMIV5 v4.0 §T31 (perf gap): ClickHouse has 2 GB memory cap + network overhead. DuckDB handles "give me counts by chain" in <10ms with zero setup. """ from __future__ import annotations import logging import os import time from pathlib import Path from typing import Any log = logging.getLogger(__name__) class DuckDBAnalytics: """Embedded DuckDB analytics engine. Default: in-memory database (fastest, no persistence). For persistent storage: DuckDBAnalytics(persist_path='/var/lib/duckdb/rmi.db'). """ def __init__( self, persist_path: str | None = None, threads: int | None = None, memory_limit: str | None = None, ) -> None: """Initialize DuckDB connection. Args: persist_path: If set, use a file-backed DB at this path. If None, use in-memory (lost on close). threads: Number of CPU threads. None = DuckDB default (cores). memory_limit: e.g. '2GB'. None = no limit. """ import duckdb # imported lazily so import cost only on first use config = {} if threads: config["threads"] = threads if memory_limit: config["memory_limit"] = memory_limit if persist_path: Path(persist_path).parent.mkdir(parents=True, exist_ok=True) self._conn = duckdb.connect(persist_path, config=config) log.info("duckdb_analytics_init persist=%s config=%s", persist_path, config) else: self._conn = duckdb.connect(":memory:", config=config) log.debug("duckdb_analytics_init in-memory config=%s", config) self._persist_path = persist_path self._attached: set[str] = set() # track attached DBs to avoid double-attach def query( self, sql: str, params: list[Any] | None = None, max_rows: int | None = None, ) -> list[dict[str, Any]]: """Execute a SQL query and return rows as list of dicts. Args: sql: SQL query. Use ? placeholders for params. params: List of parameter values for ? placeholders. max_rows: Optional cap on returned rows (for MCP/API safety). Returns: list of dicts, one per row. Empty list if no results. Examples: r = db.query("SELECT 1 AS n") # [{"n": 1}] r = db.query("SELECT count(*) AS c FROM tokens WHERE chain = ?", ["ethereum"]) # [{"c": 1234}] """ start = time.monotonic() try: cursor = self._conn.execute(sql, params or []) columns = [d[0] for d in cursor.description] if cursor.description else [] rows = cursor.fetchmany(max_rows) if max_rows is not None and max_rows > 0 else cursor.fetchall() elapsed_ms = (time.monotonic() - start) * 1000 log.info( "duckdb_query rows=%d columns=%d took_ms=%.2f", len(rows), len(columns), elapsed_ms, ) return [dict(zip(columns, row, strict=False)) for row in rows] except Exception as e: elapsed_ms = (time.monotonic() - start) * 1000 log.error("duckdb_query_fail took_ms=%.2f err=%s: %s", elapsed_ms, type(e).__name__, e) raise def query_postgres(self, sql: str) -> list[dict[str, Any]]: """Run SQL that joins/reads from Postgres. Attaches the configured Postgres DB as 'pg' (READ_ONLY) so the query can reference pg.table_name. Uses the env var PG_URL or DATABASE_URL. Example: db.query_postgres(''' SELECT t.chain, count(*) AS n FROM pg.tokens t WHERE t.deployed_at > ? GROUP BY t.chain ''') Args: sql: SQL with optional pg.