From 7267de2b329398b7985824b0edef4fb24001d9d8 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:07:36 -0300 Subject: [PATCH] fix(repair): wait out transient SQLite contention --- mempalace/repair.py | 14 +++++++++++- tests/test_repair.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/mempalace/repair.py b/mempalace/repair.py index 566d416..7d79526 100644 --- a/mempalace/repair.py +++ b/mempalace/repair.py @@ -573,6 +573,9 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None) return None +_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS = 15.0 + + def sqlite_integrity_errors(palace_path: str) -> list[str]: """Return SQLite quick_check errors for chroma.sqlite3. @@ -591,7 +594,16 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]: return [] try: - with sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True) as conn: + # A writer holding SQLite's lock is contention, not corruption. The + # sqlite3 module defaults to five seconds, which is shorter than + # routine batch mines and curator writes on rollback-journal palaces. + # Give those writers a bounded grace period before surfacing BUSY to + # callers; genuine corruption still comes from PRAGMA quick_check. + with sqlite3.connect( + sqlite_read_uri(sqlite_path), + uri=True, + timeout=_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS, + ) as conn: rows = conn.execute("PRAGMA quick_check").fetchall() except sqlite3.Error as e: return [f"PRAGMA quick_check failed: {e}"] diff --git a/tests/test_repair.py b/tests/test_repair.py index a8d79a5..1f6d1ee 100644 --- a/tests/test_repair.py +++ b/tests/test_repair.py @@ -1358,6 +1358,57 @@ def test_sqlite_integrity_errors_returns_empty_for_healthy_db(tmp_path): assert repair.sqlite_integrity_errors(str(palace)) == [] +def test_sqlite_integrity_errors_uses_bounded_contention_timeout(tmp_path, monkeypatch): + """Integrity checks wait out routine writers without a real-time sleep. + + Assert the sqlite connection contract directly so this regression test is + deterministic and does not add the seven-second delay from the original + proposal to every test run. + """ + palace = tmp_path / "palace" + palace.mkdir() + db_path = palace / "chroma.sqlite3" + db_path.touch() + + calls = [] + + class _Result: + @staticmethod + def fetchall(): + return [("ok",)] + + class _Connection: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, statement): + calls.append(("execute", statement)) + return _Result() + + def _connect(database, **kwargs): + calls.append(("connect", database, kwargs)) + return _Connection() + + monkeypatch.setattr(repair.sqlite3, "connect", _connect) + + assert repair.sqlite_integrity_errors(str(palace)) == [] + assert calls == [ + ( + "connect", + repair.sqlite_read_uri(str(db_path)), + { + "uri": True, + "timeout": repair._SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS, + }, + ), + ("execute", "PRAGMA quick_check"), + ] + assert repair._SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS == 15.0 + + def test_sqlite_integrity_errors_reports_unreadable_sqlite_file(tmp_path): palace = tmp_path / "palace" palace.mkdir()