Spaces:
Sleeping
Sleeping
Persistent storage: DATABASE_URL (Postgres/Supabase) support + deploy steps
Browse files- .env.example +6 -2
- DEPLOY.md +16 -0
- backend/app/db.py +63 -33
- backend/requirements.txt +1 -0
.env.example
CHANGED
|
@@ -56,5 +56,9 @@
|
|
| 56 |
# password resets, failed-run requeue, verification queue, usage stats).
|
| 57 |
# ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
|
| 58 |
|
| 59 |
-
# ----
|
| 60 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# password resets, failed-run requeue, verification queue, usage stats).
|
| 57 |
# ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
|
| 58 |
|
| 59 |
+
# ---- Database (persistent storage) ----
|
| 60 |
+
# Unset = SQLite under CCR_DATA_DIR (local dev; wiped on ephemeral hosts like HF Spaces).
|
| 61 |
+
# Set to a Postgres URL for durable storage. Supabase (free tier) recommended -
|
| 62 |
+
# use the "Session pooler" connection string from Project Settings > Database,
|
| 63 |
+
# and put your DB password in it. Accounts and all data then survive restarts.
|
| 64 |
+
# DATABASE_URL=postgresql://postgres.PROJECTREF:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres
|
DEPLOY.md
CHANGED
|
@@ -54,3 +54,19 @@ the password when prompted (or a credential helper).
|
|
| 54 |
on next sign-in automatically; password accounts must re-register. Fine for
|
| 55 |
feedback; a persistent volume or Postgres arrives with the launch decision.
|
| 56 |
- The Space sleeps after ~48 h idle; first visit wakes it (~1 min).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
on next sign-in automatically; password accounts must re-register. Fine for
|
| 55 |
feedback; a persistent volume or Postgres arrives with the launch decision.
|
| 56 |
- The Space sleeps after ~48 h idle; first visit wakes it (~1 min).
|
| 57 |
+
|
| 58 |
+
## Persistent storage (make accounts/data survive restarts)
|
| 59 |
+
|
| 60 |
+
HF Spaces free disk is ephemeral - SQLite is wiped on every rebuild. Point the
|
| 61 |
+
app at your Supabase Postgres (free, already used for Google auth):
|
| 62 |
+
|
| 63 |
+
1. Supabase dashboard > Project Settings > Database > Connection string >
|
| 64 |
+
"Session pooler". Copy the URI and put your DB password into it.
|
| 65 |
+
2. Add it as a Space secret named `DATABASE_URL`.
|
| 66 |
+
3. Restart the Space. First boot creates the tables in Postgres; data now
|
| 67 |
+
persists across restarts and redeploys.
|
| 68 |
+
|
| 69 |
+
For durable uploaded FILES too (not just the database), also set the
|
| 70 |
+
`CCR_STORAGE=s3` R2 secrets (see .env.example). Without that, the database
|
| 71 |
+
rows survive but a signed-in user's uploaded corpus file can still vanish on
|
| 72 |
+
restart (results CSVs are regenerable by re-running).
|
backend/app/db.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
-
"""Database setup - SQLite
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import os
|
|
@@ -12,8 +19,6 @@ from pathlib import Path
|
|
| 12 |
from sqlalchemy import create_engine, event
|
| 13 |
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
| 14 |
|
| 15 |
-
# CCR_DATA_DIR overrides where the DB, corpora, and results live
|
| 16 |
-
# (used by tests to keep runs isolated; useful for deployments too).
|
| 17 |
DATA_DIR = Path(
|
| 18 |
os.environ.get("CCR_DATA_DIR", Path(__file__).resolve().parent.parent / "data")
|
| 19 |
)
|
|
@@ -21,23 +26,44 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
| 21 |
(DATA_DIR / "corpora").mkdir(exist_ok=True)
|
| 22 |
(DATA_DIR / "results").mkdir(exist_ok=True)
|
| 23 |
|
| 24 |
-
DB_PATH = DATA_DIR / "ccr.db"
|
| 25 |
-
|
| 26 |
-
engine = create_engine(
|
| 27 |
-
f"sqlite:///{DB_PATH}",
|
| 28 |
-
connect_args={"check_same_thread": False}, # FastAPI threadpool access
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
@@ -56,8 +82,10 @@ def get_db():
|
|
| 56 |
db.close()
|
| 57 |
|
| 58 |
|
| 59 |
-
def
|
| 60 |
if isinstance(value, bool):
|
|
|
|
|
|
|
| 61 |
return "1" if value else "0"
|
| 62 |
if isinstance(value, (int, float)):
|
| 63 |
return str(value)
|
|
@@ -65,20 +93,22 @@ def _sqlite_literal(value) -> str:
|
|
| 65 |
|
| 66 |
|
| 67 |
def auto_migrate_sqlite(target_engine, metadata) -> list[str]:
|
| 68 |
-
"""Add ORM columns missing from existing
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
type changes) waits for Alembic
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 76 |
"""
|
| 77 |
import logging
|
| 78 |
|
| 79 |
from sqlalchemy import inspect, text
|
| 80 |
|
| 81 |
added: list[str] = []
|
|
|
|
| 82 |
inspector = inspect(target_engine)
|
| 83 |
with target_engine.begin() as conn:
|
| 84 |
for table in metadata.sorted_tables:
|
|
@@ -92,7 +122,7 @@ def auto_migrate_sqlite(target_engine, metadata) -> list[str]:
|
|
| 92 |
ddl = f'ALTER TABLE {table.name} ADD COLUMN "{column.name}" {col_type}'
|
| 93 |
default = getattr(column.default, "arg", None)
|
| 94 |
if default is not None and not callable(default):
|
| 95 |
-
ddl += f" DEFAULT {
|
| 96 |
conn.execute(text(ddl))
|
| 97 |
added.append(f"{table.name}.{column.name}")
|
| 98 |
if added:
|
|
|
|
| 1 |
+
"""Database setup - SQLite (local dev) or PostgreSQL (deployments).
|
| 2 |
|
| 3 |
+
Backend chosen by DATABASE_URL:
|
| 4 |
+
* unset -> SQLite file under CCR_DATA_DIR (local dev; zero setup),
|
| 5 |
+
* postgres URL -> PostgreSQL (Supabase free tier recommended: persistent,
|
| 6 |
+
backed up, and already the auth provider - one vendor).
|
| 7 |
+
|
| 8 |
+
The models use no backend-specific features, so this is a connection-string
|
| 9 |
+
change, not a rewrite. On the ephemeral-disk hosts (HF Spaces free), SQLite is
|
| 10 |
+
wiped on every restart; Postgres is what makes accounts and data survive.
|
| 11 |
+
|
| 12 |
+
DATA_DIR still holds corpora/results/cache files locally; for durable FILE
|
| 13 |
+
storage on ephemeral hosts, additionally set CCR_STORAGE=s3 (storage.py).
|
| 14 |
"""
|
| 15 |
|
| 16 |
import os
|
|
|
|
| 19 |
from sqlalchemy import create_engine, event
|
| 20 |
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
| 21 |
|
|
|
|
|
|
|
| 22 |
DATA_DIR = Path(
|
| 23 |
os.environ.get("CCR_DATA_DIR", Path(__file__).resolve().parent.parent / "data")
|
| 24 |
)
|
|
|
|
| 26 |
(DATA_DIR / "corpora").mkdir(exist_ok=True)
|
| 27 |
(DATA_DIR / "results").mkdir(exist_ok=True)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
def _normalize_pg_url(url: str) -> str:
|
| 31 |
+
"""Force the psycopg (v3) driver; accept the bare postgres:// URL that
|
| 32 |
+
dashboards (Supabase) hand out."""
|
| 33 |
+
if url.startswith("postgres://"):
|
| 34 |
+
url = "postgresql://" + url[len("postgres://"):]
|
| 35 |
+
if url.startswith("postgresql://"):
|
| 36 |
+
url = "postgresql+psycopg://" + url[len("postgresql://"):]
|
| 37 |
+
return url
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
_DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
|
| 41 |
+
IS_POSTGRES = _DATABASE_URL.startswith(("postgres://", "postgresql://"))
|
| 42 |
+
|
| 43 |
+
if IS_POSTGRES:
|
| 44 |
+
engine = create_engine(
|
| 45 |
+
_normalize_pg_url(_DATABASE_URL),
|
| 46 |
+
pool_pre_ping=True, # survive Supabase idle-connection drops
|
| 47 |
+
pool_recycle=1800,
|
| 48 |
+
pool_size=5,
|
| 49 |
+
max_overflow=5,
|
| 50 |
+
)
|
| 51 |
+
else:
|
| 52 |
+
DB_PATH = DATA_DIR / "ccr.db"
|
| 53 |
+
engine = create_engine(
|
| 54 |
+
f"sqlite:///{DB_PATH}",
|
| 55 |
+
connect_args={"check_same_thread": False}, # FastAPI threadpool access
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
@event.listens_for(engine, "connect")
|
| 59 |
+
def _sqlite_pragmas(dbapi_conn, _record):
|
| 60 |
+
"""WAL lets the API read while the job worker writes; busy_timeout
|
| 61 |
+
absorbs brief lock contention instead of raising immediately."""
|
| 62 |
+
cur = dbapi_conn.cursor()
|
| 63 |
+
cur.execute("PRAGMA journal_mode=WAL")
|
| 64 |
+
cur.execute("PRAGMA busy_timeout=5000")
|
| 65 |
+
cur.execute("PRAGMA synchronous=NORMAL")
|
| 66 |
+
cur.close()
|
| 67 |
|
| 68 |
|
| 69 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
|
|
| 82 |
db.close()
|
| 83 |
|
| 84 |
|
| 85 |
+
def _default_literal(value, dialect_name: str) -> str:
|
| 86 |
if isinstance(value, bool):
|
| 87 |
+
if dialect_name == "postgresql":
|
| 88 |
+
return "TRUE" if value else "FALSE"
|
| 89 |
return "1" if value else "0"
|
| 90 |
if isinstance(value, (int, float)):
|
| 91 |
return str(value)
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def auto_migrate_sqlite(target_engine, metadata) -> list[str]:
|
| 96 |
+
"""Add ORM columns missing from existing tables (additive only).
|
| 97 |
+
|
| 98 |
+
Named for history; runs on both backends. create_all() creates missing
|
| 99 |
+
tables but never alters existing ones, so a DB from last week 500s on this
|
| 100 |
+
week's new column. This closes that gap for additive changes; anything
|
| 101 |
+
non-additive (renames, drops, type changes) waits for Alembic. Columns with
|
| 102 |
+
scalar defaults get that default; callable defaults (uuid/now) are added
|
| 103 |
+
nullable and filled by the ORM on new rows. A brand-new Postgres database
|
| 104 |
+
needs none of this (create_all already made every current column).
|
| 105 |
"""
|
| 106 |
import logging
|
| 107 |
|
| 108 |
from sqlalchemy import inspect, text
|
| 109 |
|
| 110 |
added: list[str] = []
|
| 111 |
+
dialect = target_engine.dialect.name
|
| 112 |
inspector = inspect(target_engine)
|
| 113 |
with target_engine.begin() as conn:
|
| 114 |
for table in metadata.sorted_tables:
|
|
|
|
| 122 |
ddl = f'ALTER TABLE {table.name} ADD COLUMN "{column.name}" {col_type}'
|
| 123 |
default = getattr(column.default, "arg", None)
|
| 124 |
if default is not None and not callable(default):
|
| 125 |
+
ddl += f" DEFAULT {_default_literal(default, dialect)}"
|
| 126 |
conn.execute(text(ddl))
|
| 127 |
added.append(f"{table.name}.{column.name}")
|
| 128 |
if added:
|
backend/requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ sentence-transformers>=2.6
|
|
| 10 |
pyyaml>=6.0
|
| 11 |
langdetect>=1.0.9
|
| 12 |
boto3>=1.34 # used only when CCR_STORAGE=s3 (Cloudflare R2 / any S3-compatible store)
|
|
|
|
|
|
| 10 |
pyyaml>=6.0
|
| 11 |
langdetect>=1.0.9
|
| 12 |
boto3>=1.34 # used only when CCR_STORAGE=s3 (Cloudflare R2 / any S3-compatible store)
|
| 13 |
+
psycopg[binary]>=3.1 # used only when DATABASE_URL is a Postgres URL (Supabase)
|