devaanand commited on
Commit
f825419
·
1 Parent(s): 5a24540

Persistent storage: DATABASE_URL (Postgres/Supabase) support + deploy steps

Browse files
Files changed (4) hide show
  1. .env.example +6 -2
  2. DEPLOY.md +16 -0
  3. backend/app/db.py +63 -33
  4. 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
- # ---- Phase 2 (not read yet; reserved names) ----
60
- # DATABASE_URL=postgresql://...
 
 
 
 
 
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 via SQLAlchemy.
2
 
3
- SQLite is a deliberate choice for this deployment size (single-node, few
4
- concurrent writers). The models use no SQLite-specific features, so moving
5
- to PostgreSQL when multi-user concurrency arrives is a connection-string
6
- change plus a migration, not a rewrite.
 
 
 
 
 
 
 
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
- @event.listens_for(engine, "connect")
33
- def _sqlite_pragmas(dbapi_conn, _record):
34
- """WAL lets the API read while the job worker writes; busy_timeout
35
- absorbs brief lock contention instead of raising immediately."""
36
- cur = dbapi_conn.cursor()
37
- cur.execute("PRAGMA journal_mode=WAL")
38
- cur.execute("PRAGMA busy_timeout=5000")
39
- cur.execute("PRAGMA synchronous=NORMAL")
40
- cur.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _sqlite_literal(value) -> str:
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 SQLite tables (additive only).
69
-
70
- create_all() creates missing tables but never alters existing ones, so a
71
- dev DB from last week 500s on this week's new column. This closes that gap
72
- for the additive changes we make; anything non-additive (renames, drops,
73
- type changes) waits for Alembic, which replaces this in Phase 2 alongside
74
- Postgres. Columns with scalar defaults get that default; callable defaults
75
- (uuid/now) are added nullable and filled by the ORM on new rows.
 
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 {_sqlite_literal(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)