fix(storage): handle rollback failures in batch operations

Adds nested exception handling in add_files() and _migrate_fts_to_external()
to catch and log rollback failures. Uses exception chaining to preserve both
transaction and rollback errors, preventing silent database inconsistency.

Solution-ID: SOL-1735385400010
Issue-ID: ISS-1766921318981-10
Task-ID: T1
This commit is contained in:
catlog22
2025-12-29 19:08:49 +08:00
parent 76ab4d67fe
commit 3fdd52742b
2 changed files with 101 additions and 5 deletions

View File

@@ -3,12 +3,14 @@
from __future__ import annotations
import logging
import sqlite3
import threading
import time
from pathlib import Path
import pytest
from codexlens.entities import IndexedFile
from codexlens.storage.sqlite_store import SQLiteStore
@@ -114,3 +116,88 @@ def test_cleanup_robustness(
finally:
store.close()
def test_add_files_rollback_preserves_original_exception(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""add_files should re-raise the transaction error when rollback succeeds."""
monkeypatch.setattr(SQLiteStore, "CLEANUP_INTERVAL", 0)
store = SQLiteStore(tmp_path / "add_files_ok.db")
store.initialize()
real_conn = store._get_connection()
class FailingConnection:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
self.rollback_calls = 0
def execute(self, sql: str, params: tuple = ()):
if "INSERT INTO files" in sql:
raise sqlite3.OperationalError("boom")
return self._conn.execute(sql, params)
def executemany(self, sql: str, seq):
return self._conn.executemany(sql, seq)
def commit(self) -> None:
self._conn.commit()
def rollback(self) -> None:
self.rollback_calls += 1
self._conn.rollback()
wrapped = FailingConnection(real_conn)
monkeypatch.setattr(store, "_get_connection", lambda: wrapped)
indexed_file = IndexedFile(path=str(tmp_path / "a.py"), language="python", symbols=[])
try:
with pytest.raises(sqlite3.OperationalError, match="boom"):
store.add_files([(indexed_file, "# content")])
assert wrapped.rollback_calls == 1
finally:
store.close()
def test_add_files_rollback_failure_is_chained(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Rollback failures should be logged and chained as the cause."""
monkeypatch.setattr(SQLiteStore, "CLEANUP_INTERVAL", 0)
caplog.set_level(logging.ERROR, logger="codexlens.storage.sqlite_store")
store = SQLiteStore(tmp_path / "add_files_rollback_fail.db")
store.initialize()
real_conn = store._get_connection()
class FailingRollbackConnection:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def execute(self, sql: str, params: tuple = ()):
if "INSERT INTO files" in sql:
raise sqlite3.OperationalError("boom")
return self._conn.execute(sql, params)
def executemany(self, sql: str, seq):
return self._conn.executemany(sql, seq)
def commit(self) -> None:
self._conn.commit()
def rollback(self) -> None:
raise sqlite3.OperationalError("rollback boom")
monkeypatch.setattr(store, "_get_connection", lambda: FailingRollbackConnection(real_conn))
indexed_file = IndexedFile(path=str(tmp_path / "b.py"), language="python", symbols=[])
try:
with pytest.raises(sqlite3.OperationalError) as exc:
store.add_files([(indexed_file, "# content")])
assert exc.value.__cause__ is not None
assert isinstance(exc.value.__cause__, sqlite3.OperationalError)
assert "rollback boom" in str(exc.value.__cause__)
assert "Rollback failed after add_files() error" in caplog.text
assert "boom" in caplog.text
finally:
store.close()