Files
mempalace--mempalace/tests/test_miner_fts5_validation.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

566 lines
19 KiB
Python

"""Tests for end-of-mine FTS5 validation (#1537).
mempalace mine must not print "Done." and exit 0 on a palace whose
chroma.sqlite3 left FTS5 in a malformed state. The validation hook in
``palace._validate_palace_fts5_after_mine`` runs PRAGMA quick_check at
the end of every non-dry-run mine and raises ``MineValidationError`` so
``cmd_mine`` can surface the same recovery banner ``cmd_repair`` prints.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from mempalace import cli, convo_miner, miner
from mempalace.palace import (
MineValidationError,
_validate_palace_fts5_after_mine,
)
# ── Helpers ─────────────────────────────────────────────────────────
def _build_palace_with_drawer(palace_path: Path) -> None:
"""Create a real chromadb palace with one drawer so chroma.sqlite3 exists."""
palace_path.mkdir(parents=True, exist_ok=True)
from mempalace.backends.chroma import ChromaBackend
backend = ChromaBackend()
try:
col = backend.create_collection(str(palace_path), "mempalace_drawers")
col.upsert(
ids=["d1"],
documents=["hello world memorable phrase"],
metadatas=[{"wing": "w", "room": "r"}],
)
finally:
backend.close()
def _page_mangle(sqlite_path: Path) -> int:
"""Corrupt 4 mid-file pages so PRAGMA quick_check fails. Returns offset used."""
PAGE = 4096
CORRUPT_BYTES = 16384 # 4 pages
HEADER_GUARD = PAGE * 2
pre_size = sqlite_path.stat().st_size
assert pre_size >= HEADER_GUARD + CORRUPT_BYTES, (
f"sqlite db too small to mangle: {pre_size} bytes"
)
max_offset = (pre_size - CORRUPT_BYTES) & ~(PAGE - 1)
corrupt_offset = min(40960, max_offset)
assert corrupt_offset >= HEADER_GUARD
with open(sqlite_path, "r+b") as f:
f.seek(corrupt_offset)
f.write(b"\xde\xad\xbe\xef" * (CORRUPT_BYTES // 4))
return corrupt_offset
def _corrupt_fts5_segment(sqlite_path: Path) -> None:
"""Soft FTS5-only corruption: replace one segment blob with garbage.
Mirrors the reporter's natural failure mode where chromadb opens cleanly
but ``PRAGMA quick_check`` returns ``malformed inverted index for FTS5
table main.embedding_fulltext_search``.
"""
import sqlite3
with sqlite3.connect(str(sqlite_path)) as conn:
# Schema-drift guard: chromadb's FTS5 shadow table name is private
# API and may rename across versions. Skip cleanly rather than error.
tables = {
r[0]
for r in conn.execute(
"SELECT name FROM sqlite_master WHERE name LIKE 'embedding_fulltext_search%'"
).fetchall()
}
if "embedding_fulltext_search_data" not in tables:
pytest.skip(
f"chromadb FTS5 shadow table 'embedding_fulltext_search_data' not present; "
f"found: {sorted(tables)}"
)
rows = conn.execute("SELECT id, block FROM embedding_fulltext_search_data").fetchall()
if not rows:
pytest.skip("FTS5 segments empty: cannot fabricate FTS5-only corruption")
target = next((r for r in rows if r[0] > 10), rows[0])
garbage = b"\xde\xad\xbe\xef" * (len(target[1]) // 4)
try:
conn.execute(
"UPDATE embedding_fulltext_search_data SET block=? WHERE id=?",
(garbage, target[0]),
)
except sqlite3.OperationalError as exc:
if "may not be modified" in str(exc):
pytest.skip(
"this SQLite build refuses direct FTS5 shadow-table writes; "
"cannot fabricate FTS5-only corruption"
)
raise
conn.commit()
def _mine_args(
palace: str, src: str, *, mode: str = "project", dry_run: bool = False
) -> SimpleNamespace:
"""Build the args namespace cmd_mine reads."""
return SimpleNamespace(
palace=palace,
dir=src,
mode=mode,
wing=None,
agent="mempalace",
limit=0,
dry_run=dry_run,
no_gitignore=False,
include_ignored=None,
extract="exchange",
redetect_origin=False,
)
# ── 1. Helper returns silently on a clean palace ────────────────────
def test_helper_returns_silently_on_clean_palace(tmp_path):
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
# Should not raise: quick_check returns ('ok',) so no errors collected.
assert _validate_palace_fts5_after_mine(str(palace)) is None
# ── 2. Helper raises MineValidationError on corrupt sqlite ──────────
def test_helper_raises_on_page_mangled_sqlite(tmp_path):
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
_page_mangle(palace / "chroma.sqlite3")
with pytest.raises(MineValidationError) as exc_info:
_validate_palace_fts5_after_mine(str(palace))
err = exc_info.value
assert err.palace_path == str(palace)
assert err.errors, "errors list must be populated"
combined = " ".join(err.errors).lower()
assert "malformed" in combined or "quick_check failed" in combined
def test_helper_raises_on_fts5_segment_corruption(tmp_path):
"""The reporter-shaped failure: FTS5 inverted index malformed, main pages OK."""
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
_corrupt_fts5_segment(palace / "chroma.sqlite3")
with pytest.raises(MineValidationError) as exc_info:
_validate_palace_fts5_after_mine(str(palace))
err = exc_info.value
assert "fts5" in " ".join(err.errors).lower()
# ── 3. cmd_mine surfaces MineValidationError as exit-1 + banner ─────
def test_cmd_mine_project_mode_exits_nonzero_with_banner(tmp_path, monkeypatch, capsys):
palace = str(tmp_path / "palace")
src = tmp_path / "src"
src.mkdir()
(src / "a.md").write_text("placeholder")
def _raise(*_, **__):
raise MineValidationError(
palace, ["malformed inverted index for FTS5 table main.embedding_fulltext_search"]
)
monkeypatch.setattr(miner, "mine", _raise)
with pytest.raises(SystemExit) as exit_info:
cli.cmd_mine(_mine_args(palace, str(src), mode="project"))
assert exit_info.value.code == 1
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "SQLite-layer corruption detected" in combined
assert "PRAGMA quick_check" in combined
assert "malformed inverted index" in combined
assert "mempalace repair --yes" in combined
def test_cmd_mine_convos_mode_exits_nonzero_with_banner(tmp_path, monkeypatch, capsys):
palace = str(tmp_path / "palace")
src = tmp_path / "convos"
src.mkdir()
def _raise(*_, **__):
raise MineValidationError(palace, ["malformed inverted index for FTS5 table"])
monkeypatch.setattr(convo_miner, "mine_convos", _raise)
with pytest.raises(SystemExit) as exit_info:
cli.cmd_mine(_mine_args(palace, str(src), mode="convos"))
assert exit_info.value.code == 1
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "SQLite-layer corruption detected" in combined
assert "mempalace repair --yes" in combined
# ── 4. Dry-run skips validation ─────────────────────────────────────
def test_validate_skipped_on_dry_run(tmp_path, monkeypatch):
"""`mine(..., dry_run=True)` must not invoke the validator (no writes happened)."""
palace = tmp_path / "palace"
src = tmp_path / "src"
_build_palace_with_drawer(palace)
src.mkdir()
big = "lorem ipsum " * 500
(src / "big.md").write_text(big)
calls = []
def _spy(palace_path):
calls.append(palace_path)
monkeypatch.setattr(miner, "_validate_palace_fts5_after_mine", _spy)
miner.mine(
project_dir=str(src),
palace_path=str(palace),
wing_override=None,
agent="mempalace",
limit=0,
dry_run=True,
)
assert calls == [], f"validator must not run in dry-run mode, got: {calls}"
# ── 5. Real exception chain through _mine_impl (no monkeypatch) ────
def test_full_chain_raises_through_mine_impl(tmp_path, monkeypatch):
"""Run a real mine, corrupt FTS5 mid-process, re-mine. The validator
inside _mine_impl must raise MineValidationError as the explicit source
(spy verifies). The new `except MineValidationError: raise` clause in
miner._mine_impl bypasses the partial-progress "Mine aborted" banner so
cmd_mine prints the single, authoritative recovery message.
"""
from mempalace import miner as miner_mod
from mempalace import palace as palace_mod
palace = tmp_path / "palace"
src = tmp_path / "src"
src.mkdir()
(src / "big.md").write_text("lorem ipsum " * 200)
miner_mod.mine(
project_dir=str(src),
palace_path=str(palace),
wing_override=None,
agent="mempalace",
limit=0,
dry_run=False,
)
_corrupt_fts5_segment(palace / "chroma.sqlite3")
# Spy on the validator so we can prove IT was the raise-source, not
# some other exception masquerading as MineValidationError.
called = []
real_validator = palace_mod._validate_palace_fts5_after_mine
def _validator_spy(path):
called.append(path)
return real_validator(path)
monkeypatch.setattr(miner_mod, "_validate_palace_fts5_after_mine", _validator_spy)
with pytest.raises(MineValidationError) as exc_info:
miner_mod.mine(
project_dir=str(src),
palace_path=str(palace),
wing_override=None,
agent="mempalace",
limit=0,
dry_run=False,
)
assert called == [str(palace)], f"validator must be the raise-source; spy recorded: {called}"
assert "fts5" in " ".join(exc_info.value.errors).lower()
assert exc_info.value.palace_path == str(palace)
def test_mine_impl_does_not_print_partial_summary_on_validation_error(tmp_path, capsys):
"""When _validate_palace_fts5_after_mine raises, miner._mine_impl must
NOT print the "Mine aborted by exception" partial-progress banner that
`except Exception` adds. That banner is reserved for true mid-loop
failures and would double-up with cmd_mine's recovery banner.
"""
from mempalace import miner as miner_mod
palace = tmp_path / "palace"
src = tmp_path / "src"
src.mkdir()
(src / "big.md").write_text("lorem ipsum " * 200)
miner_mod.mine(
project_dir=str(src),
palace_path=str(palace),
wing_override=None,
agent="mempalace",
limit=0,
dry_run=False,
)
_corrupt_fts5_segment(palace / "chroma.sqlite3")
with pytest.raises(MineValidationError):
miner_mod.mine(
project_dir=str(src),
palace_path=str(palace),
wing_override=None,
agent="mempalace",
limit=0,
dry_run=False,
)
captured = capsys.readouterr()
assert "Mine aborted by exception" not in captured.out + captured.err
def test_mine_validation_error_rejects_empty_errors():
"""Defense-in-depth: the type should not allow construction with an
empty errors list (the message would say "0 issue(s)") or a blank path.
"""
with pytest.raises(ValueError, match="at least one error"):
MineValidationError("/tmp/x", [])
with pytest.raises(ValueError, match="non-empty palace_path"):
MineValidationError("", ["err"])
def test_mine_validation_error_errors_attribute_is_immutable():
err = MineValidationError("/tmp/x", ["a", "b"])
assert isinstance(err.errors, tuple)
with pytest.raises(AttributeError):
err.errors.append("c") # type: ignore[attr-defined]
def test_helper_silent_on_missing_palace(tmp_path):
"""Helper returns silently when chroma.sqlite3 doesn't exist (palace
dir missing or mine never wrote). The repair primitive `sqlite_integrity_errors`
already short-circuits on missing path; verify the contract holds end-to-end.
"""
missing = tmp_path / "no_palace_here"
assert _validate_palace_fts5_after_mine(str(missing)) is None
def test_convo_miner_dry_run_skips_validator(tmp_path, monkeypatch):
"""`mine_convos(..., dry_run=True)` must not invoke the validator;
mirrors the project-miner guarantee.
"""
from mempalace import convo_miner as convo_mod
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
src = tmp_path / "convos"
src.mkdir()
calls = []
def _spy(palace_path):
calls.append(palace_path)
monkeypatch.setattr(convo_mod, "_validate_palace_fts5_after_mine", _spy)
convo_mod.mine_convos(
convo_dir=str(src),
palace_path=str(palace),
wing="testwing",
agent="mempalace",
limit=0,
dry_run=True,
)
assert calls == [], f"convo_miner must not validate in dry-run, got: {calls}"
# ── 6. Helper closes ChromaDB handles before re-opening read-only ───
def test_close_handles_called_before_quick_check(tmp_path, monkeypatch):
"""Windows guard: ChromaDB mmap handles must be released before the read-only re-open."""
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
order = []
from mempalace import repair as repair_mod
real_close = repair_mod._close_chroma_handles
real_errors = repair_mod.sqlite_integrity_errors
def _close_spy(palace_path, *args, **kwargs):
order.append("close")
return real_close(palace_path, *args, **kwargs)
def _errors_spy(palace_path, *args, **kwargs):
order.append("quick_check")
return real_errors(palace_path, *args, **kwargs)
monkeypatch.setattr(repair_mod, "_close_chroma_handles", _close_spy)
monkeypatch.setattr(repair_mod, "sqlite_integrity_errors", _errors_spy)
_validate_palace_fts5_after_mine(str(palace))
assert order == ["close", "quick_check"], (
f"_close_chroma_handles must run before sqlite_integrity_errors, got: {order}"
)
# ── 7. --mode extract / mine_formats coverage (post-#1555 gap close) ──
def test_cmd_mine_extract_mode_exits_nonzero_with_banner(tmp_path, monkeypatch, capsys):
"""cmd_mine on --mode extract must surface the same recovery banner as
--mode convos / project when mine_formats raises MineValidationError.
The third mine entry point landed in develop via #1555 (3.3.6 release)
after #1548 was written, so without this wire-up the extract path
would exit 0 on a corrupted FTS5 palace.
"""
from mempalace import format_miner as format_mod
palace = str(tmp_path / "palace")
src = tmp_path / "docs"
src.mkdir()
def _raise(*_, **__):
raise MineValidationError(palace, ["malformed inverted index for FTS5 table"])
monkeypatch.setattr(format_mod, "mine_formats", _raise)
with pytest.raises(SystemExit) as exit_info:
cli.cmd_mine(_mine_args(palace, str(src), mode="extract"))
assert exit_info.value.code == 1
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "SQLite-layer corruption detected" in combined
assert "mempalace repair --yes" in combined
def test_mine_formats_dry_run_skips_validator(tmp_path, monkeypatch):
"""`mine_formats(..., dry_run=True)` must not invoke the validator;
mirrors the project-miner and convo-miner guarantees.
"""
from mempalace import format_miner as format_mod
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
src = tmp_path / "docs"
src.mkdir()
calls = []
def _spy(palace_path):
calls.append(palace_path)
monkeypatch.setattr(format_mod, "_validate_palace_fts5_after_mine", _spy)
format_mod.mine_formats(
format_dir=str(src),
palace_path=str(palace),
wing="testwing",
agent="mempalace",
limit=0,
dry_run=True,
)
assert calls == [], f"mine_formats must not validate in dry-run, got: {calls}"
def test_mine_formats_keyboard_interrupt_skips_validator(tmp_path, monkeypatch):
"""KeyboardInterrupt mid-mine routes through the outer `except
KeyboardInterrupt` branch, NOT the `else` branch where validation
sits, so a Ctrl-C abort must not trigger end-of-mine FTS5 validation.
The per-file `except Exception` does not catch BaseException, so the
interrupt propagates up to the outer handler as required.
"""
from mempalace import format_miner as format_mod
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
src = tmp_path / "docs"
src.mkdir()
fake_doc = src / "stub.docx"
fake_doc.write_bytes(b"PK\x03\x04stub")
calls = []
def _spy(palace_path):
calls.append(palace_path)
def _interrupt(*_args, **_kwargs):
raise KeyboardInterrupt
monkeypatch.setattr(format_mod, "_validate_palace_fts5_after_mine", _spy)
monkeypatch.setattr(format_mod, "scan_formats", lambda *_a, **_k: [fake_doc])
monkeypatch.setattr(format_mod, "extract_text", _interrupt)
format_mod.mine_formats(
format_dir=str(src),
palace_path=str(palace),
wing="testwing",
agent="mempalace",
limit=0,
dry_run=False,
)
assert calls == [], f"mine_formats must not validate on KeyboardInterrupt, got: {calls}"
def test_mine_formats_full_chain_raises_when_fts5_corrupt(tmp_path, monkeypatch):
"""End-to-end: a palace with corrupted FTS5 + real mine_formats call
must propagate MineValidationError from `_validate_palace_fts5_after_mine`.
Mirrors `test_full_chain_raises_through_mine_impl` for the extract path.
Empty source dir is sufficient: the for-loop iterates zero times, the
`else` branch runs, validation fires on the pre-corrupted sqlite.
"""
from mempalace import format_miner as format_mod
from mempalace import palace as palace_mod
palace = tmp_path / "palace"
_build_palace_with_drawer(palace)
_corrupt_fts5_segment(palace / "chroma.sqlite3")
src = tmp_path / "docs"
src.mkdir()
called = []
real_validator = palace_mod._validate_palace_fts5_after_mine
def _validator_spy(path):
called.append(path)
return real_validator(path)
monkeypatch.setattr(format_mod, "_validate_palace_fts5_after_mine", _validator_spy)
with pytest.raises(MineValidationError) as exc_info:
format_mod.mine_formats(
format_dir=str(src),
palace_path=str(palace),
wing="testwing",
agent="mempalace",
limit=0,
dry_run=False,
)
assert called == [str(palace)], f"validator must be the raise-source; spy recorded: {called}"
assert "fts5" in " ".join(exc_info.value.errors).lower()
assert exc_info.value.palace_path == str(palace)