Crypto Rug Muncher
feat(mcp): TIER 1 moat — analytics_query + mcp_discover + status_check tools
0dd407e | """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 | |