From 16cc50200adfe82e52ca2d44078af248a4879d8a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:10:33 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20ArtifactStore=20?= =?UTF-8?q?GC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace sequential fs.stat and fs.rm with chunked Promise.all for I/O - Build state-dependent removal queue sequentially - Significantly improves garbage collection performance and avoids EMFILE limits --- .jules/bolt.md | 3 ++ src/core/sub-agent/artifacts/store.ts | 54 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..6d2a6f17 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2024-07-17 - Optimize I/O batching across files +**Learning:** Optimizing file operations across many files requires batching concurrent `fs.stat` and `fs.rm` (e.g., `Promise.all` with chunk size of 10) to avoid EMFILE limits, while state-dependent operations (like calculating cumulative totals) must build their removal queue sequentially before concurrent execution. +**Action:** When refactoring sequential file system operations into batched concurrent promises, always separate state calculation into a sequential pass and restrict batched promises to pure I/O effects. diff --git a/src/core/sub-agent/artifacts/store.ts b/src/core/sub-agent/artifacts/store.ts index 8a7aff5c..bee8ce3d 100644 --- a/src/core/sub-agent/artifacts/store.ts +++ b/src/core/sub-agent/artifacts/store.ts @@ -93,13 +93,22 @@ 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 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 }); + const validEntries = entries.filter((e) => e.isFile()); + + 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); + if (!isWithinDir(root, filePath)) return null; + const stat = await fs.stat(filePath).catch(() => null); + if (!stat) return null; + return { name: entry.name, path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }; + }), + ); + for (const s of stats) { + if (s) files.push(s); + } } const maxAgeMs = options?.maxAgeMs ?? LIMITS.artifactTtlMs; @@ -109,18 +118,7 @@ export class ArtifactStore { const nowMs = Date.now(); const expired = files.filter((f) => nowMs - f.mtimeMs > maxAgeMs); - let removedFiles = 0; - let removedBytes = 0; - - const removeFile = async (file: { path: string; size: number }) => { - await fs.rm(file.path, { force: true }).catch(() => null); - removedFiles += 1; - removedBytes += file.size; - }; - - for (const file of expired) { - await removeFile(file); - } + const toRemove: Array<{ path: string; size: number }> = [...expired]; // Recompute remaining after TTL removal (newest first). const remaining = files @@ -136,11 +134,27 @@ export class ArtifactStore { if (!tooManyFiles && !tooManyBytes) break; const oldest = remaining[i]; - await removeFile(oldest); + toRemove.push(oldest); currentFiles -= 1; currentBytes -= oldest.size; } + let removedFiles = 0; + let removedBytes = 0; + + // Process removal queue in chunks concurrently + for (let i = 0; i < toRemove.length; i += 10) { + const chunk = toRemove.slice(i, i + 10); + await Promise.all( + chunk.map(async (file) => { + await fs.rm(file.path, { force: true }).catch(() => null); + // Counters updated synchronously post-operation since Promise.all guarantees completion + removedFiles += 1; + removedBytes += file.size; + }), + ); + } + return { removedFiles, removedBytes }; }