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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-13 01:01:49 +08:00
parent 289cac4dd6
commit 7ea0d59512
2 changed files with 82 additions and 0 deletions

View file

@ -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()

View file

@ -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()