History

Deduplicate workspace index builds and stabilize prompt replacement

- Reuse in-flight workspace index builds per cwd and cap `readdir` scan concurrency at 32 - Add server tests for build deduplication and directory read concurrency limits - Track composer text in a ref so prompt replacements use current text after send/reset

Julius Marminge committed 8 months ago

Commit 2743e451 parent 1b409c33 files · +122 −21Browse files

Unified · Split

3 files
  1. apps/server/src/workspaceEntries.test.ts +59 −1
  2. apps/server/src/workspaceEntries.ts +51 −10
  3. apps/web/src/components/ChatView.tsx +12 −10
apps/server/src/workspaceEntries.test.ts+59−1
@@ -1,9 +1,10 @@
11 import fs from "node:fs";
2+import fsPromises from "node:fs/promises";
23 import os from "node:os";
34 import path from "node:path";
45 import { spawnSync } from "node:child_process";
56
6−import { afterEach, assert, describe, it } from "vitest";
7+import { afterEach, assert, describe, it, vi } from "vitest";
78
89 import { searchWorkspaceEntries } from "./workspaceEntries";
910
@@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { · 20 unmodified lines
3031
3132 describe("searchWorkspaceEntries", () => {
3233 afterEach(() => {
34+ vi.restoreAllMocks();
3335 for (const dir of tempDirs.splice(0, tempDirs.length)) {
3436 fs.rmSync(dir, { recursive: true, force: true });
3537 }
@@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { · 79 unmodified lines
115117 assert.include(paths, "src/keep.ts");
116118 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
117119 });
120+
121+ it("deduplicates concurrent index builds for the same cwd", async () => {
122+ const cwd = makeTempDir("t3code-workspace-concurrent-build-");
123+ writeFile(cwd, "src/components/Composer.tsx");
124+
125+ let rootReadCount = 0;
126+ const originalReaddir = fsPromises.readdir.bind(fsPromises);
127+ vi.spyOn(fsPromises, "readdir").mockImplementation(
128+ (async (...args: Parameters<typeof fsPromises.readdir>) => {
129+ if (args[0] === cwd) {
130+ rootReadCount += 1;
131+ await new Promise((resolve) => setTimeout(resolve, 20));
132+ }
133+ return originalReaddir(...args);
134+ }) as typeof fsPromises.readdir,
135+ );
136+
137+ await Promise.all([
138+ searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
139+ searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }),
140+ searchWorkspaceEntries({ cwd, query: "src", limit: 100 }),
141+ ]);
142+
143+ assert.equal(rootReadCount, 1);
144+ });
145+
146+ it("limits concurrent directory reads while walking the filesystem", async () => {
147+ const cwd = makeTempDir("t3code-workspace-read-concurrency-");
148+ for (let index = 0; index < 80; index += 1) {
149+ writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};");
150+ }
151+
152+ let activeReads = 0;
153+ let peakReads = 0;
154+ const originalReaddir = fsPromises.readdir.bind(fsPromises);
155+ vi.spyOn(fsPromises, "readdir").mockImplementation(
156+ (async (...args: Parameters<typeof fsPromises.readdir>) => {
157+ const target = args[0];
158+ if (typeof target === "string" && target.startsWith(cwd)) {
159+ activeReads += 1;
160+ peakReads = Math.max(peakReads, activeReads);
161+ await new Promise((resolve) => setTimeout(resolve, 4));
162+ try {
163+ return await originalReaddir(...args);
164+ } finally {
165+ activeReads -= 1;
166+ }
167+ }
168+ return originalReaddir(...args);
169+ }) as typeof fsPromises.readdir,
170+ );
171+
172+ await searchWorkspaceEntries({ cwd, query: "", limit: 200 });
173+
174+ assert.isAtMost(peakReads, 32);
175+ });
118176 });
apps/server/src/workspaceEntries.ts+51−10
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 const WORKSPACE_CACHE_TTL_MS = 15_000;
1414 const WORKSPACE_CACHE_MAX_KEYS = 4;
1515 const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16+const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
1617 const IGNORED_DIRECTORY_NAMES = new Set([
1718 ".git",
1819 ".convex",
@@ -32,6 +33,7 @@ interface WorkspaceIndex { · 13 unmodified lines
3233 }
3334
3435 const workspaceIndexCache = new Map<string, WorkspaceIndex>();
36+const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
3537
3638 function toPosixPath(input: string): string {
3739 return input.split(path.sep).join("/");
@@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { · 63 unmodified lines
101103 return directories;
102104 }
103105
106+async function mapWithConcurrency<TInput, TOutput>(
107+ items: readonly TInput[],
108+ concurrency: number,
109+ mapper: (item: TInput, index: number) => Promise<TOutput>,
110+): Promise<TOutput[]> {
111+ if (items.length === 0) {
112+ return [];
113+ }
114+
115+ const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length));
116+ const results = Array.from({ length: items.length }) as TOutput[];
117+ let nextIndex = 0;
118+
119+ const workers = Array.from({ length: boundedConcurrency }, async () => {
120+ while (nextIndex < items.length) {
121+ const currentIndex = nextIndex;
122+ nextIndex += 1;
123+ results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex);
124+ }
125+ });
126+
127+ await Promise.all(workers);
128+ return results;
129+}
130+
104131 async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
105132 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
106133 cwd,
@@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 113 unmodified lines
220247 while (pendingDirectories.length > 0 && !truncated) {
221248 const currentDirectories = pendingDirectories;
222249 pendingDirectories = [];
223− const directoryEntries = await Promise.all(
224− currentDirectories.map(async (relativeDir) => {
250+ const directoryEntries = await mapWithConcurrency(
251+ currentDirectories,
252+ WORKSPACE_SCAN_READDIR_CONCURRENCY,
253+ async (relativeDir) => {
225254 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
226255 try {
227256 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
@@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 7 unmodified lines
235264 }
236265 return { relativeDir, dirents: null };
237266 }
238− }),
267+ },
239268 );
240269
241270 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
@@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 73 unmodified lines
315344 return cached;
316345 }
317346
318− const next = await buildWorkspaceIndex(cwd);
319− workspaceIndexCache.set(cwd, next);
320− while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
321− const oldestKey = workspaceIndexCache.keys().next().value;
322− if (!oldestKey) break;
323− workspaceIndexCache.delete(oldestKey);
347+ const inFlight = inFlightWorkspaceIndexBuilds.get(cwd);
348+ if (inFlight) {
349+ return inFlight;
324350 }
325− return next;
351+
352+ const nextPromise = buildWorkspaceIndex(cwd)
353+ .then((next) => {
354+ workspaceIndexCache.set(cwd, next);
355+ while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
356+ const oldestKey = workspaceIndexCache.keys().next().value;
357+ if (!oldestKey) break;
358+ workspaceIndexCache.delete(oldestKey);
359+ }
360+ return next;
361+ })
362+ .finally(() => {
363+ inFlightWorkspaceIndexBuilds.delete(cwd);
364+ });
365+ inFlightWorkspaceIndexBuilds.set(cwd, nextPromise);
366+ return nextPromise;
326367 }
327368
328369 export async function searchWorkspaceEntries(
apps/web/src/components/ChatView.tsx+12−10
@@ -329,6 +329,7 @@ export default function ChatView() { · 328 unmodified lines
329329 gitCreateWorktreeMutationOptions({ api, queryClient }),
330330 );
331331 const [prompt, setPrompt] = useState("");
332+ const promptRef = useRef(prompt);
332333 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
333334 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
334335 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
@@ -1126,6 +1127,7 @@ export default function ChatView() { · 791 unmodified lines
11261127 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
11271128 });
11281129 const previousMessages = activeThread.messages;
1130+ promptRef.current = "";
11291131 setPrompt("");
11301132 setComposerImages([]);
11311133 setComposerCursor(0);
@@ -1238,16 +1240,15 @@ export default function ChatView() { · 106 unmodified lines
12381240
12391241 const applyPromptReplacement = useCallback(
12401242 (rangeStart: number, rangeEnd: number, replacement: string) => {
1241− setPrompt((existing) => {
1242− const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement);
1243− window.requestAnimationFrame(() => {
1244− const textarea = textareaRef.current;
1245− if (!textarea) return;
1246− textarea.focus();
1247− textarea.setSelectionRange(next.cursor, next.cursor);
1248− setComposerCursor(next.cursor);
1249− });
1250− return next.text;
1243+ const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1244+ promptRef.current = next.text;
1245+ setPrompt(next.text);
1246+ window.requestAnimationFrame(() => {
1247+ const textarea = textareaRef.current;
1248+ if (!textarea) return;
1249+ textarea.focus();
1250+ textarea.setSelectionRange(next.cursor, next.cursor);
1251+ setComposerCursor(next.cursor);
12511252 });
12521253 },
12531254 [],
@@ -1284,6 +1285,7 @@ export default function ChatView() { · 30 unmodified lines
12841285 workspaceEntriesQuery.isFetching);
12851286
12861287 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1288+ promptRef.current = nextPrompt;
12871289 setPrompt(nextPrompt);
12881290 setComposerCursor(nextCursor);
12891291 }, []);