fix(server): include URL in ctx_fetch_and_index cache key

getSourceMeta(label) returned the meta from any prior fetch with the
same label, so two distinct URLs sharing a label silently returned
the cached first response instead of fetching the second.

Composes the cache key from label+url so legitimate cache hits still
work but cross-URL label reuse no longer serves stale content.
This commit is contained in:
Mert Koseoglu
2026-05-02 19:37:43 +03:00
parent 189a6e9bca
commit 1f1243e34c
3 changed files with 103 additions and 7 deletions
+15
View File
@@ -0,0 +1,15 @@
/**
* Cache-key / storage-label composition for ctx_fetch_and_index.
*
* Two distinct URLs that share a user-supplied `source` label MUST NOT collide
* in the cache (or in FTS5 storage, since indexing dedups by label). Compose
* `${source}::${url}` whenever a `source` is explicitly provided so cache
* lookup, dedup, and re-indexing are all per-(source,url). When no `source`
* is provided the URL itself is the unique key — no composition needed.
*
* `ctx_search(source: "Docs")` continues to work because LIKE-mode source
* filtering matches on the substring "Docs" inside "Docs::https://…".
*/
export function composeFetchCacheKey(source: string | undefined, url: string): string {
return source === undefined ? url : `${source}::${url}`;
}
+14 -7
View File
@@ -12,6 +12,7 @@ import { request as httpsRequest } from "node:https";
import { z } from "zod";
import { PolyglotExecutor } from "./executor.js";
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs, type SearchResult, type IndexResult } from "./store.js";
import { composeFetchCacheKey } from "./fetch-cache.js";
import {
readBashPolicies,
evaluateCommandDenyOnly,
@@ -1601,11 +1602,13 @@ server.registerTool(
}),
},
async ({ url, source, force }) => {
// TTL cache: if source was indexed within 24h, return cached hint
// TTL cache: if source was indexed within 24h, return cached hint.
// Cache key composes (source, url) so two distinct URLs sharing the same
// `source` label do not collide — they each get their own cache slot.
if (!force) {
const store = getStore();
const label = source ?? url;
const meta = store.getSourceMeta(label);
const cacheKey = composeFetchCacheKey(source, url);
const meta = store.getSourceMeta(cacheKey);
if (meta) {
const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
const ageMs = Date.now() - indexedAt.getTime();
@@ -1687,15 +1690,19 @@ server.registerTool(
trackIndexed(Buffer.byteLength(markdown));
// Route to the appropriate indexing strategy based on Content-Type
// Route to the appropriate indexing strategy based on Content-Type.
// Storage label includes URL via composeFetchCacheKey so two URLs sharing
// a `source` label do not overwrite each other; ctx_search() still finds
// both via LIKE-mode source filter on the `source` substring.
const storageLabel = composeFetchCacheKey(source, url);
let indexed: IndexResult;
if (header === "__CM_CT__:json") {
indexed = store.indexJSON(markdown, source ?? url);
indexed = store.indexJSON(markdown, storageLabel);
} else if (header === "__CM_CT__:text") {
indexed = store.indexPlainText(markdown, source ?? url);
indexed = store.indexPlainText(markdown, storageLabel);
} else {
// HTML (default) — content is already converted to markdown
indexed = store.index({ content: markdown, source: source ?? url });
indexed = store.index({ content: markdown, source: storageLabel });
}
// Build preview — first ~3KB of markdown for immediate use
+74
View File
@@ -1965,3 +1965,77 @@ describe("getSessionDir uses pre-detection when adapter not yet detected", () =>
expect(detectIdx).toBeLessThan(claudeIdx);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// ctx_fetch_and_index cache-key collision (Fix 6/10)
// ═══════════════════════════════════════════════════════════════════════════
//
// Bug: cache key was `source ?? url`, so two distinct URLs sharing a `source`
// label silently returned the cached first response instead of fetching the
// second. Fix composes the cache key from label+url for cache lookup.
describe("ctx_fetch_and_index cache key includes URL (Fix 6/10)", () => {
test("composeFetchCacheKey: same label + different URLs produce different keys", async () => {
const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
const k2 = composeFetchCacheKey("Docs", "https://y.com/b");
expect(k1).not.toBe(k2);
});
test("composeFetchCacheKey: same label + same URL → same key (legitimate cache hit)", async () => {
const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
const k2 = composeFetchCacheKey("Docs", "https://x.com/a");
expect(k1).toBe(k2);
});
test("server.ts uses composeFetchCacheKey for cache lookup (no bare-label collision)", () => {
const serverSrc = readFileSync(
resolve(__dirname, "../../src/server.ts"),
"utf-8",
);
// Locate the ctx_fetch_and_index handler block
const block = serverSrc.match(
/registerTool\(\s*"ctx_fetch_and_index"[\s\S]*?^\);/m,
);
expect(block, "ctx_fetch_and_index handler not found").not.toBeNull();
const body = block![0];
// Cache lookup must call getSourceMeta with a key composed from label+url,
// not the bare label/url. The fix uses composeFetchCacheKey().
const lookupCall = body.match(
/getSourceMeta\(\s*([^)]+)\s*\)/,
);
expect(lookupCall, "getSourceMeta call missing").not.toBeNull();
const arg = lookupCall![1];
// Must NOT be the bare `label` variable (that was the bug).
expect(arg.trim()).not.toBe("label");
// Must reference both the label and the url, ideally via composeFetchCacheKey.
expect(body).toContain("composeFetchCacheKey");
});
test("ContentStore: per-(label,url) keys do not collide on getSourceMeta", () => {
const store = new ContentStore(":memory:");
// Simulate two distinct URLs sharing a user-supplied "source" label,
// but stored under composed keys per the fix.
const URL_A = "https://example.com/a";
const URL_B = "https://example.com/b";
const labelA = `Docs::${URL_A}`;
const labelB = `Docs::${URL_B}`;
store.index({ content: "# A\nContent A unique alpha", source: labelA });
// Before fix: a second cache lookup with the bare "Docs" label would
// hit A's meta and short-circuit. After fix: lookup uses labelB → miss.
expect(store.getSourceMeta(labelB)).toBeNull();
// Cache hit for the same (label,url) still works.
expect(store.getSourceMeta(labelA)).not.toBeNull();
// Now index B and verify both remain searchable independently.
store.index({ content: "# B\nContent B unique bravo", source: labelB });
const aResults = store.search("alpha", 5, labelA);
const bResults = store.search("bravo", 5, labelB);
expect(aResults.length).toBeGreaterThan(0);
expect(bResults.length).toBeGreaterThan(0);
store.close();
});
});