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.

Files changed (2) hide show
  1. app.py +22 -2
  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", "7860"))
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 setting env var SCRIPTURE_DETECTOR_CACHE_DB=1 before this module
18
- # is imported. app.py sets this when --cache-db flag is present.
19
- _CACHE_MODE: bool = os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB", "") in ("1", "true", "yes")
20
- _cache_conn: sqlite3.Connection | None = None
21
- _cache_lock: threading.Lock = threading.Lock()
22
-
23
 
24
- def _make_cache_connection() -> sqlite3.Connection:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 get_connection() -> sqlite3.Connection:
32
- global _cache_conn
33
- if _CACHE_MODE:
34
- if _cache_conn is None:
35
- _cache_conn = _make_cache_connection()
36
- return _cache_conn
37
- conn = sqlite3.connect(str(DB_PATH))
38
- conn.row_factory = sqlite3.Row
39
- conn.execute("PRAGMA foreign_keys = ON")
40
- return conn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  @contextmanager
44
  def get_db():
45
  if _CACHE_MODE:
46
- # Serialise access to the single in-memory connection.
47
- with _cache_lock:
48
- conn = get_connection()
 
49
  try:
50
  yield conn
51
  conn.commit()
@@ -53,7 +115,9 @@ def get_db():
53
  conn.rollback()
54
  raise
55
  else:
56
- conn = get_connection()
 
 
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: