| author | |
| committer | |
| log | 2743e45dbed2895e47aed18bbabb36e8dae54247 |
| tree | e6637b6e825a2aa48fecf040eec5e4681fcb9b8f |
| parent | 1b409c39468298a2a0c01d988fc2d90340877f13 |
- 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/reset3 files changed, 122 insertions(+), 21 deletions(-)
apps/server/src/workspaceEntries.test.ts+59-1| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import fs from "node:fs"; | 1 | import fs from "node:fs"; |
| 2 | import fsPromises from "node:fs/promises"; | ||
| 2 | import os from "node:os"; | 3 | import os from "node:os"; |
| 3 | import path from "node:path"; | 4 | import path from "node:path"; |
| 4 | import { spawnSync } from "node:child_process"; | 5 | import { spawnSync } from "node:child_process"; |
| 5 | 6 | ||
| 6 | import { afterEach, assert, describe, it } from "vitest"; | 7 | import { afterEach, assert, describe, it, vi } from "vitest"; |
| 7 | 8 | ||
| 8 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 9 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 9 | 10 | ||
| ... | @@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { | ... | @@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { |
| 30 | 31 | ||
| 31 | describe("searchWorkspaceEntries", () => { | 32 | describe("searchWorkspaceEntries", () => { |
| 32 | afterEach(() => { | 33 | afterEach(() => { |
| 34 | vi.restoreAllMocks(); | ||
| 33 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | 35 | for (const dir of tempDirs.splice(0, tempDirs.length)) { |
| 34 | fs.rmSync(dir, { recursive: true, force: true }); | 36 | fs.rmSync(dir, { recursive: true, force: true }); |
| 35 | } | 37 | } |
| ... | @@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { | ... | @@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { |
| 115 | assert.include(paths, "src/keep.ts"); | 117 | assert.include(paths, "src/keep.ts"); |
| 116 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | 118 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); |
| 117 | }); | 119 | }); |
| 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 | }); | ||
| 118 | }); | 176 | }); |
apps/server/src/workspaceEntries.ts+51-10| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; | 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| 14 | const WORKSPACE_CACHE_MAX_KEYS = 4; | 14 | const WORKSPACE_CACHE_MAX_KEYS = 4; |
| 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 16 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | ||
| 16 | const IGNORED_DIRECTORY_NAMES = new Set([ | 17 | const IGNORED_DIRECTORY_NAMES = new Set([ |
| 17 | ".git", | 18 | ".git", |
| 18 | ".convex", | 19 | ".convex", |
| ... | @@ -32,6 +33,7 @@ interface WorkspaceIndex { | ... | @@ -32,6 +33,7 @@ interface WorkspaceIndex { |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 34 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); | 35 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); |
| 36 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | ||
| 35 | 37 | ||
| 36 | function toPosixPath(input: string): string { | 38 | function toPosixPath(input: string): string { |
| 37 | return input.split(path.sep).join("/"); | 39 | return input.split(path.sep).join("/"); |
| ... | @@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { | ... | @@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { |
| 101 | return directories; | 103 | return directories; |
| 102 | } | 104 | } |
| 103 | 105 | ||
| 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 | |||
| 104 | async function isInsideGitWorkTree(cwd: string): Promise<boolean> { | 131 | async function isInsideGitWorkTree(cwd: string): Promise<boolean> { |
| 105 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { | 132 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { |
| 106 | cwd, | 133 | cwd, |
| ... | @@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 220 | while (pendingDirectories.length > 0 && !truncated) { | 247 | while (pendingDirectories.length > 0 && !truncated) { |
| 221 | const currentDirectories = pendingDirectories; | 248 | const currentDirectories = pendingDirectories; |
| 222 | pendingDirectories = []; | 249 | pendingDirectories = []; |
| 223 | const directoryEntries = await Promise.all( | 250 | const directoryEntries = await mapWithConcurrency( |
| 224 | currentDirectories.map(async (relativeDir) => { | 251 | currentDirectories, |
| 252 | WORKSPACE_SCAN_READDIR_CONCURRENCY, | ||
| 253 | async (relativeDir) => { | ||
| 225 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; | 254 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; |
| 226 | try { | 255 | try { |
| 227 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); | 256 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); |
| ... | @@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 235 | } | 264 | } |
| 236 | return { relativeDir, dirents: null }; | 265 | return { relativeDir, dirents: null }; |
| 237 | } | 266 | } |
| 238 | }), | 267 | }, |
| 239 | ); | 268 | ); |
| 240 | 269 | ||
| 241 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { | 270 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { |
| ... | @@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 315 | return cached; | 344 | return cached; |
| 316 | } | 345 | } |
| 317 | 346 | ||
| 318 | const next = await buildWorkspaceIndex(cwd); | 347 | const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); |
| 319 | workspaceIndexCache.set(cwd, next); | 348 | if (inFlight) { |
| 320 | while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { | 349 | return inFlight; |
| 321 | const oldestKey = workspaceIndexCache.keys().next().value; | ||
| 322 | if (!oldestKey) break; | ||
| 323 | workspaceIndexCache.delete(oldestKey); | ||
| 324 | } | 350 | } |
| 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; | ||
| 326 | } | 367 | } |
| 327 | 368 | ||
| 328 | export async function searchWorkspaceEntries( | 369 | export async function searchWorkspaceEntries( |
apps/web/src/components/ChatView.tsx+12-10| ... | @@ -329,6 +329,7 @@ export default function ChatView() { | ... | @@ -329,6 +329,7 @@ export default function ChatView() { |
| 329 | gitCreateWorktreeMutationOptions({ api, queryClient }), | 329 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 330 | ); | 330 | ); |
| 331 | const [prompt, setPrompt] = useState(""); | 331 | const [prompt, setPrompt] = useState(""); |
| 332 | const promptRef = useRef(prompt); | ||
| 332 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | 333 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 333 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 334 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 334 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 335 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | @@ -1126,6 +1127,7 @@ export default function ChatView() { | ... | @@ -1126,6 +1127,7 @@ export default function ChatView() { |
| 1126 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), | 1127 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 1127 | }); | 1128 | }); |
| 1128 | const previousMessages = activeThread.messages; | 1129 | const previousMessages = activeThread.messages; |
| 1130 | promptRef.current = ""; | ||
| 1129 | setPrompt(""); | 1131 | setPrompt(""); |
| 1130 | setComposerImages([]); | 1132 | setComposerImages([]); |
| 1131 | setComposerCursor(0); | 1133 | setComposerCursor(0); |
| ... | @@ -1238,16 +1240,15 @@ export default function ChatView() { | ... | @@ -1238,16 +1240,15 @@ export default function ChatView() { |
| 1238 | 1240 | ||
| 1239 | const applyPromptReplacement = useCallback( | 1241 | const applyPromptReplacement = useCallback( |
| 1240 | (rangeStart: number, rangeEnd: number, replacement: string) => { | 1242 | (rangeStart: number, rangeEnd: number, replacement: string) => { |
| 1241 | setPrompt((existing) => { | 1243 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 1242 | const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement); | 1244 | promptRef.current = next.text; |
| 1243 | window.requestAnimationFrame(() => { | 1245 | setPrompt(next.text); |
| 1244 | const textarea = textareaRef.current; | 1246 | window.requestAnimationFrame(() => { |
| 1245 | if (!textarea) return; | 1247 | const textarea = textareaRef.current; |
| 1246 | textarea.focus(); | 1248 | if (!textarea) return; |
| 1247 | textarea.setSelectionRange(next.cursor, next.cursor); | 1249 | textarea.focus(); |
| 1248 | setComposerCursor(next.cursor); | 1250 | textarea.setSelectionRange(next.cursor, next.cursor); |
| 1249 | }); | 1251 | setComposerCursor(next.cursor); |
| 1250 | return next.text; | ||
| 1251 | }); | 1252 | }); |
| 1252 | }, | 1253 | }, |
| 1253 | [], | 1254 | [], |
| ... | @@ -1284,6 +1285,7 @@ export default function ChatView() { | ... | @@ -1284,6 +1285,7 @@ export default function ChatView() { |
| 1284 | workspaceEntriesQuery.isFetching); | 1285 | workspaceEntriesQuery.isFetching); |
| 1285 | 1286 | ||
| 1286 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { | 1287 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1288 | promptRef.current = nextPrompt; | ||
| 1287 | setPrompt(nextPrompt); | 1289 | setPrompt(nextPrompt); |
| 1288 | setComposerCursor(nextCursor); | 1290 | setComposerCursor(nextCursor); |
| 1289 | }, []); | 1291 | }, []); |