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:
co-authored by
Claude Opus 4.8
parent
da5a48caf5
commit
c54531a2a2
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user