Files
nap-cli/tests/test_mcp_index_rebuild_recovery.py
T
HumbleTurtleandClaude Opus 5.5 0938b3026e Keep the local index fresh from the change feed and improve ranking
- Track a feed cursor in the local index: reindex takes the head cursor
  before listing and commits it with the snapshot; reads refresh from
  /changes (bounded, throttled) and apply creates, updates, archives,
  restores and hard deletes atomically with the cursor. Existing
  cursorless indexes bootstrap tracking on first read. Failures answer
  locally with explicit freshness warnings; 400 triggers a full
  reindex, 404 falls back to timestamp sync.
- Rank by a weighted score: exact identity (including aliases and
  paths), term coverage including body, intent, bm25, authority, a
  status penalty for superseded/completed records and mild recency.
- Return query-centred FTS5 snippets (never for E2E nodes).
- FTS schema v2: porter stemming, prefix index, title column, rowid
  deletes, two-character terms, domain words no longer stopwords;
  v1 indexes upgrade in place.
- Hyphenated alias/path literals match as adjacent-token phrases and
  rank as exact identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:09:12 -03:00

348 lines
13 KiB
Python

"""Public memory-tool regressions with disposable SQLite and an HTTP provider."""
import copy
import importlib.util
import json
import pathlib
import sqlite3
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
import pytest
def node(name, minute, *, archived=False):
stamp = f"2026-09-01T12:{minute:02d}:00Z"
return {
"id": name, "project_id": "project-1", "full_path": f"/notes/{name}",
"folder_path": "/notes", "name": name, "type": "custom-existing-type",
"metadata": {"custom": "retained"}, "tags": [], "aliases": [], "links": [],
"content_text": name, "updated_at": stamp,
"archived_at": stamp if archived else None,
}
@pytest.fixture
def memory(tmp_path, monkeypatch):
provider = {"nodes": [], "requests": [], "fail_page": None, "override": None}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_GET(self):
if urlsplit(self.path).path.endswith("/changes"):
# Model a server without the change feed; the rebuild must not depend on it.
self.send_response(404)
self.end_headers()
self.wfile.write(b'{"error":{"code":"not_found","message":"no change feed"}}')
return
query = parse_qs(urlsplit(self.path).query)
provider["requests"].append(query)
offset = int(query.get("cursor", ["0"])[0])
if offset == provider["fail_page"] or len(provider["requests"]) > 20:
self.send_response(503)
self.end_headers()
self.wfile.write(b'{"error":{"code":"unavailable","message":"test outage"}}')
return
rows = copy.deepcopy(provider["nodes"])
after = query.get("updated_after", [""])[0]
before = query.get("updated_before", [""])[0]
rows = [n for n in rows if n["updated_at"] > after
and (not before or n["updated_at"] < before)
and (query.get("include_archived") == ["true"] or not n["archived_at"])]
rows.sort(key=lambda n: (n["updated_at"], n["id"]), reverse=True)
limit = int(query.get("limit", ["200"])[0])
end = offset + limit
page = {"items": rows[offset:end], "next_cursor": str(end) if end < len(rows) else None}
if provider["override"] is not None:
page = provider["override"](page, query)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(page).encode())
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
script = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
monkeypatch.syspath_prepend(str(script.parent))
def worker():
spec = importlib.util.spec_from_file_location("rebuild_recovery_test_worker", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.AUTH_DIR = tmp_path / "project-state"
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
mod.TOKEN = "disposable-test-token"
mod.AUTH = {"account_id": "test-account"}
mod.DEFAULT_PROJECT_ID = "project-1"
mod.CONFIGURED_TOOL_PROFILES = {"all"}
# Identity is synthetic; the real request, dispatch and index code run.
mod.refresh_public_auth_state = lambda: None
mod.require_unlocked = lambda _name: None
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
return mod
try:
yield worker(), provider, worker
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def rebuild(mod, capsys, *, limit=200):
mod.cli_main(["napseer_mcp_server.py", "reindex", "--limit", str(limit)])
return json.loads(capsys.readouterr().out)
def found(mod, text):
return [n["id"] for n in mod.call_tool_impl(
"nap_discover", {"q": text, "mode": "local"}
)["items"]]
def seed(mod, provider, capsys):
provider["nodes"] = [node("initial", 0)]
rebuild(mod, capsys)
def test_rebuild_finds_remote_changes_despite_newer_cached_write(memory, capsys):
mod, provider, _ = memory
seed(mod, provider, capsys)
remote, own = node("remoteunique", 5), node("ownwrite", 10)
provider["nodes"].extend([remote, own])
mod.index_node(own)
result = rebuild(mod, capsys)
assert result["graph_complete"] is True
assert found(mod, "remoteunique") == ["remoteunique"]
def test_failed_page_preserves_snapshot_and_restart_recovers(memory, capsys):
mod, provider, worker = memory
seed(mod, provider, capsys)
provider["nodes"].extend([node("remoteunique", 5), node("newest", 20)])
provider["fail_page"] = 1
with pytest.raises(mod.SafeToolError):
rebuild(mod, capsys, limit=1)
assert found(mod, "newest") == []
own = node("ownwrite", 30)
mod.index_node(own)
provider["nodes"].append(own)
provider["fail_page"] = None
restarted = worker()
rebuild(restarted, capsys, limit=1)
assert found(restarted, "remoteunique") == ["remoteunique"]
def test_existing_records_need_no_new_fields_to_rebuild(memory, capsys):
mod, provider, _ = memory
mod.index_node(node("ownwrite", 30))
mod.set_local_graph_index_complete("project-1", True)
provider["nodes"] = [node("remoteunique", 5), node("ownwrite", 30)]
rebuild(mod, capsys)
assert "updated_after" not in provider["requests"][-1]
assert found(mod, "remoteunique") == ["remoteunique"]
def test_apply_failure_rolls_back_batch_and_retry_recovers(memory, capsys):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["nodes"].extend([node("newest", 20), node("failsave", 5)])
with mod.index_connect() as conn:
conn.execute("CREATE TRIGGER fail_sync BEFORE INSERT ON local_index_nodes "
"WHEN NEW.node_id = 'failsave' BEGIN SELECT RAISE(ABORT, 'injected'); END")
with pytest.raises(sqlite3.IntegrityError):
rebuild(mod, capsys)
assert found(mod, "newest") == []
assert found(mod, "initial") == ["initial"]
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
with mod.index_connect() as conn:
conn.execute("DROP TRIGGER fail_sync")
rebuild(mod, capsys)
assert found(mod, "failsave") == ["failsave"]
@pytest.mark.parametrize("page", [{}, {"items": {}}, {"items": [], "next_cursor": 3},
{"items": [None]}, {"items": [], "has_more": True},
{"items": [{"id": "broken"}]},
{"items": [{**node("other", 5), "project_id": "project-2"}]}])
def test_malformed_rebuild_page_preserves_index_and_returns_safe_failure(memory, capsys, page):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["override"] = lambda *_: page
with pytest.raises(mod.SafeToolError) as error:
rebuild(mod, capsys)
assert error.value.code == "invalid_index_response"
assert found(mod, "initial") == ["initial"]
def test_repeated_cursor_is_rejected_before_mutating_the_index(memory, capsys):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["override"] = lambda *_: {"items": [], "next_cursor": "1"}
with pytest.raises(mod.SafeToolError) as error:
rebuild(mod, capsys)
assert error.value.code == "invalid_index_response"
assert len(provider["requests"]) == 3 # seed + two pages, no unbounded loop
assert found(mod, "initial") == ["initial"]
def test_readers_see_previous_snapshot_until_replacement_commits(memory, capsys, monkeypatch):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["nodes"] = [node("replacement", 20), node("second", 10)]
entered, release = threading.Event(), threading.Event()
errors = []
original = mod.index_node
def paused_write(item, **kwargs):
original(item, **kwargs)
if item["id"] == "replacement":
entered.set()
assert release.wait(timeout=3)
def run():
try:
mod.cli_main(["napseer_mcp_server.py", "reindex"])
except Exception as error:
errors.append(error)
monkeypatch.setattr(mod, "index_node", paused_write)
thread = threading.Thread(target=run)
thread.start()
try:
assert entered.wait(timeout=3)
assert found(mod, "initial") == ["initial"]
assert found(mod, "replacement") == []
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
finally:
release.set()
thread.join(timeout=3)
assert not thread.is_alive()
assert not errors
assert json.loads(capsys.readouterr().out)["graph_complete"] is True
assert found(mod, "initial") == []
assert found(mod, "replacement") == ["replacement"]
def test_cancelled_replacement_rolls_back_and_releases_writer_lock(memory, capsys, monkeypatch):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["nodes"] = [node("replacement", 20), node("second", 10)]
cancelled = threading.Event()
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
original = mod.index_node
def cancel_after_write(item, **kwargs):
original(item, **kwargs)
cancelled.set()
monkeypatch.setattr(mod, "index_node", cancel_after_write)
with pytest.raises(mod.SafeToolError) as error:
rebuild(mod, capsys)
assert error.value.code == "request_cancelled"
cancelled.clear()
assert found(mod, "initial") == ["initial"]
assert found(mod, "replacement") == []
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
monkeypatch.setattr(mod, "index_node", original)
rebuild(mod, capsys)
assert found(mod, "replacement") == ["replacement"]
def test_rebuild_preserves_other_projects_and_existing_node_facets(memory, capsys):
mod, provider, _ = memory
seed(mod, provider, capsys)
mod.index_node({**node("otherproject", 30), "project_id": "project-2"})
kept = node("kept", 5)
kept.update(tags=["existing-tag"], aliases=["secondaryhandle"],
links=[{"path": "/notes/target", "relation": "custom-relation"}])
mod.index_node(kept)
assert found(mod, "secondaryhandle") == ["kept"], "baseline before rebuild"
provider["nodes"] = [kept, node("target", 6), node("retired", 8, archived=True)]
result = rebuild(mod, capsys, limit=1)
assert result["indexed"] == 2
assert found(mod, "initial") == []
assert found(mod, "retired") == []
assert found(mod, "secondaryhandle") == ["kept"]
with mod.index_connect() as conn:
assert conn.execute("SELECT COUNT(*) FROM local_index_nodes WHERE project_id='project-2'").fetchone()[0] == 1
assert conn.execute("SELECT node_type FROM local_index_nodes WHERE node_id='kept'").fetchone()[0] == "custom-existing-type"
assert json.loads(conn.execute("SELECT aliases_json FROM local_index_nodes WHERE node_id='kept'").fetchone()[0]) == ["secondaryhandle"]
assert conn.execute("SELECT relation FROM local_index_edges WHERE source_node_id='kept'").fetchone()[0] == "custom-relation"
assert conn.execute("SELECT tag FROM local_index_node_tags WHERE node_id='kept'").fetchone()[0] == "existing-tag"
def test_valid_empty_inventory_commits_an_empty_complete_project(memory, capsys):
mod, provider, _ = memory
seed(mod, provider, capsys)
provider["nodes"] = []
result = rebuild(mod, capsys)
assert result["indexed"] == 0
assert result["graph_complete"] is True
assert mod.local_index_diagnostics("project-1")["count"] == 0
def test_process_exit_during_rebuild_preserves_snapshot_after_restart(memory, capsys):
mod, provider, worker = memory
seed(mod, provider, capsys)
provider["nodes"] = [node("replacement", 20), node("second", 10)]
child = r'''
import importlib.util, os, pathlib, sys
script, state, base = sys.argv[1:]
sys.path.insert(0, str(pathlib.Path(script).parent))
spec = importlib.util.spec_from_file_location("rebuild_crash_worker", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.AUTH_DIR = pathlib.Path(state)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
mod.BASE_URL = base
mod.TOKEN = "disposable-test-token"
mod.DEFAULT_PROJECT_ID = "project-1"
mod.refresh_public_auth_state = lambda: None
mod.resolve_project_id = lambda _: "project-1"
original = mod.index_node
def crash_after_write(item, **kwargs):
original(item, **kwargs)
os._exit(71)
mod.index_node = crash_after_write
mod.cli_main([script, "reindex"])
'''
result = subprocess.run(
[sys.executable, "-c", child, mod.__file__, str(mod.AUTH_DIR), mod.BASE_URL],
capture_output=True, timeout=8, check=False,
)
assert result.returncode == 71
restarted = worker()
assert found(restarted, "initial") == ["initial"]
assert found(restarted, "replacement") == []
assert restarted.local_index_diagnostics("project-1")["graph_complete"] is True
rebuild(restarted, capsys)
assert found(restarted, "replacement") == ["replacement"]