Skip to content

Commit 5e1ea7f

Browse files
committed
feat(search): surface Zoekt non-exhaustive results in UI and logs (#504)
1 parent 355509b commit 5e1ea7f

6 files changed

Lines changed: 198 additions & 4 deletions

File tree

packages/web/src/app/(app)/search/components/searchResultsPage.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
} from "@/components/ui/resizable";
1212
import { Separator } from "@/components/ui/separator";
1313
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
14-
import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search";
14+
import { getSearchLimitExplanation, RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search";
15+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
1516
import useCaptureEvent from "@/hooks/useCaptureEvent";
1617
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
1718
import { useSearchHistory } from "@/hooks/useSearchHistory";
@@ -205,6 +206,7 @@ export const SearchResultsPage = ({
205206
searchStats={stats}
206207
isMoreResultsButtonVisible={!isExhaustive}
207208
isBranchFilteringEnabled={isBranchFilteringEnabled}
209+
maxMatchDisplayCount={maxMatchCount}
208210
/>
209211
)}
210212
</div>
@@ -221,6 +223,7 @@ interface PanelGroupProps {
221223
searchDurationMs: number;
222224
numMatches: number;
223225
searchStats?: SearchStats;
226+
maxMatchDisplayCount: number;
224227
}
225228

226229
const PanelGroup = ({
@@ -233,6 +236,7 @@ const PanelGroup = ({
233236
searchDurationMs: _searchDurationMs,
234237
numMatches,
235238
searchStats,
239+
maxMatchDisplayCount,
236240
}: PanelGroupProps) => {
237241
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
238242
const filteredFileMatches = useFilteredMatches(fileMatches);
@@ -258,6 +262,13 @@ const PanelGroup = ({
258262
return Math.round(_searchDurationMs);
259263
}, [_searchDurationMs]);
260264

265+
const limitExplanation = useMemo(() => {
266+
if (isStreaming || !isMoreResultsButtonVisible) {
267+
return null;
268+
}
269+
return getSearchLimitExplanation(searchStats, maxMatchDisplayCount);
270+
}, [isStreaming, isMoreResultsButtonVisible, searchStats, maxMatchDisplayCount]);
271+
261272
return (
262273
<ResizablePanelGroup
263274
direction="horizontal"
@@ -368,6 +379,19 @@ const PanelGroup = ({
368379
</>
369380
)}
370381
</div>
382+
{limitExplanation && (
383+
<div className="px-2 pb-2 shrink-0">
384+
<Alert variant="default" className="border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/40">
385+
<AlertTriangleIcon className="text-amber-700 dark:text-amber-500" />
386+
<AlertTitle className="text-amber-950 dark:text-amber-100">{limitExplanation.summary}</AlertTitle>
387+
{limitExplanation.detail && (
388+
<AlertDescription className="text-amber-900/90 dark:text-amber-200/90">
389+
{limitExplanation.detail}
390+
</AlertDescription>
391+
)}
392+
</Alert>
393+
</div>
394+
)}
371395
<div className="flex-1 min-h-0">
372396
{filteredFileMatches.length > 0 ? (
373397
<SearchResultsPanel

packages/web/src/app/(app)/search/useStreamedSearch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface CacheEntry {
1414
timeToFirstSearchResultMs: number;
1515
timestamp: number;
1616
isExhaustive: boolean;
17+
stats?: SearchStats;
1718
}
1819

1920
const searchCache = new Map<string, CacheEntry>();
@@ -101,6 +102,7 @@ export const useStreamedSearch = ({ query, matches, contextLines, whole, isRegex
101102
timeToSearchCompletionMs: cachedEntry.timeToSearchCompletionMs,
102103
timeToFirstSearchResultMs: cachedEntry.timeToFirstSearchResultMs,
103104
numMatches: cachedEntry.numMatches,
105+
stats: cachedEntry.stats,
104106
});
105107
return;
106108
}
@@ -242,6 +244,7 @@ export const useStreamedSearch = ({ query, matches, contextLines, whole, isRegex
242244
timeToFirstSearchResultMs: prev.timeToFirstSearchResultMs,
243245
timeToSearchCompletionMs,
244246
timestamp: Date.now(),
247+
stats: prev.stats,
245248
});
246249
return {
247250
...prev,

packages/web/src/features/search/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export type {
1212
StreamedSearchResponse,
1313
SearchResultChunk,
1414
SearchResponse,
15-
} from './types';
15+
} from './types';
16+
export { getSearchLimitExplanation } from './searchLimitExplanation';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect, test } from 'vitest';
2+
import type { SearchStats } from './types';
3+
import { getSearchLimitExplanation } from './searchLimitExplanation';
4+
5+
function stats(overrides: Partial<SearchStats>): SearchStats {
6+
return {
7+
actualMatchCount: 10,
8+
totalMatchCount: 10,
9+
duration: 0,
10+
fileCount: 1,
11+
filesSkipped: 0,
12+
contentBytesLoaded: 0,
13+
indexBytesLoaded: 0,
14+
crashes: 0,
15+
shardFilesConsidered: 0,
16+
filesConsidered: 0,
17+
filesLoaded: 0,
18+
shardsScanned: 1,
19+
shardsSkipped: 0,
20+
shardsSkippedFilter: 0,
21+
ngramMatches: 0,
22+
ngramLookups: 0,
23+
wait: 0,
24+
matchTreeConstruction: 0,
25+
matchTreeSearch: 0,
26+
regexpsConsidered: 0,
27+
flushReason: 'FLUSH_REASON_UNKNOWN_UNSPECIFIED',
28+
...overrides,
29+
};
30+
}
31+
32+
test('missing stats yields generic incomplete message', () => {
33+
const out = getSearchLimitExplanation(undefined, 100);
34+
expect(out.summary).toContain('incomplete');
35+
});
36+
37+
test('shardsSkipped takes precedence (time limit / partial scan)', () => {
38+
const out = getSearchLimitExplanation(
39+
stats({
40+
shardsSkipped: 2,
41+
totalMatchCount: 500,
42+
filesSkipped: 99,
43+
}),
44+
100,
45+
);
46+
expect(out.summary).toContain('did not scan the entire index');
47+
});
48+
49+
test('totalMatchCount above display cap explains match budget', () => {
50+
const out = getSearchLimitExplanation(
51+
stats({
52+
actualMatchCount: 100,
53+
totalMatchCount: 250,
54+
}),
55+
100,
56+
);
57+
expect(out.summary).toContain('More matches exist');
58+
expect(out.detail).toContain('250');
59+
});
60+
61+
test('filesSkipped without shard skip explains early stop', () => {
62+
const out = getSearchLimitExplanation(
63+
stats({
64+
totalMatchCount: 50,
65+
actualMatchCount: 50,
66+
filesSkipped: 10,
67+
}),
68+
100,
69+
);
70+
expect(out.summary).toContain('candidate files');
71+
});
72+
73+
test('flushReason timer when no higher-priority signal', () => {
74+
const out = getSearchLimitExplanation(
75+
stats({
76+
flushReason: 'FLUSH_REASON_TIMER_EXPIRED',
77+
totalMatchCount: 10,
78+
actualMatchCount: 10,
79+
}),
80+
100,
81+
);
82+
expect(out.summary).toContain('streaming timer');
83+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { SearchStats } from './types';
2+
3+
/** Values from zoekt `FlushReason` (grpc string enum names). */
4+
const FLUSH_REASON_TIMER_EXPIRED = 'FLUSH_REASON_TIMER_EXPIRED';
5+
const FLUSH_REASON_MAX_SIZE = 'FLUSH_REASON_MAX_SIZE';
6+
7+
/**
8+
* User-facing copy when Zoekt returned a non-exhaustive search (more matches may exist
9+
* than were returned or scanned).
10+
*
11+
* @see https://github.com/sourcebot-dev/sourcebot/issues/504
12+
*/
13+
export function getSearchLimitExplanation(
14+
stats: SearchStats | undefined,
15+
maxMatchDisplayCount: number,
16+
): { summary: string; detail?: string } {
17+
if (!stats) {
18+
return {
19+
summary: 'Results may be incomplete.',
20+
detail: 'Increase the match limit, narrow your query, or scope to a repository.',
21+
};
22+
}
23+
24+
if (stats.shardsSkipped > 0) {
25+
return {
26+
summary: 'Search did not scan the entire index.',
27+
detail: 'One or more index shards were skipped (often because the search hit a time limit). Additional matches may exist.',
28+
};
29+
}
30+
31+
if (stats.flushReason === FLUSH_REASON_TIMER_EXPIRED) {
32+
return {
33+
summary: 'Results were flushed early due to a streaming timer.',
34+
detail: 'Try narrowing your query or increasing limits.',
35+
};
36+
}
37+
38+
if (stats.flushReason === FLUSH_REASON_MAX_SIZE) {
39+
return {
40+
summary: 'Intermediate result set reached its size limit.',
41+
detail: 'Try narrowing your query or increasing limits.',
42+
};
43+
}
44+
45+
if (stats.totalMatchCount > maxMatchDisplayCount) {
46+
return {
47+
summary: 'More matches exist than are shown.',
48+
detail: `The index reported ${stats.totalMatchCount} matches, but this request only returns up to ${maxMatchDisplayCount}. Use “load more” or raise the match limit.`,
49+
};
50+
}
51+
52+
if (stats.filesSkipped > 0) {
53+
return {
54+
summary: 'Some candidate files were not fully searched.',
55+
detail: 'The engine stopped after finding enough matches (per-shard or total limits). Additional matches may exist.',
56+
};
57+
}
58+
59+
return {
60+
summary: 'More matches may exist than are shown.',
61+
detail: 'Increase the match limit, narrow your query, or scope to a repository.',
62+
};
63+
}

packages/web/src/features/search/zoektSearcher.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,21 @@ export const zoektSearch = async (searchRequest: ZoektGrpcSearchRequest, prisma:
133133
const reposMapCache = await createReposMapForChunk(response, new Map<string | number, Repo>(), prisma);
134134
const { stats, files, repositoryInfo } = await transformZoektSearchResponse(response, reposMapCache);
135135

136+
const isSearchExhaustive = stats.totalMatchCount <= stats.actualMatchCount;
137+
if (!isSearchExhaustive) {
138+
logger.info('Zoekt search finished with non-exhaustive results', {
139+
totalMatchCount: stats.totalMatchCount,
140+
actualMatchCount: stats.actualMatchCount,
141+
flushReason: stats.flushReason,
142+
shardsSkipped: stats.shardsSkipped,
143+
filesSkipped: stats.filesSkipped,
144+
});
145+
}
136146
resolve({
137147
stats,
138148
files,
139149
repositoryInfo,
140-
isSearchExhaustive: stats.totalMatchCount <= stats.actualMatchCount,
150+
isSearchExhaustive,
141151
} satisfies SearchResponse);
142152
} catch (err) {
143153
reject(err);
@@ -180,10 +190,20 @@ export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, p
180190
async start(controller) {
181191
const tryCloseController = () => {
182192
if (!isStreamActive && pendingChunks === 0) {
193+
const isSearchExhaustive = accumulatedStats.totalMatchCount <= accumulatedStats.actualMatchCount;
194+
if (!isSearchExhaustive) {
195+
logger.info('Zoekt search finished with non-exhaustive results', {
196+
totalMatchCount: accumulatedStats.totalMatchCount,
197+
actualMatchCount: accumulatedStats.actualMatchCount,
198+
flushReason: accumulatedStats.flushReason,
199+
shardsSkipped: accumulatedStats.shardsSkipped,
200+
filesSkipped: accumulatedStats.filesSkipped,
201+
});
202+
}
183203
const finalResponse: StreamedSearchResponse = {
184204
type: 'final',
185205
accumulatedStats,
186-
isSearchExhaustive: accumulatedStats.totalMatchCount <= accumulatedStats.actualMatchCount,
206+
isSearchExhaustive,
187207
}
188208

189209
controller.enqueue(encodeSSEREsponseChunk(finalResponse));

0 commit comments

Comments
 (0)