fix: skip startup SQLite integrity check on oversized palace

The MCP server ran PRAGMA quick_check on the full chroma.sqlite3 during
startup, before answering the initialize handshake. quick_check is
O(database size); on multi-GB palaces it exceeds the MCP client's ~30s
connection timeout, so the server never finishes starting and the client
drops the connection (observed >2min on a 4.6GB palace).

Skip the startup probe when chroma.sqlite3 exceeds
MEMPALACE_STARTUP_INTEGRITY_MAX_MB (default 512MB; 0 disables). The gate
lives in _refresh_sqlite_integrity_status, the single choke point for the
startup calls and every lazy consumer. `mempalace repair` preflight still
runs the full quick_check via repair.sqlite_integrity_errors, so
SQLite-layer corruption is still caught on the destructive path.

Refs #1818.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq495N7e2D4wY2Mp2AQvg7
This commit is contained in:
Tom Boucher
2026-07-02 12:34:56 -04:00
co-authored by Claude Opus 4.8
parent da5a48caf5
commit c54531a2a2
2 changed files with 147 additions and 0 deletions
+63
View File
@@ -350,6 +350,19 @@ _SQLITE_INTEGRITY_ALLOWED_TOOLS = frozenset(
}
)
# The startup probe above runs PRAGMA quick_check, which reads every page of
# chroma.sqlite3 and is therefore O(database size). On multi-GB palaces it can
# exceed the MCP client's connection/handshake timeout, so the server never
# finishes starting and the client drops the connection (the peer-writer guard
# and lazy consumers all funnel through _refresh_sqlite_integrity_status). Skip
# the *startup* probe when the database exceeds this size (MB). `mempalace
# repair` still runs the full quick_check via repair.sqlite_integrity_errors
# before any destructive rebuild, so corruption is still caught where it
# matters. Set MEMPALACE_STARTUP_INTEGRITY_MAX_MB=0 to disable the gate and
# always run the startup probe.
_STARTUP_INTEGRITY_MAX_MB_ENV = "MEMPALACE_STARTUP_INTEGRITY_MAX_MB"
_STARTUP_INTEGRITY_MAX_MB_DEFAULT = 512.0
# MCP peer-writer guard (#1818).
#
@@ -465,6 +478,33 @@ def _mcp_peer_writer_refusal(req_id, tool_name: str):
}
def _startup_integrity_size_limit_bytes() -> int:
"""Byte size above which the startup SQLite quick_check is skipped.
Returns 0 when the gate is disabled (``MEMPALACE_STARTUP_INTEGRITY_MAX_MB``
set to 0, a non-positive number, or an unparseable value), meaning the
startup probe always runs.
"""
raw = os.environ.get(_STARTUP_INTEGRITY_MAX_MB_ENV, "").strip()
if not raw:
mb = _STARTUP_INTEGRITY_MAX_MB_DEFAULT
else:
try:
mb = float(raw)
except ValueError:
logger.warning(
"Invalid %s=%r; using default %.0f MB",
_STARTUP_INTEGRITY_MAX_MB_ENV,
raw,
_STARTUP_INTEGRITY_MAX_MB_DEFAULT,
)
mb = _STARTUP_INTEGRITY_MAX_MB_DEFAULT
if mb <= 0:
return 0
return int(mb * 1024 * 1024)
def _refresh_sqlite_integrity_status() -> None:
"""Refresh the MCP startup SQLite/FTS5 integrity gate.
@@ -484,6 +524,29 @@ def _refresh_sqlite_integrity_status() -> None:
_sqlite_integrity_check_error = ""
return
max_bytes = _startup_integrity_size_limit_bytes()
if max_bytes > 0:
sqlite_path = os.path.join(_config.palace_path, "chroma.sqlite3")
try:
db_bytes = os.path.getsize(sqlite_path)
except OSError:
db_bytes = 0
if db_bytes > max_bytes:
_sqlite_integrity_checked = True
_sqlite_integrity_errors = []
_sqlite_integrity_check_error = ""
logger.warning(
"SQLite startup integrity check skipped: %s is %.0f MB "
"(> %.0f MB limit); PRAGMA quick_check would block MCP "
"startup. Run `mempalace repair` for a full check, or set "
"%s (MB; 0 disables the limit).",
sqlite_path,
db_bytes / (1024 * 1024),
max_bytes / (1024 * 1024),
_STARTUP_INTEGRITY_MAX_MB_ENV,
)
return
try:
from .repair import sqlite_integrity_errors
+84
View File
@@ -4962,6 +4962,90 @@ def test_refresh_sqlite_integrity_status_records_quick_check_errors(monkeypatch)
assert "malformed inverted index" in mcp_server._sqlite_integrity_errors[0]
def test_refresh_sqlite_integrity_status_skips_oversized_db(monkeypatch, tmp_path):
"""Oversized chroma.sqlite3 must NOT run the O(size) startup quick_check."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (2 * 1024 * 1024)) # 2 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "1") # limit 1 MB < 2 MB
called = {"n": 0}
def _boom(palace_path):
called["n"] += 1
raise AssertionError("quick_check must not run for oversized DB")
monkeypatch.setattr(repair, "sqlite_integrity_errors", _boom)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", ["stale"])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 0
assert mcp_server._sqlite_integrity_checked is True
assert mcp_server._sqlite_integrity_errors == []
def test_refresh_sqlite_integrity_status_runs_when_under_limit(monkeypatch, tmp_path):
"""A DB under the limit still runs the quick_check (behaviour preserved)."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (512 * 1024)) # 0.5 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "1") # limit 1 MB > 0.5 MB
called = {"n": 0}
def _spy(palace_path):
called["n"] += 1
return []
monkeypatch.setattr(repair, "sqlite_integrity_errors", _spy)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 1
assert mcp_server._sqlite_integrity_checked is True
def test_startup_integrity_size_gate_disabled_with_zero(monkeypatch, tmp_path):
"""MEMPALACE_STARTUP_INTEGRITY_MAX_MB=0 disables the gate: check always runs."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (4 * 1024 * 1024)) # 4 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "0")
called = {"n": 0}
def _spy(palace_path):
called["n"] += 1
return []
monkeypatch.setattr(repair, "sqlite_integrity_errors", _spy)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 1
def test_sqlite_integrity_refusal_handles_none_palace_path(monkeypatch):
"""
Regression test for Gemini review feedback on PR #1823 (lines 433-455).