Compare commits

...

2 Commits

Author SHA1 Message Date
agent_coder 70a9e2a9cb fix(mcp): оживить счётчики кэша getPage + тест «хит пропускает конверсию» (#479, ревью)
Правки по ревью #480.

F1 (мёртвые метрики): счётчики mcp_getpage_cache_hits_total/_misses_total
эмитились через onMetricFn, но серверный синк mcp.service.ts диспатчил по имени
через if/else-if БЕЗ default и знал только 2 имени → мои дропались молча; в
metrics.registry их вообще не было. Починка по существующему 3-частному паттерну
(как collab_connect_timeouts_total): имена-константы в metrics.constants.ts; два
Counter'а + incGetPageCacheHit/Miss в metrics.registry.ts; два else-if в
mcp.service.ts, роутящие ровно эти имена (существующие 2 ветки не тронуты).
Проверено end-to-end: скрейп prom-реестра показывает hits=2/misses=1 после
прогона роутинга.

F2 (нет теста на пропуск конверсии): добавлен overridable seam
convertPageMarkdown в read.ts (идиома проекта для юнит-тестируемости ESM-импортов);
getPage miss-ветка идёт через него. Тест мокает convertPageMarkdown и ассертит
callCount===1 через MISS→HIT одной страницы (конверсия один раз на промахе, ноль
на хите). Мутационно доказан: пропатчил hit-ветку на повторную конверсию → тест
покраснел (callCount=2), откатил → зелёный.

mcp node --test 814/814 (+1); pmd+mcp tsc чисто; серверные metrics-файлы
компилируются изолированно, runtime-тест счётчиков зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:59:47 +03:00
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
9 changed files with 632 additions and 7 deletions
@@ -34,6 +34,8 @@ import {
isMetricsEnabled,
observeMcpTool,
incConnectTimeout,
incGetPageCacheHit,
incGetPageCacheMiss,
} from '../metrics/metrics.registry';
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
@@ -357,6 +359,10 @@ export class McpService implements OnModuleDestroy {
observeMcpTool(labels?.tool ?? 'other', value);
} else if (name === 'collab_connect_timeouts_total') {
incConnectTimeout();
} else if (name === 'mcp_getpage_cache_hits_total') {
incGetPageCacheHit();
} else if (name === 'mcp_getpage_cache_misses_total') {
incGetPageCacheMiss();
}
}
: undefined,
@@ -25,6 +25,15 @@ export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
// #479 — getPage PM→Markdown conversion cache hit/miss counters. Emitted by the
// MCP package via its dependency-neutral onMetric sink and routed onto these two
// prom counters by the mcp.service onMetric callback; a >50% hit-rate is the
// success signal for the getPage perf work. Same "do not rename" contract.
export const METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL =
'mcp_getpage_cache_hits_total';
export const METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL =
'mcp_getpage_cache_misses_total';
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
// for typical web/DB latencies without exploding series cardinality.
export const HTTP_BUCKETS = [
@@ -24,6 +24,8 @@ import {
METRIC_DB_QUERY_DURATION,
METRIC_HTTP_REQUEST_DURATION,
METRIC_MCP_TOOL_DURATION,
METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
sizeBucket,
} from './metrics.constants';
@@ -61,6 +63,9 @@ let connectTimeoutsCounter: Counter | null = null;
let collabConnectHist: Histogram | null = null;
let collabAuthHist: Histogram | null = null;
let mcpToolHist: Histogram<'tool'> | null = null;
// #479 — getPage conversion-cache hit/miss counters.
let getPageCacheHitsCounter: Counter | null = null;
let getPageCacheMissesCounter: Counter | null = null;
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
@@ -175,6 +180,18 @@ function init(): void {
buckets: MCP_TOOL_BUCKETS,
registers: [registry],
});
getPageCacheHitsCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
help: 'Total getPage PM→Markdown conversions served from the cache (skipped)',
registers: [registry],
});
getPageCacheMissesCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
help: 'Total getPage PM→Markdown conversions computed (cache misses)',
registers: [registry],
});
}
// Runs once when this module is first imported. Safe to call again (idempotent).
@@ -247,6 +264,14 @@ export function observeCollabAuth(seconds: number): void {
collabAuthHist?.observe(seconds);
}
export function incGetPageCacheHit(): void {
getPageCacheHitsCounter?.inc();
}
export function incGetPageCacheMiss(): void {
getPageCacheMissesCounter?.inc();
}
export function observeMcpTool(tool: string, seconds: number): void {
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
// guarantees it comes from the registered-tool set) — never free-form input —
@@ -10,6 +10,8 @@ import {
incConnectTimeout,
incDocLoad,
incDocUnload,
incGetPageCacheHit,
incGetPageCacheMiss,
isMetricsEnabled,
observeCollabAuth,
observeCollabConnect,
@@ -197,6 +199,8 @@ describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
incDocLoad();
incDocUnload();
incConnectTimeout();
incGetPageCacheHit();
incGetPageCacheMiss();
// Registering a source must not create the gauge or invoke the fn.
registerDocsOpenSource(() => {
throw new Error('docsOpenSource must NOT be called when disabled');
+8
View File
@@ -23,6 +23,7 @@ import { acquireCollabSession } from "../lib/collab-session.js";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
import { formatDocmostAxiosError } from "./errors.js";
import { GetPageConversionCache } from "./getpage-cache.js";
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
@@ -159,6 +160,13 @@ export abstract class DocmostClientContext {
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
// Content-addressed conversion cache for getPage (issue #479). Keyed on
// (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a
// re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown
// tree walk. Per-instance (a DocmostClient is built per user / per chat), so a
// cached conversion can never leak across identities. See getpage-cache.ts.
protected getPageCache = new GetPageConversionCache();
// Two construction forms:
// - new DocmostClient(config) // discriminated union (current)
// - new DocmostClient(baseURL, email, password) // legacy positional creds
+147
View File
@@ -0,0 +1,147 @@
// Content-addressed LRU cache for the PM->Markdown conversion in getPage
// (issue #479). getPage is the dominant agent op (812 calls / 2h, p95 840ms);
// the bulk of its cost is convertProseMirrorToMarkdown — a full ProseMirror-tree
// walk over the page content (hundreds of KB of JSON on large pages) run on
// EVERY read. Since agents re-read far more than they write (812 reads vs 28
// writes in the sample), most conversions re-produce the SAME markdown from
// UNCHANGED content. This cache skips the recomputation on a hit.
//
// KEY = (pageId, updatedAt, optionsHash):
// - pageId: the page's CANONICAL UUID (resultData.id), not the agent-supplied
// slugId — so a slugId read and a UUID read of the same page share one entry.
// - updatedAt: comes from the SAME /pages/info response as `content`, so the
// two are mutually consistent; a changed page yields a new updatedAt -> a new
// key -> automatic, precise invalidation (no stale markdown is ever served).
// - optionsHash: a stable hash of the conversion options. getPage passes
// `{dropResolvedCommentAnchors:true}` while exportPageMarkdown passes the
// defaults (#328) — DIFFERENT output for the same content, so the options
// MUST be part of the key or a hit would serve the wrong variant.
//
// BOUNDS: evict the LEAST-recently-used entry when EITHER the entry count OR the
// total stored bytes would exceed its cap. Large pages are hundreds of KB, so a
// byte cap (not just a count cap) is what actually bounds memory. A Map iterates
// in insertion order, so the first key is the LRU entry; a hit re-inserts its key
// to move it to the most-recently-used end.
//
// This module is dependency-neutral (no axios/client/prom-client): a plain class
// the shared client context owns one instance of, so the cache persists across
// getPage calls on a single DocmostClient instance (built per user / per chat).
/** A stable, order-insensitive hash of the conversion options object. */
export function hashConvertOptions(options: unknown): string {
// JSON.stringify with SORTED keys makes the hash independent of key order, so
// {a:1,b:2} and {b:2,a:1} collapse to one entry. undefined/null options -> a
// fixed empty-object key, matching a caller that passes no options at all.
if (options === undefined || options === null) return "{}";
return stableStringify(options);
}
function stableStringify(value: any): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
const keys = Object.keys(value).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
.join(",")}}`;
}
interface CacheEntry {
markdown: string;
bytes: number;
}
export interface GetPageCacheOptions {
/** Max number of entries before LRU eviction. Default 50. */
maxEntries?: number;
/** Max total stored bytes before LRU eviction. Default 10 MB. */
maxBytes?: number;
}
export class GetPageConversionCache {
private readonly maxEntries: number;
private readonly maxBytes: number;
// Insertion-ordered: the FIRST key is the least-recently-used entry.
private readonly map = new Map<string, CacheEntry>();
private totalBytes = 0;
constructor(opts: GetPageCacheOptions = {}) {
// A non-positive/NaN cap is treated as "use the default", never as an
// unbounded (or always-empty) cache — a silently unbounded cache would leak
// memory, and an always-empty one would defeat the whole optimization.
this.maxEntries =
Number.isFinite(opts.maxEntries) && (opts.maxEntries as number) > 0
? Math.floor(opts.maxEntries as number)
: 50;
this.maxBytes =
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
? Math.floor(opts.maxBytes as number)
: 10 * 1024 * 1024;
}
/** Compose the content-addressed key from its three parts. */
static key(pageId: string, updatedAt: string, optionsHash: string): string {
// A space separates the parts so no combination of values can collide by
// concatenation: a canonical UUID and an ISO updatedAt never contain a
// space, so the boundaries between the three parts are unambiguous.
return `${pageId} ${updatedAt} ${optionsHash}`;
}
/**
* Return the cached markdown for `key`, or undefined on a miss. A hit moves
* the entry to the most-recently-used end (delete + re-set) so the LRU order
* reflects real access, not just insertion.
*/
get(key: string): string | undefined {
const entry = this.map.get(key);
if (entry === undefined) return undefined;
this.map.delete(key);
this.map.set(key, entry);
return entry.markdown;
}
/**
* Store `markdown` under `key`, then evict LRU entries until BOTH caps hold.
* Re-storing an existing key refreshes its value and recency (its old bytes
* are subtracted first, so totalBytes stays exact).
*/
set(key: string, markdown: string): void {
// Byte size of the stored string (UTF-8). A single entry larger than the
// whole byte cap is still stored (so getPage always gets a hit next time),
// then the eviction loop below simply cannot shrink below it — accepted:
// one oversized page is bounded by the page itself, not a cache leak.
const bytes = Buffer.byteLength(markdown, "utf8");
const existing = this.map.get(key);
if (existing !== undefined) {
this.totalBytes -= existing.bytes;
this.map.delete(key);
}
this.map.set(key, { markdown, bytes });
this.totalBytes += bytes;
this.evict();
}
/** Evict the LRU entry until both the count and byte caps are satisfied. */
private evict(): void {
while (
this.map.size > this.maxEntries ||
(this.totalBytes > this.maxBytes && this.map.size > 1)
) {
// The first key in insertion order is the least-recently-used.
const oldest = this.map.keys().next().value as string | undefined;
if (oldest === undefined) break;
const entry = this.map.get(oldest);
this.map.delete(oldest);
if (entry) this.totalBytes -= entry.bytes;
}
}
/** Current entry count (test/introspection). */
get size(): number {
return this.map.size;
}
/** Current total stored bytes (test/introspection). */
get bytes(): number {
return this.totalBytes;
}
}
+69 -7
View File
@@ -10,7 +10,14 @@ import {
filterComment,
filterSearchResult,
} from "../lib/filters.js";
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
import {
convertProseMirrorToMarkdown,
type ConvertProseMirrorToMarkdownOptions,
} from "../lib/markdown-converter.js";
import {
GetPageConversionCache,
hashConvertOptions,
} from "./getpage-cache.js";
import {
collectInternalFileNodes,
normalizeFileUrl,
@@ -395,6 +402,19 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
/** Raw page info including the ProseMirror JSON content and slugId. */
/**
* Overridable seam over convertProseMirrorToMarkdown (issue #479). Production
* just delegates; it exists as a method so a unit test can spy on it and
* assert the conversion is genuinely SKIPPED on a getPage cache HIT (the whole
* point of the cache) — an ESM named import cannot be intercepted otherwise.
*/
protected convertPageMarkdown(
content: any,
options: ConvertProseMirrorToMarkdownOptions,
): string {
return convertProseMirrorToMarkdown(content, options);
}
async getPage(pageId: string) {
await this.ensureAuthenticated();
const resultData = await this.getPageRaw(pageId);
@@ -403,13 +423,55 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
// round-trip deliberately does NOT pass this flag — resolved anchors there
// must be preserved.)
let content = resultData.content
? convertProseMirrorToMarkdown(resultData.content, {
dropResolvedCommentAnchors: true,
})
: "";
//
// Content-addressed conversion cache (issue #479): the PM->Markdown walk is
// the dominant cost of this hot read op. Key on the page's canonical UUID +
// updatedAt (both from THIS /pages/info response, so mutually consistent) +
// a hash of the conversion options. A hit returns the cached markdown and
// skips the walk; a miss converts and stores. The cached value is the
// conversion output BEFORE the {{SUBPAGES}} substitution below, which uses
// live subpage data and stays outside the cache — so the final result is
// byte-identical to the uncached path.
const convertOptions = { dropResolvedCommentAnchors: true };
let content = "";
if (resultData.content) {
// Only cache when we have a stable identity+version for the key. Both come
// from the same response; if either is missing (unexpected server shape),
// fall back to converting uncached rather than keying on a partial tuple.
const cacheable =
typeof resultData.id === "string" &&
typeof resultData.updatedAt === "string";
const cacheKey = cacheable
? GetPageConversionCache.key(
resultData.id,
resultData.updatedAt,
hashConvertOptions(convertOptions),
)
: null;
// Always fetch subpages to provide context to the agent
const cached = cacheKey ? this.getPageCache.get(cacheKey) : undefined;
if (cached !== undefined) {
content = cached;
this.onMetricFn?.("mcp_getpage_cache_hits_total", 1);
} else {
// Goes through the convertPageMarkdown seam (not the raw import) so a
// test can assert the conversion is SKIPPED on a hit (issue #479 F2).
content = this.convertPageMarkdown(resultData.content, convertOptions);
if (cacheKey) this.getPageCache.set(cacheKey, content);
// A non-cacheable page (missing id/updatedAt) is still a genuine
// conversion, so it counts as a miss for an honest hit-rate.
this.onMetricFn?.("mcp_getpage_cache_misses_total", 1);
}
}
// Always fetch subpages to provide context to the agent.
//
// NOT parallelizable with the page fetch (issue #479 asked to check): the
// sidebar-pages endpoint REQUIRES spaceId in its POST body, and spaceId is
// only known FROM this page fetch's response (resolvePageId yields the UUID
// but never the spaceId). So `Promise.all([pageFetch, subpagesFetch])` would
// have to invent a spaceId it does not have — the two calls are inherently
// sequential. Correctness wins; the conversion cache above is the real speedup.
let subpages: any[] = [];
try {
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
@@ -0,0 +1,251 @@
// 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, mock } 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");
});
test("a cache HIT SKIPS the convertProseMirrorToMarkdown CPU walk (called once across MISS+HIT)", async () => {
// The single reason the cache exists: on a hit the expensive PM-tree walk must
// NOT run. The miss counter alone can't prove this — a broken hit branch that
// re-converted (same output, misses=1) would leave every other assert green.
// So spy directly on the conversion seam and assert the call COUNT.
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Body text" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
// Spy on the seam that wraps convertProseMirrorToMarkdown; it still delegates,
// so output stays real and byte-identical — we only count invocations.
const spy = mock.method(client, "convertPageMarkdown");
await client.getPage(PAGE_UUID); // MISS -> converts once
assert.equal(spy.mock.callCount(), 1, "the miss converts exactly once");
await client.getPage(PAGE_UUID); // HIT -> must NOT convert again
assert.equal(
spy.mock.callCount(),
1,
"the hit skips the conversion: still exactly one call across MISS+HIT",
);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "and it was recorded as a hit");
spy.mock.restore();
});
@@ -0,0 +1,113 @@
// Unit tests for the getPage content-addressed conversion cache (issue #479).
// Exercises the GetPageConversionCache class in isolation: key composition,
// hit/miss, LRU recency on read, and eviction by BOTH the count cap and the
// byte cap. The getPage integration (counter emission, byte-identical output)
// is covered separately in test/mock/getpage-conversion-cache.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
GetPageConversionCache,
hashConvertOptions,
} from "../../build/client/getpage-cache.js";
test("key: identical (pageId, updatedAt, optionsHash) collapse to one entry", () => {
const c = new GetPageConversionCache();
const k1 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
const k2 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
assert.equal(k1, k2, "same parts -> same key");
c.set(k1, "MD-A");
assert.equal(c.get(k2), "MD-A", "hit on the identical key");
assert.equal(c.size, 1);
});
test("miss after updatedAt changes (precise invalidation)", () => {
const c = new GetPageConversionCache();
const oldK = GetPageConversionCache.key("uuid-1", "v1", "{}");
const newK = GetPageConversionCache.key("uuid-1", "v2", "{}");
c.set(oldK, "OLD-MD");
assert.equal(c.get(newK), undefined, "a new updatedAt is a fresh key -> miss");
assert.equal(c.get(oldK), "OLD-MD", "the old snapshot is still addressable");
});
test("miss after options change (dropResolvedCommentAnchors true vs false)", () => {
const c = new GetPageConversionCache();
const hDrop = hashConvertOptions({ dropResolvedCommentAnchors: true });
const hKeep = hashConvertOptions({ dropResolvedCommentAnchors: false });
assert.notEqual(hDrop, hKeep, "different options hash to different keys");
const kDrop = GetPageConversionCache.key("uuid-1", "v1", hDrop);
const kKeep = GetPageConversionCache.key("uuid-1", "v1", hKeep);
c.set(kDrop, "AGENT-MD");
assert.equal(
c.get(kKeep),
undefined,
"the export variant must NOT be served the agent variant",
);
});
test("hashConvertOptions is order-insensitive and handles empty", () => {
assert.equal(
hashConvertOptions({ a: 1, b: 2 }),
hashConvertOptions({ b: 2, a: 1 }),
"key order does not change the hash",
);
assert.equal(hashConvertOptions(undefined), "{}");
assert.equal(hashConvertOptions(null), "{}");
assert.equal(hashConvertOptions({}), "{}");
});
test("LRU eviction by COUNT cap evicts the least-recently-used entry", () => {
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "a");
c.set("k2", "b");
c.set("k3", "c"); // over the count cap -> evict k1 (oldest)
assert.equal(c.size, 2);
assert.equal(c.get("k1"), undefined, "k1 evicted");
assert.equal(c.get("k2"), "b");
assert.equal(c.get("k3"), "c");
});
test("a read refreshes recency so the OTHER entry is evicted next", () => {
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "a");
c.set("k2", "b");
// Touch k1 so k2 becomes the least-recently-used.
assert.equal(c.get("k1"), "a");
c.set("k3", "c"); // evicts the LRU, which is now k2 (not k1)
assert.equal(c.get("k2"), undefined, "k2 was LRU and got evicted");
assert.equal(c.get("k1"), "a", "k1 survived because it was read");
assert.equal(c.get("k3"), "c");
});
test("LRU eviction by BYTE cap evicts oldest until under the cap", () => {
// Byte cap of 100; each value is 40 bytes -> at most 2 fit (80 < 100 < 120).
const big = "x".repeat(40);
const c = new GetPageConversionCache({ maxEntries: 50, maxBytes: 100 });
c.set("k1", big); // 40
c.set("k2", big); // 80
assert.equal(c.size, 2);
c.set("k3", big); // 120 > 100 -> evict k1 -> 80
assert.equal(c.size, 2, "byte cap forced an eviction despite the count cap");
assert.equal(c.get("k1"), undefined, "oldest evicted by bytes");
assert.equal(c.get("k2"), big);
assert.equal(c.get("k3"), big);
assert.ok(c.bytes <= 100, "total bytes stays within the cap");
});
test("re-setting an existing key updates value+recency and keeps bytes exact", () => {
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "short");
const b1 = c.bytes;
assert.equal(b1, Buffer.byteLength("short", "utf8"));
c.set("k1", "a much longer value");
assert.equal(c.size, 1, "no duplicate entry");
assert.equal(c.bytes, Buffer.byteLength("a much longer value", "utf8"));
assert.equal(c.get("k1"), "a much longer value");
});
test("an oversized single entry is still stored (never a permanent miss)", () => {
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 });
const big = "y".repeat(1000);
c.set("k1", big);
assert.equal(c.get("k1"), big, "the page bigger than the cap is still served");
assert.equal(c.size, 1);
});