Files
momentum-system/storage/event_store.py
momentum devandClaude Opus 4.8 9e730a3726 Remove DuckDB remnants (datastore is Postgres)
The duckdb library is already gone (not a dependency, not importable, no
`import duckdb` anywhere) — these were leftover text references that misled.

- Delete storage/schema.py: 418 lines of DuckDB DDL with zero importers. The
  Postgres schema is created by db_pg._apply_schema + event_store._ensure_schema.
- Neutralise stale default db_path strings (./data/momentum*.duckdb) in
  event_store, config, run_live_paper, dashboard_api — the connection layer
  ignores the path (DATABASE_URL is the datastore), so these phantom file paths
  only created confusion. Dropped the now-pointless os.makedirs for them.
- research/multi_schema: the "three DuckDB files" are one Postgres DB now (all
  resolve to the same connection); doc + constants updated to match reality.
- Fix stale "DuckDB handle" comments in api/main.

Kept the DuckDB mentions in storage/db_pg.py + storage/db.py — they document
why the Postgres facade mimics DuckDB's ? placeholders and :memory: semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:29 -04:00

244 lines
7.8 KiB
Python

"""Event store implementation for Milestone 2.
Append-only event storage with canonical event types.
"""
from .event_schema import (
BaseEvent,
EventType,
EventMode,
)
from .db import get_connection
from uuid import uuid4
import json
import logging
import threading
logger = logging.getLogger(__name__)
class EventStore:
"""Append-only event store for canonical events."""
def __init__(self, db_path: str = "momentum"):
"""Initialize event store. ``db_path`` is retained for call-site
compatibility; the datastore is Postgres (see storage.db_pg)."""
self.con = get_connection(db_path)
# serialize DB access: the fast trigger thread and the main loop share
# this one psycopg2 connection, which is not safe for concurrent use.
self._lock = threading.RLock()
self._ensure_schema()
def _ensure_schema(self):
"""Ensure event tables exist in database."""
cursor = self.con.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS events (
id VARCHAR PRIMARY KEY,
timestamp TIMESTAMP,
mode VARCHAR,
event_type VARCHAR,
correlation_id VARCHAR,
message VARCHAR,
payload_json TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
);
""")
# Composite (event_type, timestamp DESC) — THE hot-path index. Almost
# every projection runs `WHERE event_type=? ORDER BY timestamp DESC
# LIMIT n` (latest snapshot of a type). With only a plain timestamp
# index, looking up the latest of a STALE type backward-scans the whole
# table (measured: 235k rows filtered, ~370ms). This makes it an index
# range seek (sub-ms) regardless of how long ago that type last fired,
# and also serves bare `WHERE event_type=?` as the leftmost prefix.
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_type_ts
ON events(event_type, timestamp DESC);
""")
# plain timestamp index still earns its keep for type-agnostic range
# scans (e.g. `WHERE timestamp::date = CURRENT_DATE`).
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_session_id
ON events(correlation_id);
""")
# No index on payload_json: a btree over full JSON text is invalid for
# large payloads in Postgres and useless for the LIKE-based symbol
# filter (query_events scans with payload_json LIKE instead).
self.con.commit()
def emit(self, event: BaseEvent) -> str:
"""Emit an event to the event store.
Args:
event: Event to emit
Returns:
Event ID
"""
event_id = str(uuid4())
payload_json = json.dumps(event.model_dump(mode="json", exclude_unset=True))
with self._lock:
cursor = self.con.cursor()
cursor.execute(
"""
INSERT INTO events
(id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, current_timestamp)
""",
[
event_id,
event.timestamp,
event.mode.value,
event.event_type.value
if isinstance(event.event_type, EventType)
else str(event.event_type),
event.correlation_id,
event.message,
payload_json,
],
)
self.con.commit()
logger.debug(
f"Emitted event {event_id}: {event.event_type.value} - {event.message}"
)
return event_id
def query_events(
self,
event_type: str | None = None,
symbol: str | None = None,
correlation_id: str | None = None,
session_id: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int | None = 1000,
) -> list[dict]:
"""Query events from the store.
Args:
event_type: Filter by event type
symbol: Filter by symbol
correlation_id: Filter by correlation ID
session_id: Filter by session ID
since: Start timestamp (inclusive)
until: End timestamp (exclusive)
limit: Maximum number of results
Returns:
List of event dictionaries
"""
conditions = []
params = []
if event_type is not None:
conditions.append("event_type = ?")
params.append(event_type)
if symbol is not None:
conditions.append("payload_json LIKE ?")
params.append(f"%\"symbol\": \"{symbol}\"%")
if correlation_id is not None:
conditions.append("correlation_id = ?")
params.append(correlation_id)
if session_id is not None:
conditions.append("correlation_id = ?")
params.append(session_id)
if since is not None:
conditions.append("timestamp >= ?")
params.append(since)
if until is not None:
conditions.append("timestamp < ?")
params.append(until)
where_clause = " AND ".join(conditions) if conditions else "1=1"
limit_clause = f"LIMIT {limit}" if limit is not None else ""
query = f"""
SELECT id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at
FROM events
WHERE {where_clause}
ORDER BY timestamp ASC, created_at ASC
{limit_clause}
"""
with self._lock:
cursor = self.con.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
results = []
for row in rows:
results.append(
{
"id": row[0],
"timestamp": str(row[1]),
"mode": row[2],
"event_type": row[3],
"correlation_id": row[4],
"message": row[5],
"payload_json": row[6],
"created_at": str(row[7]),
}
)
return results
def count_events(self) -> int:
"""Total number of events. Cheap change-detection signal for SSE."""
with self._lock:
cursor = self.con.cursor()
cursor.execute("SELECT COUNT(*) FROM events")
row = cursor.fetchone()
return int(row[0]) if row else 0
def get_symbol_timeline(self, symbol: str) -> list[dict]:
"""Get full timeline for a symbol.
Args:
symbol: Ticker symbol
Returns:
List of all events for the symbol in chronological order
"""
return self.query_events(symbol=symbol, limit=None)
def rebuild_state(self, session_id: str) -> dict:
"""Rebuild state from event store for a session.
Args:
session_id: Session ID to rebuild
Returns:
Dictionary with session summary and latest state
"""
events = self.query_events(session_id=session_id, limit=None)
summary = {
"session_id": session_id,
"total_events": len(events),
"event_types": {},
}
for event in events:
event_type = event["event_type"]
summary["event_types"][event_type] = (
summary["event_types"].get(event_type, 0) + 1
)
return summary
def close(self):
"""Close event store connection."""
if self.con:
self.con.close()