From 1a9456b1c64a11a7dbf21be375d877b7836fe0a4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:29:22 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[performance=20improvement]?= =?UTF-8?q?=20Batched=20GC=20file=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced sequential `await` loops inside `ArtifactStore.gc` for `fs.stat` and `fs.rm` with chunked, concurrent `Promise.all` execution to speed up file deletions without hitting EMFILE limits. --- .jules/bolt.md | 6 +++++ src/core/sub-agent/artifacts/store.ts | 39 ++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..17cebe63 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,6 @@ +## 2024-05-15 - Batched concurrent I/O operations for large file iterations +**Learning:** Sequential await calls in loops for operations like `fs.stat` or `fs.rm` over large directories (e.g. ArtifactStore.gc) cause severe performance bottlenecks and can trigger EMFILE limits. +**Action:** Always batch I/O operations using `Promise.all` with a reasonable chunk size (e.g. 10) to parallelize without hitting EMFILE limits. +## 2024-05-15 - Array.find inside batched iterations causes O(N^2) regressions +**Learning:** In batched loops, such as when calling `isExpired` inside `evictExpiredEntries`, calling `this.cacheStore.get()` inside the loop performs an O(N) lookup. Since the loop runs O(N) times, the overall complexity becomes O(N^2), causing performance regressions. +**Action:** Always reuse the entry objects obtained during the initial O(N) iteration rather than fetching them again. diff --git a/src/core/sub-agent/artifacts/store.ts b/src/core/sub-agent/artifacts/store.ts index 8a7aff5c..02a82ab1 100644 --- a/src/core/sub-agent/artifacts/store.ts +++ b/src/core/sub-agent/artifacts/store.ts @@ -93,13 +93,27 @@ export class ArtifactStore { const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); const files: Array<{ name: string; path: string; mtimeMs: number; size: number }> = []; - for (const entry of entries) { - if (!entry.isFile()) continue; + const validEntries = entries.filter((entry) => { + if (!entry.isFile()) return false; const filePath = path.join(root, entry.name); - if (!isWithinDir(root, filePath)) continue; - const stat = await fs.stat(filePath).catch(() => null); - if (!stat) continue; - files.push({ name: entry.name, path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }); + return isWithinDir(root, filePath); + }); + + for (let i = 0; i < validEntries.length; i += 10) { + const chunk = validEntries.slice(i, i + 10); + const stats = await Promise.all( + chunk.map(async (entry) => { + const filePath = path.join(root, entry.name); + const stat = await fs.stat(filePath).catch(() => null); + return { entry, filePath, stat }; + }), + ); + + for (const { entry, filePath, stat } of stats) { + if (stat) { + files.push({ name: entry.name, path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }); + } + } } const maxAgeMs = options?.maxAgeMs ?? LIMITS.artifactTtlMs; @@ -118,8 +132,9 @@ export class ArtifactStore { removedBytes += file.size; }; - for (const file of expired) { - await removeFile(file); + for (let i = 0; i < expired.length; i += 10) { + const chunk = expired.slice(i, i + 10); + await Promise.all(chunk.map((file) => removeFile(file))); } // Recompute remaining after TTL removal (newest first). @@ -130,17 +145,23 @@ export class ArtifactStore { let currentFiles = remaining.length; let currentBytes = remaining.reduce((acc, f) => acc + f.size, 0); + const excessFilesToRemove = []; for (let i = remaining.length - 1; i >= 0; i--) { const tooManyFiles = currentFiles > maxFiles; const tooManyBytes = currentBytes > maxTotalBytes; if (!tooManyFiles && !tooManyBytes) break; const oldest = remaining[i]; - await removeFile(oldest); + excessFilesToRemove.push(oldest); currentFiles -= 1; currentBytes -= oldest.size; } + for (let i = 0; i < excessFilesToRemove.length; i += 10) { + const chunk = excessFilesToRemove.slice(i, i + 10); + await Promise.all(chunk.map((file) => removeFile(file))); + } + return { removedFiles, removedBytes }; }