admin / Syanpse-Vanguard
public
Syanpse-Vanguard / synapse-vanguard-v3 / sv / storage / duckdb_engine.py
4728 B · main
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 | """Embedded DuckDB engine — the zero-config default backend. DuckDB is single-writer and not natively async, so all calls are dispatched to a thread and serialized behind a lock. Batches are converted to a columnar Arrow table and appended in one INSERT for throughput.""" from __future__ import annotations import asyncio import os from datetime import datetime from importlib import resources from typing import Any, AsyncIterator, Sequence import duckdb import pyarrow as pa from sv.models.event import STORAGE_COLUMNS, EventModel from sv.storage.base import Query, StorageEngine class DuckDBEngine(StorageEngine): def __init__(self, path: str, cold_dir: str): if path != ":memory:": os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) os.makedirs(cold_dir, exist_ok=True) self._con = duckdb.connect(path) self._cold_dir = cold_dir self._lock = asyncio.Lock() async def _run(self, fn): async with self._lock: return await asyncio.to_thread(fn) # Idempotent, additive migrations for databases created by an earlier # version (e.g. a reused v1 volume where CREATE TABLE IF NOT EXISTS is a # no-op). Each statement is safe to run repeatedly. _MIGRATIONS = [ "ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_ip VARCHAR", "ALTER TABLE alert_rules ADD COLUMN IF NOT EXISTS webhook_id VARCHAR", ] async def init_schema(self) -> None: ddl = resources.files("sv.storage").joinpath("schema.sql").read_text() def _do(): self._con.execute(ddl) for stmt in self._MIGRATIONS: try: self._con.execute(stmt) except Exception: # noqa: BLE001 — column already present / older engine pass await self._run(_do) async def write_batch(self, events: Sequence[EventModel]) -> int: if not events: return 0 table = _events_to_arrow(events) def _do(): self._con.register("_batch", table) self._con.execute( f"INSERT INTO events ({', '.join(STORAGE_COLUMNS)}) " f"SELECT {', '.join(STORAGE_COLUMNS)} FROM _batch" ) self._con.unregister("_batch") return table.num_rows return await self._run(_do) async def execute(self, query: Query) -> pa.Table: return await self._run( lambda: self._con.execute(query.text, query.params).fetch_arrow_table() ) async def stream(self, query: Query, batch_rows: int = 10_000 ) -> AsyncIterator[pa.RecordBatch]: reader = await self._run( lambda: self._con.execute(query.text, query.params) .fetch_record_batch(batch_rows) ) while True: batch = await self._run(lambda: next(reader, None)) if batch is None: break yield batch async def retention_sweep(self, older_than: datetime) -> int: def _do(): self._con.execute( "COPY (SELECT * FROM events WHERE ts < ?) TO ? " "(FORMAT parquet, PARTITION_BY (tenant_id), APPEND)", [older_than, self._cold_dir], ) self._con.execute("DELETE FROM events WHERE ts < ?", [older_than]) return self._con.execute( "SELECT changes()" # rows affected by the last statement ).fetchone()[0] return await self._run(_do) async def fetchone(self, query: Query) -> dict | None: def _do(): cur = self._con.execute(query.text, query.params) row = cur.fetchone() if row is None: return None cols = [d[0] for d in cur.description] return dict(zip(cols, row)) return await self._run(_do) async def fetchall(self, query: Query) -> list[dict]: def _do(): cur = self._con.execute(query.text, query.params) rows = cur.fetchall() cols = [d[0] for d in cur.description] return [dict(zip(cols, r)) for r in rows] return await self._run(_do) async def run(self, query: Query) -> None: await self._run(lambda: self._con.execute(query.text, query.params)) async def close(self) -> None: await self._run(self._con.close) def _events_to_arrow(events: Sequence[EventModel]) -> pa.Table: cols: dict[str, list[Any]] = {c: [] for c in STORAGE_COLUMNS} for e in events: row = e.to_storage_row() for c in STORAGE_COLUMNS: cols[c].append(row[c]) return pa.table(cols) |