Skip to content

Commit c1129c7

Browse files
authored
fix: reduce cold server detail reads (#202)
1 parent 8a1d9d4 commit c1129c7

3 files changed

Lines changed: 136 additions & 24 deletions

File tree

apps/web/lib/queries-reliability.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
type Result = { data: unknown; count?: number; error?: { message: string; code?: string } | null };
44
const state = vi.hoisted(() => ({
@@ -36,11 +36,16 @@ vi.mock('./supabase', () => ({ supabase: {
3636
import { getServerBySlug, listServers, getServersSitemapPage, __resetReadmeLengthProbe } from './queries';
3737
const outage: Result = { data: null, error: { message: 'timeout', code: '57014' } };
3838
const server = { id: 'one', slug: 'one', canonical_slug: 'one', registry_status: 'active' };
39+
let errorLog: ReturnType<typeof vi.spyOn>;
40+
let warnLog: ReturnType<typeof vi.spyOn>;
3941

4042
beforeEach(() => {
4143
state.results = []; state.signals = []; state.reads = 0; state.cache.clear();
4244
__resetReadmeLengthProbe();
45+
errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined);
46+
warnLog = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
4347
});
48+
afterEach(() => vi.restoreAllMocks());
4449

4550
describe('directory failures do not become persistent content', () => {
4651
it('fails a canonical lookup promptly without hiding its error behind legacy lookup, then recovers', async () => {
@@ -58,6 +63,24 @@ describe('directory failures do not become persistent content', () => {
5863
expect(state.signals).toHaveLength(3);
5964
expect(new Set(state.signals).size).toBe(1);
6065
});
66+
it('skips the tools read only when both authoritative fields prove there are none', async () => {
67+
const noTools = { ...server, has_tools: false, tool_count: 0 };
68+
state.results.push({ data: noTools });
69+
expect(await getServerBySlug('no-tools')).toMatchObject({ ...noTools, tools: [] });
70+
expect(state.reads).toBe(1);
71+
expect(await getServerBySlug('no-tools')).toMatchObject({ tools: [] });
72+
expect(state.reads).toBe(1);
73+
});
74+
it.each([
75+
{ has_tools: true, tool_count: 0 },
76+
{ has_tools: false, tool_count: 1 },
77+
{ has_tools: undefined, tool_count: undefined },
78+
])('queries tools when tool state is positive or unknown: %o', async toolState => {
79+
const slug = `state-${String(toolState.has_tools)}-${String(toolState.tool_count)}`;
80+
state.results.push({ data: { ...server, ...toolState } }, { data: [{ id: 1 }] });
81+
expect((await getServerBySlug(slug))?.tools).toHaveLength(1);
82+
expect(state.reads).toBe(2);
83+
});
6184
it('only treats two successful absent lookups as missing', async () => {
6285
state.results.push({ data: null }, outage);
6386
await expect(getServerBySlug('missing')).rejects.toThrow();
@@ -67,9 +90,33 @@ describe('directory failures do not become persistent content', () => {
6790
it('does not cache lost tool documentation as an empty tools list', async () => {
6891
state.results.push({ data: server }, outage);
6992
await expect(getServerBySlug('one')).rejects.toThrow();
93+
expect(errorLog).toHaveBeenCalledWith(
94+
'[queries] server detail query failed',
95+
expect.objectContaining({ event: 'server_detail_query_error', stage: 'tools' })
96+
);
7097
state.results.push({ data: server }, { data: [{ name: 'query' }] });
7198
expect((await getServerBySlug('one'))?.tools).toHaveLength(1);
7299
});
100+
it('logs a structured failing stage without upstream messages or identifiers', async () => {
101+
state.results.push({ data: null, error: { message: 'secret upstream URL', code: '57014' } });
102+
await expect(getServerBySlug('private-request-slug')).rejects.toThrow('temporarily unavailable');
103+
expect(errorLog).toHaveBeenCalledWith(
104+
'[queries] server detail query failed',
105+
expect.objectContaining({
106+
event: 'server_detail_query_error', stage: 'canonical', error_code: '57014',
107+
})
108+
);
109+
expect(JSON.stringify(errorLog.mock.calls)).not.toContain('secret upstream URL');
110+
expect(JSON.stringify(errorLog.mock.calls)).not.toContain('private-request-slug');
111+
});
112+
it('logs a structured slow stage with duration', async () => {
113+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(1001);
114+
state.results.push({ data: { ...server, has_tools: false, tool_count: 0 } });
115+
await getServerBySlug('slow');
116+
expect(warnLog).toHaveBeenCalledWith('[queries] server detail query slow', {
117+
event: 'server_detail_query_slow', stage: 'canonical', duration_ms: 1001,
118+
});
119+
});
73120
it('does not cache listing with a failed count as a successful zero result', async () => {
74121
state.results.push(outage, { data: [server] });
75122
await expect(listServers({})).rejects.toThrow();

apps/web/lib/queries.ts

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,54 @@ import { normalizeListParams } from './filter-utils';
1212

1313
// All sequential detail reads share one deadline; leave room for rendering under 15s.
1414
const QUERY_TIMEOUT_MS = 6000;
15+
const DETAIL_QUERY_SLOW_MS = 1000;
16+
17+
type DetailQueryStage = 'canonical' | 'slug_fallback' | 'tools';
18+
type ObservedQueryResult = { error?: { code?: string; message?: string } | null };
19+
20+
/**
21+
* Records only operational metadata. Deliberately excludes the requested slug,
22+
* query URL, response data and upstream error message so logs cannot capture
23+
* credentials, README content, or identifiers from request paths.
24+
*/
25+
async function observeDetailQuery<T extends ObservedQueryResult>(
26+
stage: DetailQueryStage,
27+
signal: AbortSignal,
28+
query: PromiseLike<T>
29+
): Promise<T> {
30+
const startedAt = performance.now();
31+
try {
32+
const result = await query;
33+
const durationMs = Math.round(performance.now() - startedAt);
34+
if (result.error) {
35+
console.error('[queries] server detail query failed', {
36+
event: 'server_detail_query_error',
37+
stage,
38+
duration_ms: durationMs,
39+
error_code: result.error.code ?? 'unknown',
40+
aborted: signal.aborted,
41+
});
42+
} else if (durationMs >= DETAIL_QUERY_SLOW_MS) {
43+
console.warn('[queries] server detail query slow', {
44+
event: 'server_detail_query_slow',
45+
stage,
46+
duration_ms: durationMs,
47+
});
48+
}
49+
return result;
50+
} catch (error) {
51+
const durationMs = Math.round(performance.now() - startedAt);
52+
console.error('[queries] server detail query failed', {
53+
event: 'server_detail_query_error',
54+
stage,
55+
duration_ms: durationMs,
56+
error_code: error instanceof Error ? error.name : 'unknown',
57+
aborted: signal.aborted,
58+
});
59+
throw error;
60+
}
61+
}
62+
1563
function assertAvailable(error: { message?: string } | null | undefined): void {
1664
if (error) throw new Error('Directory temporarily unavailable');
1765
}
@@ -192,42 +240,58 @@ async function _getServerBySlug(slug: string): Promise<ServerWithTools | null> {
192240
// Try canonical_slug first (populated after migration 005 runs).
193241
// If no match, fall back to the mutable slug column (pre-migration or community servers).
194242
// The same signal covers all lookups, including the legacy fallback.
195-
let { data: server, error } = await supabase
196-
.from('servers')
197-
.select(SERVER_DETAIL_COLUMNS)
198-
.eq('canonical_slug', slug)
199-
.abortSignal(signal)
200-
.maybeSingle();
243+
let { data: server, error } = await observeDetailQuery(
244+
'canonical',
245+
signal,
246+
supabase
247+
.from('servers')
248+
.select(SERVER_DETAIL_COLUMNS)
249+
.eq('canonical_slug', slug)
250+
.abortSignal(signal)
251+
.maybeSingle()
252+
);
201253

202254
assertAvailable(error);
203255
if (!server) {
204256
// Defensive fallback: resolve by the mutable slug column.
205257
// This path is hit before migration 005 is applied, or for rows where
206258
// canonical_slug has not yet been backfilled.
207-
const result = await supabase
208-
.from('servers')
209-
.select(SERVER_DETAIL_COLUMNS)
210-
.eq('slug', slug)
211-
.abortSignal(signal)
212-
.maybeSingle();
259+
const result = await observeDetailQuery(
260+
'slug_fallback',
261+
signal,
262+
supabase
263+
.from('servers')
264+
.select(SERVER_DETAIL_COLUMNS)
265+
.eq('slug', slug)
266+
.abortSignal(signal)
267+
.maybeSingle()
268+
);
213269
server = result.data;
214270
error = result.error;
215271
}
216272

217273
assertAvailable(error);
218274
if (!server) return null;
219275

220-
// Skip the tools fetch for deprecated rows — the page will call notFound() immediately,
221-
// so the tools data is never used. Return early with an empty tools array.
222-
if (server.registry_status === 'deprecated') {
276+
// Skip the tools fetch when authoritative row fields prove it cannot return
277+
// anything. The exact comparisons are deliberate: legacy/unknown states
278+
// still query server_tools and therefore cannot hide tool documentation.
279+
if (
280+
server.registry_status === 'deprecated' ||
281+
(server.has_tools === false && server.tool_count === 0)
282+
) {
223283
return { ...server, tools: [] } as ServerWithTools;
224284
}
225285

226-
const { data: tools, error: toolsError } = await supabase
227-
.from('server_tools')
228-
.select('*')
229-
.eq('server_id', server.id)
230-
.abortSignal(signal);
286+
const { data: tools, error: toolsError } = await observeDetailQuery(
287+
'tools',
288+
signal,
289+
supabase
290+
.from('server_tools')
291+
.select('*')
292+
.eq('server_id', server.id)
293+
.abortSignal(signal)
294+
);
231295

232296
assertAvailable(toolsError);
233297
return { ...server, tools: tools || [] } as ServerWithTools;

supabase/migrations/010_readme_length_generated_column.sql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@
5555
--
5656
-- APPLYING
5757
-- --------
58-
-- NOT APPLIED as of this commit. Adding a STORED generated column rewrites
59-
-- the table, which will detoast and re-read every README once — a one-time
60-
-- cost, but a real one on the current instance. Apply it in a quiet window.
58+
-- Applied to the production mcp-find project on 2026-09-11 through the linked
59+
-- Supabase CLI. The pre-apply JS signal count and post-apply generated-column
60+
-- count both returned 1,429 active rows; the boundary parity check inspected
61+
-- 3 rows and found 0 eligibility disagreements. IF NOT EXISTS keeps reruns safe.
6162
--
6263
-- Deploy order is NOT load-bearing: apps/web/lib/queries.ts probes for this
6364
-- column and falls back to selecting readme_content (the old behaviour, and

0 commit comments

Comments
 (0)