420 lines
16 KiB
Python
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()
|