Files
praxis/server/cohort/learner_cache.py
T
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 22:35:56 +00:00

259 lines
10 KiB
Python

"""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 "<MagicMock" in candidate:
return None # safety: a MagicMock repr slipped through
return candidate
def _is_mock(store: Any) -> 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",
]