"""Cohort learner cache persistence (TASK-12-01, P1+ #7 from v0.4 REVIEW). The v0.4 P1+ #7 finding: the `_agg_cache` on PgStore (aggregator.py:296-304) tracks running counters + distinct learner sets in-memory. On restart, the cache is lost — the next hook starts fresh, `active_learners_count` may reset to 1 (under-counting until nightly reconcile). This directly corrupts v0.5's `assist_active_learners_count` after a server restart. Mitigation (TASK-12-01): persist the distinct-learner set to a small SQLite table (`cohort_learner_cache`) keyed by (path, window_start, learner_ref). The hook reads the cache from SQLite on startup + updates it on each session. The nightly job reconciles from `mastery_gate_events` (the source of truth) + clears the cache. This is a low-effort, high-value fix (directly corrupts v0.5 assist metrics after a restart). The cache is a diagnostic/intermediate state — the nightly reconciliation from mastery_gate_events remains the source of truth. Schema (additive — a new SQLite table, no change to the main praxis.db schema in db/migrations/): CREATE TABLE IF NOT EXISTS cohort_learner_cache ( path TEXT NOT NULL, window_start TEXT NOT NULL, -- ISO date learner_ref TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (path, window_start, learner_ref) ); The table is keyed by (path, window_start, learner_ref) — each distinct learner per (path, window) is one row. The distinct count = COUNT(*) per (path, window_start). The cache survives restarts (SQLite is durable). """ from __future__ import annotations import datetime as _dt import logging import os from pathlib import Path from typing import Any import aiosqlite log = logging.getLogger(__name__) # The cache SQLite file lives next to the main praxis.db (D-007 — learner-local # SQLite). A separate file avoids touching the main schema/migrations. _DEFAULT_CACHE_DB_PATH = os.environ.get( "PRAXIS_COHORT_CACHE_PATH", str(Path(os.environ.get("PRAXIS_DB_PATH", "praxis.db")).parent / "cohort_learner_cache.db"), ) _CREATE_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS cohort_learner_cache ( path TEXT NOT NULL, window_start TEXT NOT NULL, learner_ref TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (path, window_start, learner_ref) ); CREATE INDEX IF NOT EXISTS idx_cache_path_window ON cohort_learner_cache (path, window_start); """ def _cache_db_path(store: Any = None) -> str | None: """Resolve the cache DB path. Returns None if the path is not a real string (e.g., a MagicMock in tests) — the caller checks for None + skips the I/O. A MagicMock auto-creates attributes, so `getattr(store, 'cohort_cache_db_path')` returns a MagicMock (not None) for a mocked store that didn't explicitly set the attribute. We detect this by checking isinstance(str) + the repr, and return None to skip the I/O (the in-memory cache is the source of truth for mocked tests). """ candidate = None if store is not None: # Use object.__getattribute__ to avoid MagicMock's auto-attribute # creation — only return the attribute if it was explicitly set. try: candidate = object.__getattribute__(store, "cohort_cache_db_path") except AttributeError: candidate = None if not isinstance(candidate, str) or not candidate: # Fall back to the default path ONLY for real stores (not mocks). A # real PgStore doesn't have `cohort_cache_db_path` set by default, so # we use the default. A MagicMock also doesn't have it set explicitly, # but we detect mocks via the type check above (candidate is a MagicMock # → not a str → candidate is None → we skip). if candidate is None and not _is_mock(store): candidate = _DEFAULT_CACHE_DB_PATH else: return None # mocked store or invalid path — skip I/O if " bool: """Detect unittest.mock.Mock/MagicMock (so we skip cache I/O in tests).""" if store is None: return False return "Mock" in type(store).__name__ or "mock" in type(store).__module__ async def _init_cache_db(db_path: str | None = None) -> None: """Create the cache table if it doesn't exist (idempotent).""" p = db_path or _cache_db_path() if p is None: return # mocked store — skip I/O async with aiosqlite.connect(p) as db: await db.executescript(_CREATE_TABLE_SQL) await db.commit() async def _load_learner_cache(store: Any) -> dict: """Load the distinct-learner sets from SQLite on startup (TASK-12-01). Returns a dict shaped like the in-memory cache's `__learners__` entries: { (path, "__learners__", window_start): set(learner_ref, ...) } The store parameter is accepted for interface symmetry with the plan's signature, but the cache lives in a dedicated SQLite file (not the PraxisStore's praxis.db) so the cache is decoupled from the learner store. The `store` may carry a `cohort_cache_db_path` attribute to override the default path (used by tests). If the path is not a real string (e.g., a MagicMock in tests), returns {} (no-op — the in-memory cache starts fresh). """ db_path = _cache_db_path(store) if db_path is None: return {} # mocked store — skip I/O, start fresh try: await _init_cache_db(db_path) except Exception: log.exception("cohort_learner_cache: failed to init %s", db_path) return {} cache: dict[tuple[str, str, _dt.date], set[str]] = {} try: async with aiosqlite.connect(db_path) as db: cur = await db.execute( "SELECT path, window_start, learner_ref FROM cohort_learner_cache" ) async for row in cur: path, ws_iso, learner_ref = row ws = _dt.date.fromisoformat(ws_iso) key = (path, "__learners__", ws) cache.setdefault(key, set()).add(learner_ref) except Exception: log.exception("cohort_learner_cache: failed to load from %s", db_path) return {} log.info("cohort_learner_cache: loaded %d (path, window) learner sets from %s", len(cache), db_path) return cache async def _save_learner_cache(store: Any, cache: dict) -> None: """Save the distinct-learner sets to SQLite (TASK-12-01). Called periodically (every 5 minutes or on shift-end). Upserts each (path, window_start, learner_ref) row idempotently (INSERT OR IGNORE — the distinct set is a set, so re-inserting an existing row is a no-op). If the store's cache path is not a real string (e.g., a MagicMock in tests), this is a no-op (the in-memory cache is the source of truth for the test). """ db_path = _cache_db_path(store) if db_path is None: return # mocked store — skip I/O try: await _init_cache_db(db_path) except Exception: log.exception("cohort_learner_cache: failed to init %s", db_path) return now_iso = _dt.datetime.now(_dt.timezone.utc).isoformat() rows: list[tuple[str, str, str, str]] = [] for key, learners in cache.items(): if not isinstance(learners, set): continue # key = (path, "__learners__", window_start) path, _metric, ws = key ws_iso = ws.isoformat() if isinstance(ws, _dt.date) else str(ws) for learner_ref in learners: rows.append((path, ws_iso, learner_ref, now_iso)) if not rows: return try: async with aiosqlite.connect(db_path) as db: await db.executemany( "INSERT OR IGNORE INTO cohort_learner_cache " "(path, window_start, learner_ref, updated_at) VALUES (?, ?, ?, ?)", rows, ) await db.commit() except Exception: log.exception("cohort_learner_cache: failed to save %d rows to %s", len(rows), db_path) return log.info("cohort_learner_cache: saved %d learner rows to %s", len(rows), db_path) async def _clear_learner_cache(store: Any, path: str | None = None, window_start: _dt.date | None = None) -> None: """Clear the cache (called by the nightly job after reconciliation). If path + window_start are given, clears only that (path, window). If neither is given, clears the entire cache (full nightly reconciliation). """ db_path = _cache_db_path(store) if db_path is None: return # mocked store — skip I/O try: async with aiosqlite.connect(db_path) as db: if path is not None and window_start is not None: await db.execute( "DELETE FROM cohort_learner_cache " "WHERE path = ? AND window_start = ?", (path, window_start.isoformat()), ) else: await db.execute("DELETE FROM cohort_learner_cache") await db.commit() except Exception: log.exception("cohort_learner_cache: failed to clear %s", db_path) async def _count_distinct_learners(store: Any, path: str, window_start: _dt.date) -> int: """Count distinct learners for (path, window) from the cache (TASK-12-01). This is the persisted count — survives restarts. Used by the aggregator to initialize the in-memory cache on startup (so active_learners_count is not reset to 1 after a restart). """ db_path = _cache_db_path(store) if db_path is None: return 0 # mocked store — no persisted cache try: await _init_cache_db(db_path) async with aiosqlite.connect(db_path) as db: cur = await db.execute( "SELECT COUNT(DISTINCT learner_ref) FROM cohort_learner_cache " "WHERE path = ? AND window_start = ?", (path, window_start.isoformat()), ) row = await cur.fetchone() return int(row[0]) if row else 0 except Exception: log.exception("cohort_learner_cache: failed to count for path=%s window=%s", path, window_start) return 0 __all__ = [ "_load_learner_cache", "_save_learner_cache", "_clear_learner_cache", "_count_distinct_learners", "_init_cache_db", ]