Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<StepComponentEvents>();
Expand Down Expand Up @@ -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<string, { success: boolean; error: string | null }>();
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)))
Expand All @@ -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));
Expand Down
17 changes: 17 additions & 0 deletions src/lib/utils/wave/types/waveProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof bulkAddIssueResultDtoSchema>;

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
// ===========================
Expand Down
32 changes: 32 additions & 0 deletions src/lib/utils/wave/wavePrograms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from './types/pagination';
import {
batchApplyResponseSchema,
bulkAddIssuesToWaveProgramResponseDtoSchema,
repoAppealContextDtoSchema,
repoRejectionAppealDtoSchema,
waveProgramApplicationLimitsDtoSchema,
Expand Down Expand Up @@ -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,
Expand Down
Loading