"""Nightly reconciliation scheduler (TASK-07-03, D-054, REQ-NFR-DASH-02). In-process asyncio scheduler (no APScheduler — RESEARCH-v0.4 §3.4). Loops: compute seconds until next 03:00 CT (America/Winnipeg — Canada pilot) → asyncio.sleep → reconcile all 7-day windows → repeat. Resumes after restart. Failures log + retry next night (R-DASH-04). Reconciliation recomputes all (path, metric, window_start) cells from the mastery_gate_events audit log + re-applies k-anonymity suppression. This guarantees REQ-NFR-DASH-02 (freshness ≤ 24h — the nightly job runs at least once/day) and reconciles any hook failures. """ from __future__ import annotations import asyncio import datetime as _dt import logging import statistics from collections import Counter, defaultdict from typing import Any from db.pg_store import PgStore log = logging.getLogger(__name__) CT = _dt.timezone(_dt.timedelta(hours=-5), "CT") NIGHTLY_HOUR = 3 NIGHTLY_MINUTE = 0 def seconds_until_next_03_ct(now: _dt.datetime | None = None) -> float: """Seconds from `now` until the next 03:00 America/Winnipeg (CT). America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer. We approximate CT as a fixed UTC-5 offset (the pilot is in summer CDT and the scheduler drift of ≤1h over DST boundaries is acceptable for a nightly reconciliation job — the on-session-end hook keeps data fresh). A future hardening would use zoneinfo.ZoneInfo("America/Winnipeg") with proper DST handling. """ now = now or _dt.datetime.now(CT) if now.tzinfo is None: now = now.replace(tzinfo=CT) next_run = now.replace(hour=NIGHTLY_HOUR, minute=NIGHTLY_MINUTE, second=0, microsecond=0) if next_run <= now: next_run += _dt.timedelta(days=1) return (next_run - now).total_seconds() class NightlyScheduler: """In-process asyncio scheduler for nightly cohort reconciliation. Started as an asyncio task in the app lifespan (TASK-10-02). Cancel on shutdown. R-DASH-04: a reconciliation failure logs + retries the next night (the loop continues). """ def __init__(self) -> None: self._task: asyncio.Task | None = None self._stopped = False async def start(self, pg_store: PgStore) -> asyncio.Task: """Begin the nightly loop. Returns the running task.""" self._stopped = False self._task = asyncio.create_task(self._run_loop(pg_store)) return self._task async def stop(self) -> None: """Cancel the running loop (graceful shutdown).""" self._stopped = True if self._task is not None: self._task.cancel() try: await self._task except (asyncio.CancelledError, Exception): pass self._task = None async def _run_loop(self, pg_store: PgStore) -> None: while not self._stopped: try: secs = seconds_until_next_03_ct() log.info("nightly scheduler: next run in %.0fs (03:00 CT)", secs) await asyncio.sleep(secs) if self._stopped: return await self._reconcile(pg_store) except asyncio.CancelledError: return except Exception: log.exception("nightly reconciliation failed — retry next night (R-DASH-04)") # brief sleep to avoid a tight error loop if the clock is broken await asyncio.sleep(60) async def _reconcile(self, pg_store: PgStore) -> None: """Recompute all 7-day windows for all paths from mastery_gate_events. Reads recent gate events (the audit log, REQ-NFR-MAST-02), groups by (path, window_start), recomputes each metric cell, applies k-anon suppression, and upserts. Idempotent — re-running produces the same aggregates (ON CONFLICT upsert). """ events = await _load_recent_events(pg_store) if not events: log.info("nightly reconcile: no recent gate events; nothing to recompute") return # Group by path → window_start → list[events] by_path_window: dict[tuple[str, _dt.date], list[dict[str, Any]]] = defaultdict(list) today = _dt.datetime.now(_dt.timezone.utc).date() window_start = today - _dt.timedelta(days=6) for ev in events: ev_date = _coerce_date(ev.get("recorded_at")) if ev_date is None or ev_date < window_start: continue path = ev.get("path_id") or "unknown" by_path_window[(path, window_start)].append(ev) from server.cohort.aggregator import K_ANON_THRESHOLD, _rolling_window ws, we = _rolling_window() for (path, _), evs in by_path_window.items(): learners = {e.get("learner_ref") for e in evs if e.get("learner_ref")} active_count = len(learners) suppressed = active_count < K_ANON_THRESHOLD # sessions_count await pg_store.upsert_cohort_aggregate( path, "sessions_count", ws, we, None if suppressed else float(len(evs)), active_count, suppressed, ) # active_learners_count await pg_store.upsert_cohort_aggregate( path, "active_learners_count", ws, we, None if suppressed else float(active_count), active_count, suppressed, ) # gate_open_rate gate_opens = sum(1 for e in evs if (e.get("gate_outcome") or "") == "open") rate = gate_opens / len(evs) if evs else 0.0 await pg_store.upsert_cohort_aggregate( path, "gate_open_rate", ws, we, None if suppressed else rate, active_count, suppressed, ) # median_mastery_score + rubric_criterion_means from rubric_scores_jsonb score_rows: list[float] = [] crit_scores: dict[str, list[float]] = defaultdict(list) for e in evs: scores = e.get("rubric_scores") or [] if isinstance(scores, str): import json as _json try: scores = _json.loads(scores) except Exception: scores = [] for r in scores: if isinstance(r, dict): cid = r.get("criterion_id") or r.get("id") or "unknown" s = r.get("score") or r.get("weighted_mean") if s is not None: crit_scores[cid].append(float(s)) score_rows.append(float(s)) if score_rows: med = statistics.median(score_rows) await pg_store.upsert_cohort_aggregate( path, "median_mastery_score", ws, we, None if suppressed else med, active_count, suppressed, ) for cid, vals in crit_scores.items(): mean_v = statistics.mean(vals) if vals else 0.0 await pg_store.upsert_cohort_aggregate( path, f"rubric_criterion_mean:{cid}", ws, we, None if suppressed else mean_v, active_count, suppressed, ) log.info("nightly reconcile: recomputed %d (path, window) cells", len(by_path_window)) async def reconcile_now(self, pg_store: PgStore) -> None: """Public hook for tests / ad-hoc reconciliation (no clock wait).""" await self._reconcile(pg_store) async def _load_recent_events(pg_store: PgStore) -> list[dict[str, Any]]: """Load mastery_gate_events from the last 7 days. Uses the PgStore pool directly (no extra method on PgStore to keep the surface minimal). Returns rows as dicts with decoded rubric_scores. """ async with pg_store.pool.acquire() as conn: rows = await conn.fetch( "SELECT learner_ref, scenario_id, path_id, gate_outcome, " "rubric_scores_jsonb, recorded_at " "FROM mastery_gate_events " "WHERE recorded_at >= now() - interval '7 days' " "ORDER BY recorded_at" ) out: list[dict[str, Any]] = [] for r in rows: d = dict(r) scores = d.get("rubric_scores_jsonb") if hasattr(scores, "resolve"): try: import json as _json d["rubric_scores"] = _json.loads(scores.resolve()) if scores else [] except Exception: d["rubric_scores"] = [] else: d["rubric_scores"] = scores out.append(d) return out def _coerce_date(val: Any) -> _dt.date | None: if val is None: return None if isinstance(val, _dt.datetime): return val.date() if isinstance(val, _dt.date): return val try: return _dt.datetime.fromisoformat(str(val)).date() except Exception: return None __all__ = ["NightlyScheduler", "seconds_until_next_03_ct", "CT"]