Spaces:
Running
Running
William Mattingly commited on
Commit Β·
163e18f
1
Parent(s): 9cf1ac2
Implement per-session in-memory database caching and update app secret key handling
Browse files- Added support for per-session in-memory SQLite databases, allowing each browser session to have its own isolated database.
- Introduced a unique session ID for database connections using Flask sessions.
- Updated the default port in app.py from 7860 to 5001 for consistency.
- Refactored database initialization to accommodate the new caching mechanism.
- app.py +22 -2
- database.py +91 -54
app.py
CHANGED
|
@@ -12,13 +12,15 @@ import csv
|
|
| 12 |
import io
|
| 13 |
import json
|
| 14 |
import re
|
|
|
|
| 15 |
import zipfile
|
| 16 |
from datetime import date
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
-
from flask import Flask, render_template, jsonify, request, redirect, url_for, Response
|
| 20 |
from google import genai
|
| 21 |
|
|
|
|
| 22 |
from database import (
|
| 23 |
init_db,
|
| 24 |
create_source, get_source, get_all_sources, delete_source,
|
|
@@ -32,6 +34,24 @@ from tei import source_to_tei, tei_to_source_data
|
|
| 32 |
|
| 33 |
app = Flask(__name__)
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 36 |
BIBLE_TSV_PATH = PROJECT_ROOT / "data" / "bible.tsv"
|
| 37 |
BOOK_MAPPING_PATH = PROJECT_ROOT / "data" / "book_mapping.tsv"
|
|
@@ -721,7 +741,7 @@ init_db()
|
|
| 721 |
if __name__ == "__main__":
|
| 722 |
_cache_db = bool(os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB"))
|
| 723 |
_host = os.environ.get("SD_HOST", "127.0.0.1")
|
| 724 |
-
_port = int(os.environ.get("SD_PORT", "
|
| 725 |
# Disable the reloader in cache-db mode: the reloader forks the process,
|
| 726 |
# which would create a fresh in-memory database and lose all data.
|
| 727 |
_debug = not _cache_db
|
|
|
|
| 12 |
import io
|
| 13 |
import json
|
| 14 |
import re
|
| 15 |
+
import uuid
|
| 16 |
import zipfile
|
| 17 |
from datetime import date
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
+
from flask import Flask, render_template, jsonify, request, redirect, url_for, Response, session
|
| 21 |
from google import genai
|
| 22 |
|
| 23 |
+
import database # imported as module so we can write to database.session_local
|
| 24 |
from database import (
|
| 25 |
init_db,
|
| 26 |
create_source, get_source, get_all_sources, delete_source,
|
|
|
|
| 34 |
|
| 35 |
app = Flask(__name__)
|
| 36 |
|
| 37 |
+
# Flask sessions need a secret key for signing the session cookie.
|
| 38 |
+
# In production set SD_SECRET_KEY in the environment; otherwise a random
|
| 39 |
+
# key is generated at startup (sessions survive only while the server runs).
|
| 40 |
+
app.secret_key = os.environ.get("SD_SECRET_KEY") or os.urandom(32)
|
| 41 |
+
|
| 42 |
+
_CACHE_MODE = bool(os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB"))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.before_request
|
| 46 |
+
def _bind_session_db():
|
| 47 |
+
"""Assign (and persist) a unique DB session ID for this browser session."""
|
| 48 |
+
if not _CACHE_MODE:
|
| 49 |
+
return
|
| 50 |
+
if "_db_sid" not in session:
|
| 51 |
+
session["_db_sid"] = str(uuid.uuid4())
|
| 52 |
+
session.permanent = True # honour PERMANENT_SESSION_LIFETIME
|
| 53 |
+
database.session_local.session_id = session["_db_sid"]
|
| 54 |
+
|
| 55 |
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 56 |
BIBLE_TSV_PATH = PROJECT_ROOT / "data" / "bible.tsv"
|
| 57 |
BOOK_MAPPING_PATH = PROJECT_ROOT / "data" / "book_mapping.tsv"
|
|
|
|
| 741 |
if __name__ == "__main__":
|
| 742 |
_cache_db = bool(os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB"))
|
| 743 |
_host = os.environ.get("SD_HOST", "127.0.0.1")
|
| 744 |
+
_port = int(os.environ.get("SD_PORT", "5001"))
|
| 745 |
# Disable the reloader in cache-db mode: the reloader forks the process,
|
| 746 |
# which would create a fresh in-memory database and lose all data.
|
| 747 |
_debug = not _cache_db
|
database.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
| 2 |
import sqlite3
|
| 3 |
import re
|
| 4 |
import threading
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from contextlib import contextmanager
|
| 7 |
|
|
@@ -13,39 +14,100 @@ DB_PATH = (
|
|
| 13 |
else Path(__file__).resolve().parent / "scripture_detector.db"
|
| 14 |
)
|
| 15 |
|
| 16 |
-
# ββ cache-db (in-memory) mode ββββββββββββββββββββββββββββββββββββ
|
| 17 |
-
# Enabled by
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
_cache_lock: threading.Lock = threading.Lock()
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 26 |
conn.row_factory = sqlite3.Row
|
| 27 |
conn.execute("PRAGMA foreign_keys = ON")
|
|
|
|
|
|
|
| 28 |
return conn
|
| 29 |
|
| 30 |
|
| 31 |
-
def
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
@contextmanager
|
| 44 |
def get_db():
|
| 45 |
if _CACHE_MODE:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
try:
|
| 50 |
yield conn
|
| 51 |
conn.commit()
|
|
@@ -53,7 +115,9 @@ def get_db():
|
|
| 53 |
conn.rollback()
|
| 54 |
raise
|
| 55 |
else:
|
| 56 |
-
conn =
|
|
|
|
|
|
|
| 57 |
try:
|
| 58 |
yield conn
|
| 59 |
conn.commit()
|
|
@@ -65,39 +129,12 @@ def get_db():
|
|
| 65 |
|
| 66 |
|
| 67 |
def init_db():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
with get_db() as conn:
|
| 69 |
-
conn.executescript(
|
| 70 |
-
CREATE TABLE IF NOT EXISTS sources (
|
| 71 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 72 |
-
name TEXT NOT NULL,
|
| 73 |
-
text TEXT NOT NULL,
|
| 74 |
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 75 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 76 |
-
);
|
| 77 |
-
CREATE TABLE IF NOT EXISTS quotes (
|
| 78 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 79 |
-
source_id INTEGER NOT NULL,
|
| 80 |
-
span_start INTEGER,
|
| 81 |
-
span_end INTEGER,
|
| 82 |
-
quote_text TEXT NOT NULL,
|
| 83 |
-
quote_type TEXT NOT NULL DEFAULT 'allusion'
|
| 84 |
-
CHECK(quote_type IN ('full','partial','paraphrase','allusion')),
|
| 85 |
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 86 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 87 |
-
FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE
|
| 88 |
-
);
|
| 89 |
-
CREATE TABLE IF NOT EXISTS quote_references (
|
| 90 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 91 |
-
quote_id INTEGER NOT NULL,
|
| 92 |
-
reference TEXT NOT NULL,
|
| 93 |
-
book_code TEXT,
|
| 94 |
-
FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE
|
| 95 |
-
);
|
| 96 |
-
CREATE TABLE IF NOT EXISTS settings (
|
| 97 |
-
key TEXT PRIMARY KEY,
|
| 98 |
-
value TEXT NOT NULL
|
| 99 |
-
);
|
| 100 |
-
""")
|
| 101 |
|
| 102 |
|
| 103 |
def extract_book_code(reference: str) -> str:
|
|
|
|
| 2 |
import sqlite3
|
| 3 |
import re
|
| 4 |
import threading
|
| 5 |
+
import time
|
| 6 |
from pathlib import Path
|
| 7 |
from contextlib import contextmanager
|
| 8 |
|
|
|
|
| 14 |
else Path(__file__).resolve().parent / "scripture_detector.db"
|
| 15 |
)
|
| 16 |
|
| 17 |
+
# ββ cache-db (per-session in-memory) mode ββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
# Enabled by SCRIPTURE_DETECTOR_CACHE_DB=1 (set by app.py when --cache-db is
|
| 19 |
+
# passed). Each browser session gets its own isolated SQLite :memory: database.
|
| 20 |
+
# Sessions are keyed by a UUID stored in the signed Flask session cookie and
|
| 21 |
+
# cleaned up after SESSION_TTL seconds of inactivity.
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
_CACHE_MODE: bool = os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB", "") in ("1", "true", "yes")
|
| 24 |
+
SESSION_TTL: int = int(os.environ.get("SD_SESSION_TTL", "3600")) # default 1 hour
|
| 25 |
+
|
| 26 |
+
# Maps session_id β {conn, lock, last_used}
|
| 27 |
+
_sessions: dict[str, dict] = {}
|
| 28 |
+
_sessions_lock = threading.Lock()
|
| 29 |
+
|
| 30 |
+
# Thread-local holds the current session_id (set by app.py before_request hook)
|
| 31 |
+
session_local = threading.local()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
_SCHEMA_SQL = """
|
| 35 |
+
CREATE TABLE IF NOT EXISTS sources (
|
| 36 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 37 |
+
name TEXT NOT NULL,
|
| 38 |
+
text TEXT NOT NULL,
|
| 39 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 40 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 41 |
+
);
|
| 42 |
+
CREATE TABLE IF NOT EXISTS quotes (
|
| 43 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 44 |
+
source_id INTEGER NOT NULL,
|
| 45 |
+
span_start INTEGER,
|
| 46 |
+
span_end INTEGER,
|
| 47 |
+
quote_text TEXT NOT NULL,
|
| 48 |
+
quote_type TEXT NOT NULL DEFAULT 'allusion'
|
| 49 |
+
CHECK(quote_type IN ('full','partial','paraphrase','allusion')),
|
| 50 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 51 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 52 |
+
FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE
|
| 53 |
+
);
|
| 54 |
+
CREATE TABLE IF NOT EXISTS quote_references (
|
| 55 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 56 |
+
quote_id INTEGER NOT NULL,
|
| 57 |
+
reference TEXT NOT NULL,
|
| 58 |
+
book_code TEXT,
|
| 59 |
+
FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE
|
| 60 |
+
);
|
| 61 |
+
CREATE TABLE IF NOT EXISTS settings (
|
| 62 |
+
key TEXT PRIMARY KEY,
|
| 63 |
+
value TEXT NOT NULL
|
| 64 |
+
);
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _new_session_conn() -> sqlite3.Connection:
|
| 69 |
+
"""Create a fresh in-memory SQLite connection with the full schema."""
|
| 70 |
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 71 |
conn.row_factory = sqlite3.Row
|
| 72 |
conn.execute("PRAGMA foreign_keys = ON")
|
| 73 |
+
conn.executescript(_SCHEMA_SQL)
|
| 74 |
+
conn.commit()
|
| 75 |
return conn
|
| 76 |
|
| 77 |
|
| 78 |
+
def _get_session_entry(session_id: str) -> dict:
|
| 79 |
+
"""Return (and lazily create) the session entry for *session_id*."""
|
| 80 |
+
with _sessions_lock:
|
| 81 |
+
# Prune idle sessions
|
| 82 |
+
now = time.monotonic()
|
| 83 |
+
stale = [sid for sid, s in _sessions.items()
|
| 84 |
+
if now - s["last_used"] > SESSION_TTL]
|
| 85 |
+
for sid in stale:
|
| 86 |
+
try:
|
| 87 |
+
_sessions[sid]["conn"].close()
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
| 90 |
+
del _sessions[sid]
|
| 91 |
+
|
| 92 |
+
if session_id not in _sessions:
|
| 93 |
+
_sessions[session_id] = {
|
| 94 |
+
"conn": _new_session_conn(),
|
| 95 |
+
"lock": threading.Lock(),
|
| 96 |
+
"last_used": now,
|
| 97 |
+
}
|
| 98 |
+
else:
|
| 99 |
+
_sessions[session_id]["last_used"] = now
|
| 100 |
+
|
| 101 |
+
return _sessions[session_id]
|
| 102 |
|
| 103 |
|
| 104 |
@contextmanager
|
| 105 |
def get_db():
|
| 106 |
if _CACHE_MODE:
|
| 107 |
+
sid = getattr(session_local, "session_id", None) or "anonymous"
|
| 108 |
+
entry = _get_session_entry(sid)
|
| 109 |
+
with entry["lock"]:
|
| 110 |
+
conn = entry["conn"]
|
| 111 |
try:
|
| 112 |
yield conn
|
| 113 |
conn.commit()
|
|
|
|
| 115 |
conn.rollback()
|
| 116 |
raise
|
| 117 |
else:
|
| 118 |
+
conn = sqlite3.connect(str(DB_PATH))
|
| 119 |
+
conn.row_factory = sqlite3.Row
|
| 120 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 121 |
try:
|
| 122 |
yield conn
|
| 123 |
conn.commit()
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
def init_db():
|
| 132 |
+
if _CACHE_MODE:
|
| 133 |
+
# In cache mode each session's schema is initialised when its
|
| 134 |
+
# connection is first created (_new_session_conn). Nothing to do here.
|
| 135 |
+
return
|
| 136 |
with get_db() as conn:
|
| 137 |
+
conn.executescript(_SCHEMA_SQL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
def extract_book_code(reference: str) -> str:
|