fester/backend/storage/sqlite_db.py

420 lines
16 KiB
Python

"""
SQLite-backed storage for Fester.
Single database file at FESTER_DB_PATH (default: /var/lib/fester/fester.db,
with fallbacks to ~/.fester/fester.db and /tmp/fester.db).
Tables:
builds — build records (one row per build)
sessions — replay sessions
events — timeline events (one row per bus event)
node_states — latest known state per node (snapshot, not journal)
All writes are serialized through a single connection with a threading.Lock.
Reads are concurrent. The database is WAL-mode for better concurrency.
Used by:
- backend.pipeline.runner.BuildRunner (builds table)
- backend.session.db.SessionDB (sessions + events tables)
- backend.analysis.timeline_store.TimelineStore (events table)
- backend.main ambient loop (node_states table, optional)
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
# ----------------------------------------------------------------
# Database path resolution
# ----------------------------------------------------------------
def _resolve_db_path() -> Path:
env = os.environ.get("FESTER_DB_PATH")
if env:
p = Path(env)
p.parent.mkdir(parents=True, exist_ok=True)
return p
for c in [
Path("/var/lib/fester/fester.db"),
Path.home() / ".fester" / "fester.db",
Path("/tmp/fester.db"),
]:
try:
c.parent.mkdir(parents=True, exist_ok=True)
# touch to verify writability
if not c.exists():
c.write_text("")
else:
with open(c, "a"):
pass
return c
except (OSError, PermissionError):
continue
return Path(":memory:") # in-memory last resort
DB_PATH = _resolve_db_path()
# ----------------------------------------------------------------
# Storage backend
# ----------------------------------------------------------------
class Storage:
"""SQLite-backed storage with a serialization lock for writes."""
def __init__(self, path: Path = DB_PATH):
self.path = path
self._lock = threading.Lock()
self._conn = self._open(path)
self._migrate()
def _open(self, path: Path) -> sqlite3.Connection:
# Allow in-memory for tests
if str(path) == ":memory:":
conn = sqlite3.connect(":memory:", check_same_thread=False)
else:
conn = sqlite3.connect(str(path), check_same_thread=False)
conn.row_factory = sqlite3.Row
# WAL mode for better read/write concurrency
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
except sqlite3.OperationalError:
pass # in-memory db doesn't support WAL
return conn
def _migrate(self):
"""Create tables if they don't exist. Idempotent."""
with self._lock:
cur = self._conn.cursor()
cur.executescript("""
CREATE TABLE IF NOT EXISTS builds (
build_id TEXT PRIMARY KEY,
cmd TEXT,
dir TEXT,
project TEXT,
arch TEXT,
target TEXT,
toolchain TEXT,
started_at REAL,
ended_at REAL,
status TEXT,
actions TEXT, -- JSON array
error TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
source TEXT,
created_at REAL,
event_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT, -- nullable (live events have no session)
ts REAL,
type TEXT,
node TEXT,
action TEXT,
state TEXT,
reason TEXT,
target TEXT,
payload TEXT -- full JSON event
);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
CREATE INDEX IF NOT EXISTS idx_events_node ON events(node);
CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
CREATE TABLE IF NOT EXISTS node_states (
name TEXT PRIMARY KEY,
state TEXT,
cpu_load REAL,
memory_load REAL,
temp REAL,
instability REAL,
active_jobs INTEGER,
max_jobs INTEGER,
last_seen REAL,
labels TEXT,
updated_at REAL
);
""")
self._conn.commit()
# ----------------------------------------------------------------
# BUILDS
# ----------------------------------------------------------------
def save_build(self, build: Dict[str, Any]) -> bool:
try:
with self._lock:
self._conn.execute("""
INSERT OR REPLACE INTO builds
(build_id, cmd, dir, project, arch, target, toolchain,
started_at, ended_at, status, actions, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
build.get("build_id"),
build.get("cmd"),
build.get("dir"),
build.get("project"),
build.get("arch"),
build.get("target"),
build.get("toolchain"),
build.get("started_at"),
build.get("ended_at"),
build.get("status"),
json.dumps(build.get("actions", [])),
build.get("error"),
))
self._conn.commit()
return True
except Exception:
return False
def get_build(self, build_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM builds WHERE build_id = ?", (build_id,)
).fetchone()
if not row:
return None
return _row_to_build(row)
def list_builds(self, limit: int = 100) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM builds ORDER BY started_at DESC LIMIT ?", (limit,)
).fetchall()
return [_row_to_build(r) for r in rows]
def delete_build(self, build_id: str) -> bool:
with self._lock:
cur = self._conn.execute(
"DELETE FROM builds WHERE build_id = ?", (build_id,)
)
self._conn.commit()
return cur.rowcount > 0
# ----------------------------------------------------------------
# SESSIONS
# ----------------------------------------------------------------
def create_session(self, session_id: str, source: str = "live",
created_at: Optional[float] = None) -> bool:
try:
with self._lock:
self._conn.execute("""
INSERT OR REPLACE INTO sessions
(session_id, source, created_at, event_count)
VALUES (?, ?, ?, 0)
""", (session_id, source, created_at or time.time()))
self._conn.commit()
return True
except Exception:
return False
def list_sessions(self) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM sessions ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
return dict(row) if row else None
def delete_session(self, session_id: str) -> bool:
with self._lock:
cur = self._conn.execute(
"DELETE FROM sessions WHERE session_id = ?", (session_id,)
)
self._conn.commit()
return cur.rowcount > 0
# ----------------------------------------------------------------
# EVENTS
# ----------------------------------------------------------------
def append_event(self, event: Dict[str, Any], session_id: Optional[str] = None) -> int:
"""Insert an event. Returns the new row id."""
ts = event.get("timestamp") or time.time()
etype = event.get("type", "event")
payload = json.dumps(event, default=str)
try:
with self._lock:
cur = self._conn.execute("""
INSERT INTO events
(session_id, ts, type, node, action, state, reason, target, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
session_id,
ts,
etype,
event.get("node") or (event.get("data", {}) or {}).get("node"),
event.get("action") or (event.get("data", {}) or {}).get("action"),
event.get("state") or (event.get("data", {}) or {}).get("state"),
event.get("reason") or (event.get("data", {}) or {}).get("reason"),
event.get("target") or (event.get("data", {}) or {}).get("target"),
payload,
))
self._conn.commit()
if session_id:
self._conn.execute(
"UPDATE sessions SET event_count = event_count + 1 WHERE session_id = ?",
(session_id,),
)
self._conn.commit()
return cur.lastrowid or 0
except Exception:
return 0
def get_events(self, session_id: Optional[str] = None, limit: int = 1000,
offset: int = 0) -> List[Dict[str, Any]]:
with self._lock:
if session_id:
rows = self._conn.execute(
"SELECT * FROM events WHERE session_id = ? ORDER BY ts ASC LIMIT ? OFFSET ?",
(session_id, limit, offset),
).fetchall()
else:
rows = self._conn.execute(
"SELECT * FROM events ORDER BY ts ASC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [_row_to_event(r) for r in rows]
def get_node_events(self, node: str, limit: int = 100) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE node = ? ORDER BY ts ASC LIMIT ?",
(node, limit),
).fetchall()
return [_row_to_event(r) for r in rows]
def get_action_events(self, action: str, limit: int = 100) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE action = ? ORDER BY ts ASC LIMIT ?",
(action, limit),
).fetchall()
return [_row_to_event(r) for r in rows]
def find_failures(self, limit: int = 100) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE state = 'failed' OR type = 'failure' ORDER BY ts DESC LIMIT ?",
(limit,),
).fetchall()
return [_row_to_event(r) for r in rows]
def event_count(self, session_id: Optional[str] = None) -> int:
with self._lock:
if session_id:
row = self._conn.execute(
"SELECT COUNT(*) FROM events WHERE session_id = ?", (session_id,)
).fetchone()
else:
row = self._conn.execute("SELECT COUNT(*) FROM events").fetchone()
return row[0] if row else 0
# ----------------------------------------------------------------
# NODE STATES (snapshot — not journal)
# ----------------------------------------------------------------
def save_node_state(self, name: str, state: Dict[str, Any]) -> bool:
try:
with self._lock:
self._conn.execute("""
INSERT OR REPLACE INTO node_states
(name, state, cpu_load, memory_load, temp, instability,
active_jobs, max_jobs, last_seen, labels, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
name,
state.get("state", "online"),
float(state.get("cpu_load", 0)),
float(state.get("memory_load", 0)),
float(state.get("temp", 0)),
float(state.get("instability", 0)),
int(state.get("active_jobs", 0)),
int(state.get("max_jobs", 0)),
float(state.get("last_seen", 0)),
json.dumps(state.get("labels", {})),
time.time(),
))
self._conn.commit()
return True
except Exception:
return False
def get_node_state(self, name: str) -> Optional[Dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM node_states WHERE name = ?", (name,)
).fetchone()
return _row_to_node_state(row) if row else None
def list_node_states(self) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM node_states ORDER BY name"
).fetchall()
return [_row_to_node_state(r) for r in rows]
# ----------------------------------------------------------------
# MAINTENANCE
# ----------------------------------------------------------------
def vacuum(self):
with self._lock:
self._conn.execute("VACUUM")
self._conn.commit()
def close(self):
with self._lock:
self._conn.close()
# ----------------------------------------------------------------
# Row converters
# ----------------------------------------------------------------
def _row_to_build(row: sqlite3.Row) -> Dict[str, Any]:
d = dict(row)
try:
d["actions"] = json.loads(d.get("actions") or "[]")
except (json.JSONDecodeError, TypeError):
d["actions"] = []
return d
def _row_to_event(row: sqlite3.Row) -> Dict[str, Any]:
payload = row["payload"]
try:
return json.loads(payload)
except (json.JSONDecodeError, TypeError):
return {"type": row["type"], "timestamp": row["ts"], "node": row["node"],
"action": row["action"], "state": row["state"]}
def _row_to_node_state(row: sqlite3.Row) -> Dict[str, Any]:
d = dict(row)
try:
d["labels"] = json.loads(d.get("labels") or "{}")
except (json.JSONDecodeError, TypeError):
d["labels"] = {}
return d
# ----------------------------------------------------------------
# Process-wide singleton
# ----------------------------------------------------------------
STORAGE = Storage()