"""Observation store: SQLite database for persisting observation history. This module handles: - Store initialization with WAL mode - Schema versioning with PRAGMA user_version - Observation records, derived activity, publication state, and metadata """ import sqlite3 from pathlib import Path from typing import Optional # Schema version - increment on each migration SCHEMA_VERSION = 5 # Packaged default placement (spec §8.3). The config may override it, but a # fresh install that sets only the device selector must collect cleanly. DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db") def get_store_path(config: dict) -> Path: """Get the store path from config. Falls back to the packaged default when the config does not pin one, so a fresh install whose config holds only the device selector works instead of crashing with KeyError 'store_path' (issue #53). """ return Path(config.get("store_path", DEFAULT_STORE_PATH)) def init_store(store_path: Path) -> sqlite3.Connection: """Initialize the observation store if not present. Creates the observation schema, including private pending-publication storage. Returns a connection to the store. """ conn = sqlite3.connect(str(store_path)) # Enable WAL mode for concurrent reads during writes conn.execute("PRAGMA journal_mode=WAL") # Group members (fenris group) read the live store read-only, but SQLite # in WAL mode needs write access to the db and its -wal/-shm sidecars even # for readers. Best effort: root-created stores stay group-accessible # without relying on the creating process's umask (issue #54). import os as _os for sidecar in (store_path, store_path.with_name(store_path.name + "-wal"), store_path.with_name(store_path.name + "-shm")): try: mode = _os.stat(sidecar).st_mode & 0o777 _os.chmod(sidecar, mode | 0o060) except OSError: pass # Check if this is a new database cursor = conn.execute("PRAGMA user_version") current_version = cursor.fetchone()[0] if current_version == 0: # New database - create schema _create_schema(conn) conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") conn.commit() elif current_version > SCHEMA_VERSION: # Unknown newer version - refuse conn.close() raise ValueError( f"Observation store written by a newer Fenris (version {current_version}) " f"— upgrade Fenris" ) elif current_version < SCHEMA_VERSION: # Older version - apply migrations _apply_migrations(conn, current_version) conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") conn.commit() return conn def _create_schema(conn: sqlite3.Connection): """Create the initial schema with all six entities.""" # Samples: raw collection runs (14-day retention) conn.execute(""" CREATE TABLE IF NOT EXISTS samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, -- ISO 8601 UTC timestamp device TEXT NOT NULL, -- Normalized controller-identity fields captured at acquisition subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, capacity_bytes INTEGER, percentage_used INTEGER, available_spare INTEGER, media_errors INTEGER, power_on_hours INTEGER, power_cycles INTEGER, unsafe_shutdowns INTEGER, temperature_c INTEGER, data_units_written INTEGER, data_units_read INTEGER, bytes_written INTEGER, bytes_read INTEGER, critical_warning INTEGER, segment_id INTEGER, local_tz TEXT ) """) # Hour observations: UTC-hour usage-habit split conn.execute(""" CREATE TABLE IF NOT EXISTS hour_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, hour TEXT NOT NULL UNIQUE, -- ISO 8601 UTC hour (e.g., "2026-09-01T12:00:00Z") active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0, powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0, bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0, temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER, sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0 ) """) # Day aggregates: derived from hour observations conn.execute(""" CREATE TABLE IF NOT EXISTS day_aggregates ( id INTEGER PRIMARY KEY AUTOINCREMENT, day TEXT NOT NULL UNIQUE, -- ISO 8601 UTC day (e.g., "2026-09-01") active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0, powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0, bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0, sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0, unattributed_bytes_written INTEGER DEFAULT 0, unattributed_bytes_read INTEGER DEFAULT 0 ) """) # Monitoring periods: tracking when monitoring was enabled/disabled conn.execute(""" CREATE TABLE IF NOT EXISTS monitoring_periods ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, -- ISO 8601 UTC timestamp ended_at TEXT, -- NULL if currently active end_cause TEXT CHECK(end_cause IN ('user_disabled', 'migrated', 'unknown_gap')) ) """) # Controller segments: identity key plus metadata snapshot conn.execute(""" CREATE TABLE IF NOT EXISTS controller_segments ( id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, -- ISO 8601 UTC timestamp identity_key TEXT, -- Normalized identity key (NULL if degraded) identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT ) """) # Endurance baseline: one active row, replaced on edit conn.execute(""" CREATE TABLE IF NOT EXISTS endurance_baseline ( id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, -- 'user' or 'machine_match' verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) # Local-day activity summaries derived from UTC hour observations. # Each row retains its recorded timezone and UTC boundaries so that # historical summaries survive a system-timezone change (ADR 0010). conn.execute(""" CREATE TABLE IF NOT EXISTS local_days ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata" tz_offset TEXT NOT NULL, -- e.g. "+05:30" utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end bytes_written INTEGER DEFAULT 0, bytes_read INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0, sample_count INTEGER DEFAULT 0, complete BOOLEAN DEFAULT 0, activity_seconds INTEGER NOT NULL DEFAULT 0, activity_intervals INTEGER NOT NULL DEFAULT 0, activity_incomplete BOOLEAN NOT NULL DEFAULT 0, activity_precision TEXT NOT NULL DEFAULT 'measured', last_sample_id INTEGER, UNIQUE(local_date, tz_name) ) """) _create_local_day_shared_evidence(conn) _create_local_day_segment_totals(conn) # Metadata table for store state (e.g., legacy import marker) conn.execute(""" CREATE TABLE IF NOT EXISTS store_metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """) _create_pending_publications(conn) def _create_pending_publications(conn: sqlite3.Connection) -> None: """Create private staging for valid observations awaiting derivation.""" conn.execute(""" CREATE TABLE IF NOT EXISTS pending_publications ( id INTEGER PRIMARY KEY AUTOINCREMENT, sample_ts TEXT NOT NULL, payload TEXT NOT NULL ) """) def _create_local_day_shared_evidence(conn: sqlite3.Connection) -> None: """Create once-only local activity evidence that cannot be day-allocated.""" conn.execute(""" CREATE TABLE IF NOT EXISTS local_day_unallocated_evidence ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_sample_id INTEGER NOT NULL, end_sample_id INTEGER NOT NULL, start_local_date TEXT NOT NULL, end_local_date TEXT NOT NULL, start_tz_name TEXT, end_tz_name TEXT, started_at TEXT NOT NULL, ended_at TEXT NOT NULL, bytes_written INTEGER NOT NULL DEFAULT 0, bytes_read INTEGER NOT NULL DEFAULT 0, reason TEXT NOT NULL, segment_id INTEGER, UNIQUE(start_sample_id, end_sample_id) ) """) conn.execute( "CREATE INDEX IF NOT EXISTS local_day_evidence_start " "ON local_day_unallocated_evidence(start_local_date, start_tz_name)" ) conn.execute( "CREATE INDEX IF NOT EXISTS local_day_evidence_end " "ON local_day_unallocated_evidence(end_local_date, end_tz_name)" ) def _create_local_day_segment_totals(conn: sqlite3.Connection) -> None: """Retain the controller-segment provenance behind known day totals.""" conn.execute(""" CREATE TABLE IF NOT EXISTS local_day_segment_totals ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_day_id INTEGER NOT NULL, segment_id INTEGER NOT NULL, bytes_written INTEGER NOT NULL DEFAULT 0, bytes_read INTEGER NOT NULL DEFAULT 0, activity_seconds INTEGER NOT NULL DEFAULT 0, activity_intervals INTEGER NOT NULL DEFAULT 0, UNIQUE(local_day_id, segment_id) ) """) def _apply_migrations(conn: sqlite3.Connection, current_version: int): """Apply forward-only migrations from current_version to SCHEMA_VERSION. Each migration step is a transactional block. Add new steps as sequential elif branches when SCHEMA_VERSION increases. Spec: §3.6, §10.2 """ # Migration 1→2: add segment_id provenance to samples, # unattributed byte tracking to day_aggregates (issue #73) if current_version < 2: # Defensive: only ALTER if table exists (handles minimal v1 stores) tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} if "samples" in tables: # Check if column already exists (idempotent) cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()} if "segment_id" not in cols: conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER") if "day_aggregates" in tables: cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()} if "unattributed_bytes_written" not in cols: conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0") if "unattributed_bytes_read" not in cols: conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0") current_version = 2 # Migration 2→3: add local_days table for local-day activity totals # (issue #90, ADR 0010). Pure addition — no existing rows touched. if current_version < 3: tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} if "local_days" not in tables: conn.execute(""" CREATE TABLE IF NOT EXISTS local_days ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_date TEXT NOT NULL, tz_name TEXT NOT NULL, tz_offset TEXT NOT NULL, utc_start TEXT NOT NULL, utc_end TEXT NOT NULL, bytes_written INTEGER DEFAULT 0, bytes_read INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0, sample_count INTEGER DEFAULT 0, complete BOOLEAN DEFAULT 0, UNIQUE(local_date, tz_name) ) """) current_version = 3 # Migration 3→4: retain acquired observations until derived evidence can # be published atomically (issue #97, ADR 0011). if current_version < 4: _create_pending_publications(conn) current_version = 4 # Migration 4→5: retain precise measured local-day activity and once-only # unallocated intervals. Existing local-day totals came from UTC-hour # aggregates, so preserve their rows but mark their local precision legacy. if current_version < 5: tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} if "samples" in tables: sample_cols = {row[1] for row in conn.execute( "PRAGMA table_info(samples)" ).fetchall()} if "local_tz" not in sample_cols: conn.execute("ALTER TABLE samples ADD COLUMN local_tz TEXT") if "local_days" in tables: local_cols = {row[1] for row in conn.execute( "PRAGMA table_info(local_days)" ).fetchall()} for column, declaration in ( ("activity_seconds", "INTEGER NOT NULL DEFAULT 0"), ("activity_intervals", "INTEGER NOT NULL DEFAULT 0"), ("activity_incomplete", "BOOLEAN NOT NULL DEFAULT 0"), ("activity_precision", "TEXT NOT NULL DEFAULT 'legacy'"), ("last_sample_id", "INTEGER"), ): if column not in local_cols: conn.execute( "ALTER TABLE local_days ADD COLUMN %s %s" % (column, declaration) ) _create_local_day_shared_evidence(conn) _create_local_day_segment_totals(conn) current_version = 5 def migrate_to_latest(store_path: Path) -> int: """Apply forward-only migrations to bring the store to SCHEMA_VERSION. Called by the upgrade target (§10.2). Returns the number of migration steps applied. Raises ValueError on newer-schema store (§3.6, §9.5). Spec: §3.6, §10.2, §10.3 """ conn = sqlite3.connect(str(store_path)) conn.execute("PRAGMA journal_mode=WAL") cursor = conn.execute("PRAGMA user_version") current_version = cursor.fetchone()[0] if current_version > SCHEMA_VERSION: conn.close() raise ValueError( f"Observation store written by a newer Fenris (version {current_version}) " f"— upgrade Fenris" ) if current_version == SCHEMA_VERSION: conn.close() return 0 # Already up to date # Version 0 means no schema — create fresh (issue #73) if current_version == 0: _create_schema(conn) conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") conn.commit() conn.close() return SCHEMA_VERSION steps = SCHEMA_VERSION - current_version _apply_migrations(conn, current_version) conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") conn.commit() conn.close() return steps def is_store_faulty(store_path: Path) -> bool: """Check if the store is present but cannot be read or trusted.""" if not store_path.exists(): return False try: conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True) conn.execute("PRAGMA user_version") conn.close() return False except sqlite3.Error: return True