diff --git a/src/lib/flows/wave/add-issues-to-wave-program/configure-details.svelte b/src/lib/flows/wave/add-issues-to-wave-program/configure-details.svelte index ca070e1d0..ccd050318 100644 --- a/src/lib/flows/wave/add-issues-to-wave-program/configure-details.svelte +++ b/src/lib/flows/wave/add-issues-to-wave-program/configure-details.svelte @@ -14,14 +14,13 @@ import Card from '$lib/components/wave/card/card.svelte'; import ListSelect from '$lib/components/list-select/list-select.svelte'; import type { Items } from '$lib/components/list-select/list-select.types'; - import { addIssueToWaveProgram } from '$lib/utils/wave/wavePrograms'; + import { addIssuesToWaveProgram, BULK_ADD_MAX_ISSUES } from '$lib/utils/wave/wavePrograms'; import { invalidate } from '$app/navigation'; import { notifyIssuesUpdated } from '$lib/components/wave/issues-page/issue-update-coordinator'; import AnnotationBox from '$lib/components/annotation-box/annotation-box.svelte'; import { getIssue } from '$lib/utils/wave/issues'; import { getPointsForComplexity } from '$lib/utils/wave/get-points-for-complexity'; import extractApiErrorMessage from '$lib/utils/wave/utils/extract-api-error-message'; - import mapWithConcurrency from '$lib/utils/map-with-concurrency'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; const dispatch = createEventDispatcher(); @@ -300,17 +299,40 @@ const selectedWaveId = selectedWaveIds[0]; const issuesToAdd = submittableIssues; - // Adds sequentialize on an org-wide lock on the backend, so firing them - // all at once just piles up lock waiters. Keep concurrency low. - const results = await mapWithConcurrency( - issuesToAdd, - (issue) => addIssueToWaveProgram(undefined, selectedWaveId, issue.id, activeComplexity), - 2, - ); + // One bulk request per chunk of BULK_ADD_MAX_ISSUES, sequentially. The + // backend adds each chunk in a single transaction with one org-lock + // acquisition, reporting per-issue outcomes — unlike parallel single + // adds, which serialize on that lock and can time out (wave#735). + const resultByIssueId = new SvelteMap(); + for (let i = 0; i < issuesToAdd.length; i += BULK_ADD_MAX_ISSUES) { + const chunk = issuesToAdd.slice(i, i + BULK_ADD_MAX_ISSUES); + try { + const response = await addIssuesToWaveProgram( + undefined, + selectedWaveId, + chunk.map((issue) => ({ issueId: issue.id, complexity: activeComplexity })), + ); + for (const result of response.results) { + resultByIssueId.set(result.issueId, result); + } + } catch (error) { + // Whole-chunk failure (network, 5xx, batch-level 4xx): report every + // issue in the chunk as failed but keep going — later chunks may + // succeed, and earlier ones already have. + for (const issue of chunk) { + resultByIssueId.set(issue.id, { + success: false, + error: extractApiErrorMessage(error), + }); + } + } + } // Refresh every issue that was added successfully — even on partial // failure — so the list reflects reality regardless of the outcome. - const succeededIssues = issuesToAdd.filter((_, i) => results[i].status === 'fulfilled'); + const succeededIssues = issuesToAdd.filter( + (issue) => resultByIssueId.get(issue.id)?.success, + ); if (succeededIssues.length > 0) { const updatedIssues = ( await Promise.all(succeededIssues.map((issue) => getIssue(undefined, issue.id))) @@ -321,12 +343,11 @@ } const failures = issuesToAdd - .map((issue, i) => ({ issue, result: results[i] })) - .filter( - (entry): entry is { issue: IssueDetailsDto; result: PromiseRejectedResult } => - entry.result.status === 'rejected', - ) - .map(({ issue, result }) => ({ issue, reason: result.reason })); + .filter((issue) => !resultByIssueId.get(issue.id)?.success) + .map((issue) => ({ + issue, + reason: resultByIssueId.get(issue.id)?.error ?? 'Unknown error', + })); if (failures.length > 0) { throw new Error(formatAddIssueFailures(failures, issuesToAdd.length)); diff --git a/src/lib/utils/wave/types/waveProgram.ts b/src/lib/utils/wave/types/waveProgram.ts index ccbcaf169..2caa1e1c1 100644 --- a/src/lib/utils/wave/types/waveProgram.ts +++ b/src/lib/utils/wave/types/waveProgram.ts @@ -367,6 +367,23 @@ export const waveProgramIssueWithDetailsDtoSchema = z.object({ removedBy: waveUserDtoSchema.nullable(), }); +export const bulkAddIssueResultDtoSchema = z.object({ + issueId: z.uuid(), + success: z.boolean(), + waveProgramIssue: waveProgramIssueWithDetailsDtoSchema.nullable(), + error: z.string().nullable(), +}); +export type BulkAddIssueResultDto = z.infer; + +export const bulkAddIssuesToWaveProgramResponseDtoSchema = z.object({ + results: z.array(bulkAddIssueResultDtoSchema), + addedCount: z.number().int(), + failedCount: z.number().int(), +}); +export type BulkAddIssuesToWaveProgramResponseDto = z.infer< + typeof bulkAddIssuesToWaveProgramResponseDtoSchema +>; + // =========================== // Wave Types // =========================== diff --git a/src/lib/utils/wave/wavePrograms.ts b/src/lib/utils/wave/wavePrograms.ts index c9a73f068..64d788172 100644 --- a/src/lib/utils/wave/wavePrograms.ts +++ b/src/lib/utils/wave/wavePrograms.ts @@ -7,6 +7,7 @@ import { } from './types/pagination'; import { batchApplyResponseSchema, + bulkAddIssuesToWaveProgramResponseDtoSchema, repoAppealContextDtoSchema, repoRejectionAppealDtoSchema, waveProgramApplicationLimitsDtoSchema, @@ -251,6 +252,37 @@ export async function addIssueToWaveProgram( ); } +/** + * Server-side cap on issues per bulk-add request (mirrors `BULK_ADD_MAX_ISSUES` + * in the Wave API). Larger selections must be chunked by the caller. + */ +export const BULK_ADD_MAX_ISSUES = 50; + +/** + * Adds up to `BULK_ADD_MAX_ISSUES` issues to a wave program in a single request + * (and a single backend transaction). Per-issue failures are reported in the + * response's `results` without failing the batch. Prefer this over parallel + * `addIssueToWaveProgram` calls: those serialize on a per-org lock server-side + * and can time out in bulk (see wave#735). + */ +export async function addIssuesToWaveProgram( + f = fetch, + waveProgramId: string, + issues: { issueId: string; complexity?: Complexity }[], +) { + if (issues.length > BULK_ADD_MAX_ISSUES) { + throw new Error(`Cannot add more than ${BULK_ADD_MAX_ISSUES} issues per request`); + } + + return parseRes( + bulkAddIssuesToWaveProgramResponseDtoSchema, + await authenticatedCall(f, `/api/wave-programs/${waveProgramId}/issues/bulk`, { + method: 'POST', + body: JSON.stringify({ issues }), + }), + ); +} + export async function removeIssueFromWaveProgram( f = fetch, waveProgramId: string,