fix(ui): limit concurrent scan launches in launchOrganizationScans

Replace unbounded Promise.allSettled with runWithConcurrencyLimit
capped at 5 concurrent requests. Move error handling into the worker
callback. Add unit test verifying concurrency ceiling.
This commit is contained in:
alejandrobailo
2026-02-24 15:17:33 +01:00
parent 54e2fe76a8
commit 2ba2e7b5ff
2 changed files with 137 additions and 30 deletions

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: vi.fn(),
}));
import { launchOrganizationScans } from "./scans";
describe("launchOrganizationScans", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: { id: "scan-id" } });
handleApiErrorMock.mockReturnValue({ error: "Scan launch failed." });
});
it("limits concurrent launch requests to avoid overwhelming the backend", async () => {
// Given
const providerIds = Array.from(
{ length: 12 },
(_, index) => `provider-${index + 1}`,
);
let activeRequests = 0;
let maxActiveRequests = 0;
fetchMock.mockImplementation(async () => {
activeRequests += 1;
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 5));
activeRequests -= 1;
return new Response(JSON.stringify({ data: { id: "scan-id" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
// When
const result = await launchOrganizationScans(providerIds, "daily");
// Then
expect(maxActiveRequests).toBeLessThanOrEqual(5);
expect(result.successCount).toBe(providerIds.length);
expect(result.failureCount).toBe(0);
});
});

View File

@@ -13,6 +13,41 @@ import {
} from "@/lib/provider-filters";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
export async function runWithConcurrencyLimit<T, R>(
items: T[],
concurrencyLimit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) {
return [];
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
const results = new Array<R>(items.length);
let currentIndex = 0;
const runWorker = async () => {
while (currentIndex < items.length) {
const assignedIndex = currentIndex;
currentIndex += 1;
results[assignedIndex] = await worker(
items[assignedIndex],
assignedIndex,
);
}
};
const workers = Array.from(
{ length: Math.min(normalizedConcurrency, items.length) },
() => runWorker(),
);
await Promise.all(workers);
return results;
}
export const getScans = async ({
page = 1,
query = "",
@@ -178,45 +213,46 @@ export const launchOrganizationScans = async (
};
}
const launchPromises = validProviderIds.map(async (providerId) => {
const formData = new FormData();
formData.set("providerId", providerId);
const launchResults = await runWithConcurrencyLimit(
validProviderIds,
ORGANIZATION_SCAN_CONCURRENCY_LIMIT,
async (providerId) => {
try {
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === "daily"
? await scheduleDaily(formData)
: await scanOnDemand(formData);
const result =
scheduleOption === "daily"
? await scheduleDaily(formData)
: await scanOnDemand(formData);
return {
providerId,
ok: !result?.error,
error: result?.error ? String(result.error) : null,
};
});
return {
providerId,
ok: !result?.error,
error: result?.error ? String(result.error) : null,
};
} catch (error) {
return {
providerId,
ok: false,
error:
error instanceof Error ? error.message : "Failed to launch scan.",
};
}
},
);
const settled = await Promise.allSettled(launchPromises);
const summary = settled.reduce(
const summary = launchResults.reduce(
(acc, item) => {
if (item.status === "fulfilled") {
if (item.value.ok) {
acc.successCount += 1;
} else {
acc.failureCount += 1;
acc.errors.push({
providerId: item.value.providerId,
error: item.value.error || "Failed to launch scan.",
});
}
if (item.ok) {
acc.successCount += 1;
return acc;
}
acc.failureCount += 1;
acc.errors.push({
providerId: "",
error:
item.reason instanceof Error
? item.reason.message
: String(item.reason),
providerId: item.providerId,
error: item.error || "Failed to launch scan.",
});
return acc;
},