File size: 9,795 Bytes
bde2f3a | 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 | """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:
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
|