ResearchIT / app /turso_sync.py
siddhm11
deploy: search latency, metadata sidecar, durable user data
b861f87
Raw
History Blame Contribute Delete
13.5 kB
"""
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"],
}