- 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>
348 lines
13 KiB
Python
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"]
|