From c3011c80a5225ec590f60798f8350bee6a155968 Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Mon, 4 May 2026 03:39:13 +0000 Subject: [PATCH] api.py: route all sqlite3.connect() through helpers; enable synchronous=NORMAL per-conn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to 4204806 (WAL + index + backup.sh). The previous commit deferred synchronous=NORMAL because it's a per-connection PRAGMA and api.py has 16 sqlite3.connect() call sites — setting it once at init would have applied to nothing afterwards. Adds three helpers near the *_DB constants: - _connect(path): inner; sets PRAGMA synchronous=NORMAL and uses timeout=5.0 (5000ms busy_timeout) on every new connection. - _connect_conversations(), _connect_sessions(): named wrappers so call sites read explicitly. Mechanical replacement at all 16 call sites: 4 sessions, 12 conversations. No semantic change beyond the PRAGMA + busy_timeout — every site still opens-then-closes, no held-open connections. busy_timeout=5000ms is cheap insurance: under WAL with api.py as sole writer, contention should be near-zero, but the backup.sh online-backup path briefly holds a read lock on the source, and any future second writer would otherwise hit SQLITE_BUSY immediately on contention. Combined effect with WAL: per-write fsync count drops from ~2 to ~1 (WAL alone) further reduced by synchronous=NORMAL deferring fsyncs to checkpoint boundaries. No durability loss for the use case (single host, app crash tolerated, OS crash gives at most one lost transaction). Not included: foreign_keys=ON. Audit found 2 orphan rows in messages (conversation_id pointing to deleted conversations) and untested write paths that could begin raising IntegrityError. Tracked as separate followup: inspect orphans, identify the delete path that didn't cascade, clean up, then enable enforcement and test chat delete flow end-to-end. --- scripts/api.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/scripts/api.py b/scripts/api.py index bd4415a..925484d 100644 --- a/scripts/api.py +++ b/scripts/api.py @@ -38,6 +38,18 @@ load_dotenv(Path.home() / "aaronai" / ".env") MEMORY_PATH = Path.home() / "aaronai" / "memory.md" CONVERSATIONS_DB = str(Path.home() / "aaronai" / "conversations.db") + +def _connect(path): + conn = sqlite3.connect(path, timeout=5.0) + conn.execute("PRAGMA synchronous=NORMAL") + return conn + +def _connect_conversations(): + return _connect(CONVERSATIONS_DB) + +def _connect_sessions(): + return _connect(SESSIONS_DB) + SETTINGS_PATH = Path.home() / "aaronai" / "settings.json" WATCHER_LOG = str(Path.home() / "aaronai" / "watcher.log") WATCHER_STATE = str(Path.home() / "aaronai" / "watcher_state.json") @@ -122,7 +134,7 @@ SESSION_PASSWORD = os.getenv("AARON_AI_PASSWORD", "changeme") SESSIONS_DB = str(Path.home() / "aaronai" / "sessions.db") def _init_sessions(): - conn = sqlite3.connect(SESSIONS_DB) + conn = _connect_sessions() conn.execute("CREATE TABLE IF NOT EXISTS sessions (token TEXT PRIMARY KEY, created_at TEXT)") conn.execute("PRAGMA journal_mode=WAL") conn.commit() @@ -137,19 +149,19 @@ def hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() def save_session(token: str): - conn = sqlite3.connect(SESSIONS_DB) + conn = _connect_sessions() conn.execute("INSERT OR REPLACE INTO sessions VALUES (?, ?)", (token, datetime.now().isoformat())) conn.commit() conn.close() def delete_session(token: str): - conn = sqlite3.connect(SESSIONS_DB) + conn = _connect_sessions() conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) conn.commit() conn.close() def session_exists(token: str) -> bool: - conn = sqlite3.connect(SESSIONS_DB) + conn = _connect_sessions() row = conn.execute("SELECT 1 FROM sessions WHERE token = ?", (token,)).fetchone() conn.close() return row is not None @@ -164,7 +176,7 @@ def require_auth(request: Request): return token def init_conversations_db(): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute('''CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, @@ -253,7 +265,7 @@ def retrieve_context(query, n_results=8): return context_pieces, sources def get_conversation_history(conversation_id, limit=20): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute('''SELECT role, content FROM messages WHERE conversation_id = ? @@ -263,7 +275,7 @@ def get_conversation_history(conversation_id, limit=20): return [{"role": r[0], "content": r[1]} for r in reversed(rows)] def save_message(conversation_id, role, content, sources=None): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() msg_id = hashlib.md5(f"{conversation_id}{role}{datetime.now().isoformat()}".encode()).hexdigest() timestamp = datetime.now().isoformat() @@ -277,7 +289,7 @@ def save_message(conversation_id, role, content, sources=None): conn.close() def create_conversation(title="New conversation"): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() conv_id = hashlib.md5(f"{datetime.now().isoformat()}".encode()).hexdigest()[:16] now = datetime.now().isoformat() @@ -412,7 +424,7 @@ async def update_settings(request: Request, auth: str = Depends(require_auth)): @app.get("/api/conversations") async def list_conversations(auth: str = Depends(require_auth)): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute('''SELECT id, title, created_at, updated_at, message_count FROM conversations ORDER BY updated_at DESC LIMIT 100''') @@ -432,7 +444,7 @@ async def new_conversation(request: Request, auth: str = Depends(require_auth)): @app.get("/api/conversations/{conv_id}/messages") async def get_messages(conv_id: str, auth: str = Depends(require_auth)): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute('''SELECT role, content, sources, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC''', (conv_id,)) @@ -449,7 +461,7 @@ async def rename_conversation(conv_id: str, request: Request, auth: str = Depend title = data.get("title", "") if not title: return JSONResponse({"error": "Title required"}, status_code=400) - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("UPDATE conversations SET title = ? WHERE id = ?", (title, conv_id)) conn.commit() @@ -458,7 +470,7 @@ async def rename_conversation(conv_id: str, request: Request, auth: str = Depend @app.delete("/api/conversations/{conv_id}") async def delete_conversation(conv_id: str, auth: str = Depends(require_auth)): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,)) c.execute("DELETE FROM conversations WHERE id = ?", (conv_id,)) @@ -503,14 +515,14 @@ async def chat_endpoint(request: Request, auth: str = Depends(require_auth)): save_message(conversation_id, "user", user_message) # Auto-title conversation from first message - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("SELECT message_count, title FROM conversations WHERE id = ?", (conversation_id,)) row = c.fetchone() conn.close() if row and row[0] <= 1 and row[1] == "New conversation": auto_title = user_message[:60] + ("..." if len(user_message) > 60 else "") - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("UPDATE conversations SET title = ? WHERE id = ?", (auto_title, conversation_id)) conn.commit() @@ -590,7 +602,7 @@ async def get_status(auth: str = Depends(require_auth)): pass # Conversation count - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("SELECT COUNT(*) FROM conversations") conv_count = c.fetchone()[0] @@ -973,7 +985,7 @@ async def reindex_status(auth: str = Depends(require_auth)): @app.delete("/api/conversations") async def clear_all_conversations(auth: str = Depends(require_auth)): - conn = sqlite3.connect(CONVERSATIONS_DB) + conn = _connect_conversations() c = conn.cursor() c.execute("DELETE FROM messages") c.execute("DELETE FROM conversations")