From 46fae21e67a4f0650849c45b972efbe4c84e864a Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 28 Apr 2026 10:26:49 -0400 Subject: [PATCH] perf(backend): parallelize pruneManagedOnly removals (#830) DockerController.pruneManagedOnly removed managed volumes, networks, and images one at a time inside a serial for-await loop. Each remove call hits the Docker daemon over the Unix socket with a synchronous-from-the-caller's-perspective HTTP round-trip, so a prune over N items took the sum of N round-trips. The daemon handles concurrent removes fine for these resource types. Wrap each loop in Promise.all so wall time tracks the slowest single remove rather than the sum. The existing per-item try/catch keeps the partial-failure semantics: a single resource that fails to delete logs and continues; the rest still get removed. JavaScript single-threading makes the shared reclaimedBytes counter safe under the parallel awaits. --- backend/src/services/DockerController.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index be376411..35657888 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -334,26 +334,29 @@ class DockerController { return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack) && (v.UsageData?.RefCount ?? 1) === 0; }); - for (const vol of prunable) { + // Removals are independent and Docker handles concurrent volume + // deletes; parallelize so wall time matches the slowest single + // remove rather than the sum of all of them. + await Promise.all(prunable.map(async (vol) => { try { await this.docker.getVolume(vol.Name).remove({ force: true }); reclaimedBytes += vol.UsageData?.Size ?? 0; } catch (e) { console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e); } - } + })); } else if (target === 'networks') { const rawNetworks = await this.docker.listNetworks(); const prunable = (rawNetworks as any[]).filter((n: any) => { return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack); }); - for (const net of prunable) { + await Promise.all(prunable.map(async (net) => { try { await this.docker.getNetwork(net.Id).remove({ force: true }); } catch (e) { console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e); } - } + })); } else if (target === 'images') { const allContainers = await this.docker.listContainers({ all: true }); const resolvedBase = path.resolve(COMPOSE_DIR); @@ -369,14 +372,14 @@ class DockerController { const prunable = (rawImages as any[]).filter((img: any) => img.Containers === 0 && !unmanagedImageIds.has(img.Id) ); - for (const img of prunable) { + await Promise.all(prunable.map(async (img) => { try { await this.docker.getImage(img.Id).remove({ force: true }); reclaimedBytes += img.Size ?? 0; } catch (e) { console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e); } - } + })); } return { success: true, reclaimedBytes };