From 7ea0d59512c6faa5cf537efc8578c86b0fb1b05c Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 13 May 2026 01:01:49 +0800 Subject: [PATCH] fix(db): prune rows referencing deleted chat_session on init Remove orphan chat_message, tool_log, oclaw_*, and attachment_acl rows when session_id no longer exists (e.g. legacy deletes without FK). Add regression tests. Co-authored-by: Cursor --- platform/persistence/sqlite_store.py | 15 +++++++ tests/test_session_orphan_prune.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/test_session_orphan_prune.py diff --git a/platform/persistence/sqlite_store.py b/platform/persistence/sqlite_store.py index 6f49ece8..9a8723b2 100644 --- a/platform/persistence/sqlite_store.py +++ b/platform/persistence/sqlite_store.py @@ -997,6 +997,21 @@ class SqliteStore: conn.execute( "CREATE INDEX IF NOT EXISTS idx_chat_message_session_id_id ON chat_message(session_id, id)" ) + # 若历史上曾在未开启 foreign_keys 的连接里删除过 ``chat_session``,或表创建时未带外键, + # 会留下指向已不存在会话的行。每次初始化库时做一次幂等清理。 + self._prune_rows_for_missing_chat_session(conn) + + def _prune_rows_for_missing_chat_session(self, conn: sqlite3.Connection) -> None: + sid_alive = "(SELECT id FROM chat_session)" + conn.execute(f"DELETE FROM chat_message WHERE session_id NOT IN {sid_alive}") + conn.execute(f"DELETE FROM tool_log WHERE session_id NOT IN {sid_alive}") + conn.execute( + "DELETE FROM oclaw_attempt WHERE run_id NOT IN (SELECT run_id FROM oclaw_run) " + f"OR run_id IN (SELECT run_id FROM oclaw_run WHERE session_id NOT IN {sid_alive})" + ) + conn.execute(f"DELETE FROM oclaw_run WHERE session_id NOT IN {sid_alive}") + conn.execute(f"DELETE FROM oclaw_task WHERE session_id NOT IN {sid_alive}") + conn.execute(f"DELETE FROM attachment_acl WHERE session_id NOT IN {sid_alive}") def _seed_builtin_llm_profiles(self, conn: sqlite3.Connection) -> None: ts = utc_now_iso() diff --git a/tests/test_session_orphan_prune.py b/tests/test_session_orphan_prune.py new file mode 100644 index 00000000..bd8fc4d2 --- /dev/null +++ b/tests/test_session_orphan_prune.py @@ -0,0 +1,67 @@ +"""_init_db prunes rows that reference deleted chat_session (legacy FK-off deletes).""" + +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from oclaw.platform.persistence.sqlite_store import SqliteStore + + +class SessionOrphanPruneTests(unittest.TestCase): + def test_chat_message_pruned_after_session_deleted_with_fk_off(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + db = Path(td) / "x.sqlite" + s = SqliteStore(str(db)) + sess = s.create_session("t") + s.add_message(sess.id, "user", "hello") + raw = sqlite3.connect(str(db)) + raw.execute("PRAGMA foreign_keys=OFF") + raw.execute("DELETE FROM chat_session WHERE id = ?", (sess.id,)) + raw.commit() + raw.close() + self.assertGreater( + int( + sqlite3.connect(str(db)) + .execute("select count(*) from chat_message where session_id = ?", (sess.id,)) + .fetchone()[0] + ), + 0, + ) + SqliteStore(str(db)) + n = int( + sqlite3.connect(str(db)) + .execute("select count(*) from chat_message where session_id = ?", (sess.id,)) + .fetchone()[0] + ) + self.assertEqual(n, 0) + + def test_oclaw_task_pruned_when_session_row_missing(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + db = Path(td) / "y.sqlite" + store = SqliteStore(str(db)) + sess = store.create_session("s") + with store._connect() as conn: + conn.execute( + """ + INSERT INTO oclaw_task( + id, tenant_id, session_id, status, payload, result, + attempt_count, last_error, created_at, updated_at + ) VALUES (?, ?, ?, 'done', '{}', '{}', 0, '', datetime('now'), datetime('now')) + """, + ("task-1", "tenant-1", sess.id), + ) + raw = sqlite3.connect(str(db)) + raw.execute("PRAGMA foreign_keys=OFF") + raw.execute("DELETE FROM chat_session WHERE id = ?", (sess.id,)) + raw.commit() + raw.close() + SqliteStore(str(db)) + c = int(sqlite3.connect(str(db)).execute("select count(*) from oclaw_task").fetchone()[0]) + self.assertEqual(c, 0) + + +if __name__ == "__main__": + unittest.main()