rmi-backend/app/core/duckdb_analytics.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

261 lines
9.6 KiB
Python

"""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.<table> references.
Returns:
list of dicts.
"""
pg_url = os.getenv("PG_URL") or os.getenv("DATABASE_URL") or "postgres://rmi:postgres@localhost:5432/rmi"
self._attach_postgres(pg_url)
return self.query(sql)
def query_parquet(self, parquet_path: str, sql: str | None = None) -> list[dict[str, Any]]:
"""Query a Parquet file directly (no ingestion needed).
Args:
parquet_path: Path or glob to Parquet file(s).
sql: SQL query. If None, returns SELECT * FROM read_parquet(path).
The path is bound to a 'parquet' table for the query.
Examples:
db.query_parquet('s3://bucket/export.parquet')
db.query_parquet('/tmp/*.parquet', 'SELECT count(*) AS n FROM parquet')
"""
# Bind parquet path to a table for the duration of the query
bind_sql = f"SELECT * FROM read_parquet('{parquet_path}')"
if sql is None: # noqa: SIM108
sql = bind_sql
else:
# Inject the parquet binding as a CTE the user can reference
sql = f"WITH parquet AS ({bind_sql}) {sql}"
return self.query(sql)
def register_dataframe(self, name: str, df: Any) -> None:
"""Register a pandas/polars DataFrame as a queryable table.
Args:
name: Table name to use in queries.
df: pandas.DataFrame or polars.DataFrame.
"""
self._conn.register(name, df)
log.info("duckdb_register_df name=%s rows=%d", name, len(df))
def export_to_parquet(self, sql: str, output_path: str, params: list[Any] | None = None) -> int:
"""Run a query and export results to Parquet.
Args:
sql: SQL query (results become the Parquet content).
output_path: Where to write the Parquet file.
params: Optional parameter list.
Returns:
Number of rows exported.
"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# Use COPY (SELECT ... ) TO 'file.parquet' (FORMAT PARQUET) for direct export
start = time.monotonic()
self._conn.execute(f"COPY ({sql}) TO ? (FORMAT PARQUET)", [output_path, *(params or [])])
elapsed_ms = (time.monotonic() - start) * 1000
# Count rows
rows = self.query(f"SELECT count(*) AS n FROM '{output_path}'")
n = rows[0]["n"] if rows else 0
log.info(
"duckdb_export_parquet rows=%d path=%s took_ms=%.2f",
n, output_path, elapsed_ms,
)
return int(n)
def table_exists(self, table_name: str) -> bool:
"""Check if a table is registered in this connection."""
rows = self.query(
"SELECT count(*) AS n FROM information_schema.tables WHERE table_name = ?",
[table_name],
)
return bool(rows and rows[0]["n"] > 0)
def list_tables(self) -> list[str]:
"""List all registered tables."""
rows = self.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name"
)
return [r["table_name"] for r in rows]
def _attach_postgres(self, pg_url: str) -> None:
"""Attach a Postgres DB as READ_ONLY under the alias 'pg'.
Idempotent: skips if already attached.
"""
if "pg" in self._attached:
return
# DuckDB's ATTACH syntax for Postgres: ATTACH 'postgres://...' AS pg (READ_ONLY)
# Use the SQL escaping: escape single quotes in URL by doubling them
escaped_url = pg_url.replace("'", "''")
self._conn.execute(f"ATTACH '{escaped_url}' AS pg (READ_ONLY)")
self._attached.add("pg")
log.info("duckdb_attached_postgres alias=pg")
def close(self) -> None:
"""Close the DuckDB connection."""
try:
self._conn.close()
log.debug("duckdb_analytics_closed persist=%s", self._persist_path)
except Exception as e:
log.warning("duckdb_close_err: %s", e)
def __enter__(self) -> DuckDBAnalytics:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
# Convenience factory
_default_instance: DuckDBAnalytics | None = None
def get_default_analytics() -> DuckDBAnalytics:
"""Get a process-wide DuckDBAnalytics instance.
Use this for one-off analytics queries that don't need their own
persistent DB. The instance is reused across calls.
"""
global _default_instance
if _default_instance is None:
_default_instance = DuckDBAnalytics()
return _default_instance