342 lines
13 KiB
Python
342 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):
|
|
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"]
|