Spaces:
Running
Running
| """ | |
| Durable user data: local SQLite stays the working store, Turso is the backup. | |
| Why not just move db.py onto Turso | |
| ----------------------------------- | |
| Turso is ~968 ms per round trip from the Space (measured; the database is in | |
| aws-ap-south-1 and the Space is not). A single page load makes 5-10 database | |
| calls, so a direct port would turn every request into 5-10 seconds. Reads have | |
| to stay local. | |
| Why this is needed at all | |
| -------------------------- | |
| DB_PATH is /tmp/interactions.db on an ephemeral filesystem. Every rebuild | |
| destroys every save, EWMA profile, cluster and onboarding record. Users are | |
| re-onboarded from scratch, and no interaction history can ever accumulate -- | |
| which is also why there is nothing to retrain the reranker on. | |
| Design | |
| ------ | |
| boot restore local SQLite from Turso, but only when local is empty | |
| read untouched -- app/db.py keeps its aiosqlite calls | |
| write untouched -- picked up by the next sync | |
| sync every TURSO_SYNC_INTERVAL seconds, push rows newer than a watermark | |
| Incremental rather than write-through, deliberately: it needs no changes to the | |
| six existing write paths and cannot coupling-break when their SQL changes. The | |
| cost is a bounded loss window equal to the sync interval. | |
| Only user data is replicated. paper_metadata and paper_qdrant_map are caches | |
| rebuildable from Turso and Qdrant, so copying them would waste quota. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import json | |
| import time | |
| import aiosqlite | |
| import httpx | |
| from app import config | |
| SYNC_INTERVAL = int(__import__("os").getenv("TURSO_SYNC_INTERVAL", "60")) | |
| # local table -> (remote table, watermark column, primary key columns) | |
| # | |
| # interactions is append-only with an AUTOINCREMENT id, so an integer watermark | |
| # is exact. The other three are upserted in place, so they use a timestamp. | |
| TABLES: dict[str, dict] = { | |
| "interactions": { | |
| "remote": "user_interactions", | |
| "watermark": "id", | |
| "wm_kind": "int", | |
| "pk": ["id"], | |
| "cols": ["id", "user_id", "paper_id", "event_type", "source", "position", | |
| "query_id", "ranker_version", "candidate_source", "cluster_id", | |
| "propensity", "policy_id", "timestamp"], | |
| "blobs": [], | |
| }, | |
| "user_profiles": { | |
| "remote": "user_profiles", | |
| "watermark": "updated_at", | |
| "wm_kind": "text", | |
| "pk": ["user_id", "profile_type"], | |
| "cols": ["user_id", "profile_type", "vector", "interaction_count", "updated_at"], | |
| "blobs": ["vector"], | |
| }, | |
| "user_clusters": { | |
| "remote": "user_clusters", | |
| "watermark": "computed_at", | |
| "wm_kind": "text", | |
| "pk": ["user_id", "cluster_idx"], | |
| "cols": ["user_id", "cluster_idx", "medoid_paper_id", "medoid_embedding_blob", | |
| "importance", "paper_ids", "computed_at"], | |
| "blobs": ["medoid_embedding_blob"], | |
| }, | |
| "user_onboarding": { | |
| "remote": "user_onboarding", | |
| "watermark": "updated_at", | |
| "wm_kind": "text", | |
| "pk": ["user_id"], | |
| "cols": ["user_id", "selected_categories", "onboarding_completed", | |
| "created_at", "updated_at"], | |
| "blobs": [], | |
| }, | |
| } | |
| REMOTE_DDL = [ | |
| """CREATE TABLE IF NOT EXISTS user_interactions ( | |
| id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, paper_id TEXT NOT NULL, | |
| event_type TEXT NOT NULL, source TEXT, position INTEGER, query_id TEXT, | |
| ranker_version TEXT, candidate_source TEXT, cluster_id INTEGER, | |
| propensity REAL, policy_id TEXT, timestamp TEXT)""", | |
| "CREATE INDEX IF NOT EXISTS idx_ui_user ON user_interactions(user_id)", | |
| """CREATE TABLE IF NOT EXISTS user_profiles ( | |
| user_id TEXT NOT NULL, profile_type TEXT NOT NULL, vector BLOB NOT NULL, | |
| interaction_count INTEGER DEFAULT 0, updated_at TEXT, | |
| PRIMARY KEY (user_id, profile_type))""", | |
| """CREATE TABLE IF NOT EXISTS user_clusters ( | |
| user_id TEXT NOT NULL, cluster_idx INTEGER NOT NULL, | |
| medoid_paper_id TEXT NOT NULL, medoid_embedding_blob BLOB, | |
| importance REAL, paper_ids TEXT, computed_at TEXT, | |
| PRIMARY KEY (user_id, cluster_idx))""", | |
| """CREATE TABLE IF NOT EXISTS user_onboarding ( | |
| user_id TEXT PRIMARY KEY, selected_categories TEXT, | |
| onboarding_completed INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT)""", | |
| ] | |
| _LOCAL_STATE_DDL = """CREATE TABLE IF NOT EXISTS sync_state ( | |
| key TEXT PRIMARY KEY, value TEXT)""" | |
| _task: asyncio.Task | None = None | |
| _last: dict = {"ok": None, "at": None, "pushed": 0, "error": None} | |
| def enabled() -> bool: | |
| return bool(config.TURSO_URL and config.TURSO_DB_TOKEN) | |
| # ── Turso HTTP ─────────────────────────────────────────────────────────────── | |
| def _url() -> str: | |
| u = config.TURSO_URL.rstrip("/") | |
| if u.startswith("libsql://"): | |
| u = "https://" + u[len("libsql://"):] | |
| elif not u.startswith("https://"): | |
| u = "https://" + u.lstrip("http://") | |
| return u | |
| def _cell(v): | |
| """Python value -> libSQL argument.""" | |
| if v is None: | |
| return {"type": "null", "value": None} | |
| if isinstance(v, bool): | |
| return {"type": "integer", "value": str(int(v))} | |
| if isinstance(v, int): | |
| return {"type": "integer", "value": str(v)} | |
| if isinstance(v, float): | |
| return {"type": "float", "value": v} | |
| if isinstance(v, (bytes, bytearray, memoryview)): | |
| # Hrana keys blob payloads as "base64", not "value" — using "value" | |
| # here returns a bare 400 with no explanation. | |
| return {"type": "blob", "base64": base64.b64encode(bytes(v)).decode()} | |
| return {"type": "text", "value": str(v)} | |
| def _b64decode(s: str) -> bytes: | |
| """Decode a Hrana blob payload. | |
| Turso returns base64 with the padding stripped, and occasionally the | |
| URL-safe alphabet, so a plain b64decode raises "Incorrect padding". | |
| """ | |
| s = s or "" | |
| s += "=" * (-len(s) % 4) | |
| try: | |
| return base64.b64decode(s) | |
| except Exception: | |
| return base64.urlsafe_b64decode(s) | |
| def _uncell(c): | |
| t = c.get("type") | |
| if t == "null": | |
| return None | |
| if t == "blob": | |
| return _b64decode(c.get("base64") or c.get("value") or "") | |
| v = c.get("value") | |
| if t == "integer": | |
| return int(v) | |
| if t == "float": | |
| return float(v) | |
| return v | |
| async def _execute(stmts: list[dict], timeout: int = 60) -> list[list[list]]: | |
| """Run statements in one round trip. Returns rows per statement.""" | |
| payload = {"requests": [{"type": "execute", "stmt": s} for s in stmts] | |
| + [{"type": "close"}]} | |
| async with httpx.AsyncClient(timeout=timeout) as client: | |
| r = await client.post( | |
| f"{_url()}/v2/pipeline", json=payload, | |
| headers={"Authorization": f"Bearer {config.TURSO_DB_TOKEN}", | |
| "Content-Type": "application/json"}) | |
| if r.status_code >= 400: | |
| # Surface the body. Turso returns the actual reason there, and | |
| # raise_for_status() alone gives a bare status with no diagnosis. | |
| raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}") | |
| data = r.json() | |
| out = [] | |
| for res in data.get("results", []): | |
| if res.get("type") == "error": | |
| raise RuntimeError(str(res.get("error"))[:250]) | |
| resp = res.get("response", {}) | |
| if resp.get("type") != "execute": | |
| continue | |
| rows = resp.get("result", {}).get("rows", []) | |
| out.append([[_uncell(c) for c in row] for row in rows]) | |
| return out | |
| # ── Watermarks ─────────────────────────────────────────────────────────────── | |
| async def _get_wm(conn, table: str) -> str | None: | |
| cur = await conn.execute( | |
| "SELECT value FROM sync_state WHERE key = ?", (f"wm:{table}",)) | |
| row = await cur.fetchone() | |
| return row[0] if row else None | |
| async def _set_wm(conn, table: str, value) -> None: | |
| await conn.execute( | |
| "INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)", | |
| (f"wm:{table}", str(value))) | |
| # ── Public API ─────────────────────────────────────────────────────────────── | |
| async def ensure_remote_schema() -> None: | |
| await _execute([{"sql": d} for d in REMOTE_DDL]) | |
| async def restore() -> dict: | |
| """Pull user data from Turso into the local DB. | |
| Only fills tables that are locally EMPTY. A container that already has data | |
| is never clobbered, so this is safe to run on every boot. | |
| """ | |
| restored = {} | |
| async with aiosqlite.connect(config.DB_PATH) as conn: | |
| await conn.execute(_LOCAL_STATE_DDL) | |
| await conn.commit() | |
| for local, spec in TABLES.items(): | |
| cur = await conn.execute(f"SELECT COUNT(*) FROM {local}") | |
| n = (await cur.fetchone())[0] | |
| if n: | |
| continue # local already has data; leave it alone | |
| cols = ", ".join(spec["cols"]) | |
| try: | |
| rows = (await _execute( | |
| [{"sql": f"SELECT {cols} FROM {spec['remote']}"}]))[0] | |
| except Exception as e: | |
| print(f"[turso_sync] restore {local} failed: {str(e)[:120]}") | |
| continue | |
| if not rows: | |
| continue | |
| ph = ", ".join("?" * len(spec["cols"])) | |
| await conn.executemany( | |
| f"INSERT OR REPLACE INTO {local} ({cols}) VALUES ({ph})", rows) | |
| # Restored rows came FROM Turso, so advance the watermark past them | |
| # or the next sync would push them straight back. | |
| wm = spec["watermark"] | |
| cur = await conn.execute(f"SELECT MAX({wm}) FROM {local}") | |
| hi = (await cur.fetchone())[0] | |
| if hi is not None: | |
| await _set_wm(conn, local, hi) | |
| restored[local] = len(rows) | |
| await conn.commit() | |
| if restored: | |
| print(f"[turso_sync] restored from Turso: {restored}") | |
| return restored | |
| async def sync_once() -> dict: | |
| """Push rows newer than the watermark. Returns {table: count}.""" | |
| pushed = {} | |
| async with aiosqlite.connect(config.DB_PATH) as conn: | |
| await conn.execute(_LOCAL_STATE_DDL) | |
| await conn.commit() | |
| for local, spec in TABLES.items(): | |
| wm_col, kind = spec["watermark"], spec["wm_kind"] | |
| wm = await _get_wm(conn, local) | |
| if wm is None: | |
| where, args = "", () | |
| else: | |
| where = f"WHERE {wm_col} > ?" | |
| args = (int(wm),) if kind == "int" else (wm,) | |
| cols = ", ".join(spec["cols"]) | |
| cur = await conn.execute( | |
| f"SELECT {cols} FROM {local} {where} ORDER BY {wm_col} LIMIT 500", args) | |
| rows = await cur.fetchall() | |
| if not rows: | |
| continue | |
| ph = ", ".join("?" * len(spec["cols"])) | |
| stmts = [{ | |
| "sql": f"INSERT OR REPLACE INTO {spec['remote']} ({cols}) VALUES ({ph})", | |
| "args": [_cell(v) for v in row], | |
| } for row in rows] | |
| await _execute(stmts, timeout=120) | |
| wm_idx = spec["cols"].index(wm_col) | |
| await _set_wm(conn, local, rows[-1][wm_idx]) | |
| pushed[local] = len(rows) | |
| await conn.commit() | |
| return pushed | |
| async def _loop() -> None: | |
| while True: | |
| try: | |
| await asyncio.sleep(SYNC_INTERVAL) | |
| pushed = await sync_once() | |
| _last.update(ok=True, at=time.time(), | |
| pushed=sum(pushed.values()), error=None) | |
| if pushed: | |
| print(f"[turso_sync] pushed {pushed}") | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception as e: | |
| _last.update(ok=False, at=time.time(), error=str(e)[:200]) | |
| print(f"[turso_sync] sync failed (will retry): {str(e)[:150]}") | |
| async def start() -> None: | |
| """Called from the app lifespan. Never raises — sync is best-effort.""" | |
| global _task | |
| if not enabled(): | |
| print("[turso_sync] TURSO not configured — user data stays ephemeral") | |
| return | |
| try: | |
| await ensure_remote_schema() | |
| await restore() | |
| except Exception as e: | |
| print(f"[turso_sync] startup failed ({str(e)[:150]}) — continuing without sync") | |
| return | |
| _task = asyncio.create_task(_loop()) | |
| print(f"[turso_sync] active, every {SYNC_INTERVAL}s") | |
| async def stop() -> None: | |
| """Final flush on shutdown so the last interval is not lost.""" | |
| global _task | |
| if _task: | |
| _task.cancel() | |
| try: | |
| await _task | |
| except (asyncio.CancelledError, Exception): | |
| pass | |
| _task = None | |
| if enabled(): | |
| try: | |
| await sync_once() | |
| except Exception as e: | |
| print(f"[turso_sync] final flush failed: {str(e)[:120]}") | |
| def status() -> dict: | |
| return { | |
| "enabled": enabled(), | |
| "running": _task is not None and not _task.done(), | |
| "interval_s": SYNC_INTERVAL, | |
| "last_ok": _last["ok"], | |
| "last_at": _last["at"], | |
| "last_pushed": _last["pushed"], | |
| "last_error": _last["error"], | |
| } | |