Files
gitmost/packages/mcp/test/mock/getpage-conversion-cache.test.mjs
T
agent_coder 3e945305c8 perf(mcp): content-addressed LRU-кэш конверсии getPage — снять доминирующую агентскую нагрузку (#479)
getPage — доминирующая операция агентского цикла (812 вызовов/2ч, p95 840мс):
полный обход ProseMirror-дерева convertProseMirrorToMarkdown на КАЖДЫЙ вызов,
кэша нет. При 812 read против 28 update большинство — повторная конверсия того
же неизменившегося контента в тот же Markdown на общем event loop. closes #479

- getpage-cache.ts: LRU-кэш (класс, не синглтон) результата конверсии. Ключ
  (canonical pageId UUID, updatedAt, optionsHash). updatedAt из ТОГО ЖЕ ответа
  /pages/info, что и content → инвалидация бесплатная и точная (страница
  изменилась → новый ключ). optionsHash — стабильная сериализация опций
  (dropResolvedCommentAnchors #328), getPage и export не коллизят. Границы: LRU
  по количеству (50) И по байтам (10МБ, Buffer.byteLength), вытеснение по любому;
  oversized-запись хранится, не заклинивает.
- Кэш — protected инстанс-поле в context.ts (один DocmostClient на сессию/
  идентичность) → изоляция как у клиента, межпользовательской утечки контента
  нет. Байт-идентичный вывод: кэшируется строка ДО подстановки {{SUBPAGES}},
  подстановка на живых subpages выполняется на hit и miss одинаково.
- Счётчики через существующий onMetricFn-синк: mcp_getpage_cache_hits_total /
  misses_total (honest hit-rate: miss на реальной конверсии, вкл. non-cacheable).
- Subpages НЕ параллелизуемы: listSidebarPages требует spaceId из ответа
  page-fetch (resolvePageId даёт UUID, но не spaceId) → последовательность
  сохранена (задокументировано); кэш — основной выигрыш.

Тесты: mcp node --test 813/813 (9 unit: hit/miss по updatedAt+options, вытеснение
по count И byte, recency, oversized, hash order-insensitive; 4 mock: MISS→HIT
байт-идентично + конверсия один раз, смена updatedAt→свежий MISS, slugId+UUID
одна запись, дифф-тест живой подстановки subpages на hit). tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:45:01 +03:00

224 lines
9.3 KiB
JavaScript

// Mock-HTTP integration tests for the getPage conversion cache (issue #479).
// A local http.createServer stands in for Docmost (same harness style as
// get-page-context.test.mjs) so everything is deterministic and offline.
//
// Verifies end-to-end through the real client that:
// - the FIRST getPage of a page is a MISS (mcp_getpage_cache_misses_total)
// and converts the content (the server's convert-representative counter);
// - a SECOND getPage of the same (pageId, updatedAt) is a HIT
// (mcp_getpage_cache_hits_total) and returns BYTE-IDENTICAL output while
// skipping the conversion;
// - a changed updatedAt is a fresh key -> MISS again;
// - the returned shape still resolves page + subpages.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
const PAGE_UUID = "00000000-0000-4000-8000-000000000010";
const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa";
const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb";
// A small ProseMirror doc so the converter produces non-trivial markdown.
function makeDoc(text) {
return {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text }],
},
],
};
}
// state.info counts /pages/info hits; state.updatedAt / state.text drive the
// content+version returned; state.sidebar counts sidebar-pages hits;
// state.subpages (when set) drives the child list the sidebar endpoint returns,
// so a test can vary the live subpages across two reads of the same page.
function spawn(state) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
}
if (req.url === "/api/pages/info") {
state.info++;
return sendJson(res, 200, {
success: true,
data: {
id: PAGE_UUID,
slugId: "slug123456",
title: "Cached Page",
parentPageId: null,
spaceId: SPACE_UUID,
updatedAt: state.updatedAt,
content: makeDoc(state.text),
},
});
}
if (req.url === "/api/pages/sidebar-pages") {
state.sidebar++;
const items = state.subpages ?? [
{ id: CHILD_UUID, title: "Child", hasChildren: false },
];
return sendJson(res, 200, {
success: true,
data: {
items,
meta: { hasNextPage: false, nextCursor: null },
},
});
}
return sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
function makeClient(baseURL, metrics) {
return new DocmostClient({
apiUrl: baseURL,
getToken: async () => "access",
onMetric: (name, value) => {
metrics[name] = (metrics[name] ?? 0) + value;
},
});
}
test("first read MISS, second read HIT with byte-identical output; convert runs once", async () => {
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Hello world" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
const first = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read is a miss");
assert.equal(metrics["mcp_getpage_cache_hits_total"] ?? 0, 0, "no hit yet");
const second = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a hit");
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "still one miss");
// BYTE-IDENTICAL: the cache only skips recomputation, never changes output.
assert.deepEqual(second, first, "cached result is identical to the uncached one");
assert.equal(
JSON.stringify(second),
JSON.stringify(first),
"serialized output is byte-identical",
);
// The page fetch + subpages fetch still happen every call (only the CPU
// conversion is cached); both reads hit /pages/info and sidebar-pages.
assert.equal(state.info, 2, "both reads still fetch /pages/info");
assert.equal(state.sidebar, 2, "both reads still fetch subpages");
// Shape sanity: content present, subpages resolved.
assert.equal(typeof second.data.content, "string");
assert.ok(second.data.content.includes("Hello world"));
assert.deepEqual(second.data.subpages, [{ id: CHILD_UUID, title: "Child" }]);
});
test("a changed updatedAt is a fresh key -> MISS again, with the NEW content", async () => {
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Version one" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
const a = await client.getPage(PAGE_UUID); // miss
const b = await client.getPage(PAGE_UUID); // hit
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1);
assert.ok(a.data.content.includes("Version one"));
// The page changes: new updatedAt AND new content.
state.updatedAt = "2026-02-02T00:00:00Z";
state.text = "Version two";
const c = await client.getPage(PAGE_UUID); // miss on the new key
assert.equal(metrics["mcp_getpage_cache_misses_total"], 2, "changed version -> miss");
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "no stale hit");
assert.ok(c.data.content.includes("Version two"), "the NEW content is served");
assert.ok(!c.data.content.includes("Version one"), "no stale markdown");
const d = await client.getPage(PAGE_UUID); // hit on the new key
assert.equal(metrics["mcp_getpage_cache_hits_total"], 2, "the new snapshot caches too");
});
test("a slugId read and a UUID read of the same page share one cache entry", async () => {
// resolvePageId maps the slugId -> UUID via /pages/info; the cache keys on the
// canonical UUID (resultData.id), so both inputs land on the same entry.
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Shared" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
await client.getPage(PAGE_UUID); // miss (keyed on UUID)
await client.getPage("slug123456"); // the server returns the same id -> HIT
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "one conversion total");
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "slugId read hits the UUID entry");
});
test("on a conversion HIT, the {{SUBPAGES}} block reflects the LIVE subpages, not the cached ones", async () => {
// The whole byte-identity guarantee: the cache stores the conversion output
// BEFORE the {{SUBPAGES}} substitution, so a re-read of an UNCHANGED page still
// splices the FRESH subpage list. The page body itself contains {{SUBPAGES}}
// (converts to a literal placeholder); getPage replaces it with the live list.
const CHILD_A = "00000000-0000-4000-8000-0000000000a1";
const CHILD_B = "00000000-0000-4000-8000-0000000000b2";
const state = {
info: 0,
sidebar: 0,
updatedAt: "2026-01-01T00:00:00Z", // FIXED across both reads -> conversion cache HIT
text: "Body before {{SUBPAGES}} body after",
subpages: [{ id: CHILD_A, title: "Alpha", hasChildren: false }],
};
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
// Read 1: MISS (converts). The substitution runs with list A.
const first = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read converts (miss)");
assert.ok(first.data.content.includes("[Alpha](page:" + CHILD_A + ")"), "list A spliced in");
assert.ok(!first.data.content.includes("{{SUBPAGES}}"), "placeholder consumed");
// The subpages change while the PAGE CONTENT/updatedAt do NOT: same conversion
// cache key -> a HIT that skips the CPU walk, but the live substitution must
// still run on the NEW list B.
state.subpages = [{ id: CHILD_B, title: "Beta", hasChildren: false }];
const second = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a conversion HIT");
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "no second conversion");
// The cache did NOT freeze the subpages block: list B is present, list A gone.
assert.ok(second.data.content.includes("[Beta](page:" + CHILD_B + ")"), "live list B spliced in on a HIT");
assert.ok(!second.data.content.includes("Alpha"), "stale list A is NOT frozen into the output");
assert.deepEqual(second.data.subpages, [{ id: CHILD_B, title: "Beta" }], "subpages field reflects list B");
});