Skip to content

Improve POST /links endpoint performance #2177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3a0892f
Improve `POST /links` endpoint
devkiran Mar 18, 2025
907a85e
Update http.ts
devkiran Mar 18, 2025
71dd506
Update process-link.ts
devkiran Mar 18, 2025
1c8001a
Update process-link.ts
devkiran Mar 18, 2025
87c9c80
Revert "Update process-link.ts"
devkiran Mar 18, 2025
bdddf92
Update process-link.ts
devkiran Mar 18, 2025
84f46fb
Update http.ts
devkiran Mar 18, 2025
83f7ae6
Merge branch 'main' into optimize-link-creation
steven-tey Mar 19, 2025
810f2d6
Merge branch 'main' into optimize-link-creation
steven-tey Mar 19, 2025
bc96f7c
Merge branch 'main' into optimize-link-creation
steven-tey Mar 21, 2025
b1e1644
Merge branch 'main' into optimize-link-creation
steven-tey Mar 22, 2025
df9b7f9
Merge branch 'main' into optimize-link-creation
devkiran Mar 22, 2025
5c2d2ba
Merge branch 'main' into optimize-link-creation
steven-tey Mar 25, 2025
6bc5bb9
Merge branch 'main' into optimize-link-creation
steven-tey Mar 25, 2025
7873502
Merge branch 'main' into optimize-link-creation
devkiran Mar 25, 2025
48dc7c8
refactor `getRandomKey` to always skip availability check.
devkiran Mar 25, 2025
4e8492d
remove `checkIfKeyExists` from `keyChecks`
devkiran Mar 25, 2025
7a9026e
Delete check-if-key-exists.ts
devkiran Mar 25, 2025
4721254
Refactor link creation and update logic to remove try-catch blocks, s…
devkiran Mar 25, 2025
033e4e2
Enhance bulk link creation by returning structured responses for vali…
devkiran Mar 25, 2025
39a8ec7
Update create-link-with-key-retry.ts
devkiran Mar 25, 2025
01fbcee
Update sitemap-importer.ts
devkiran Mar 25, 2025
e3adf80
fix tests
devkiran Mar 25, 2025
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
43 changes: 18 additions & 25 deletions apps/web/app/api/links/[linkId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,33 +166,26 @@ export const PATCH = withWorkspace(
});
}

try {
const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});
const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});

waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);
waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);

return NextResponse.json(response, {
headers,
});
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
}
return NextResponse.json(response, {
headers,
});
},
{
requiredPermissions: ["links.write"],
Expand Down
12 changes: 10 additions & 2 deletions apps/web/app/api/links/bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,16 @@ export const POST = withWorkspace(
});
}

const validLinksResponse =
validLinks.length > 0 ? await bulkCreateLinks({ links: validLinks }) : [];
let validLinksResponse: ProcessedLinkProps[] = [];

if (validLinks.length > 0) {
const response = await bulkCreateLinks({
links: validLinks,
});

validLinksResponse.push(...response.validLinks);
errorLinks.push(...response.invalidLinks);
}

return NextResponse.json([...validLinksResponse, ...errorLinks], {
headers,
Expand Down
1 change: 0 additions & 1 deletion apps/web/app/api/links/random/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const GET = async (req: NextRequest) => {
await ratelimitOrThrow(req, "links-random");

const response = await getRandomKey({
domain,
long: domain === "loooooooo.ng",
});
return NextResponse.json(response);
Expand Down
41 changes: 20 additions & 21 deletions apps/web/app/api/links/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getFolderIdsToFilter } from "@/lib/analytics/get-folder-ids-to-filter";
import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
import { DubApiError, ErrorCodes } from "@/lib/api/errors";
import { createLink, getLinksForWorkspace, processLink } from "@/lib/api/links";
import { getLinksForWorkspace, processLink } from "@/lib/api/links";
import { createLinkWithKeyRetry } from "@/lib/api/links/create-link-with-key-retry";
import { throwIfLinksUsageExceeded } from "@/lib/api/links/usage-checks";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
Expand Down Expand Up @@ -96,6 +97,8 @@ export const POST = withWorkspace(
}
}

const { key } = body;

const { link, error, code } = await processLink({
payload: body,
workspace,
Expand All @@ -109,28 +112,24 @@ export const POST = withWorkspace(
});
}

try {
const response = await createLink(link);

if (response.projectId && response.userId) {
waitUntil(
sendWorkspaceWebhook({
trigger: "link.created",
workspace,
data: linkEventSchema.parse(response),
}),
);
}
const response = await createLinkWithKeyRetry({
link,
isRandomKey: !key,
});

return NextResponse.json(response, {
headers,
});
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
if (response.projectId && response.userId) {
waitUntil(
sendWorkspaceWebhook({
trigger: "link.created",
workspace,
data: linkEventSchema.parse(response),
}),
);
}

return NextResponse.json(response, {
headers,
});
},
{
requiredPermissions: ["links.write"],
Expand Down
83 changes: 36 additions & 47 deletions apps/web/app/api/links/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,58 +131,47 @@ export const PUT = withWorkspace(
});
}

try {
const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});
const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});

waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);
waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);

return NextResponse.json(response, {
headers,
});
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
}
} else {
// proceed with /api/links POST logic
const { link, error, code } = await processLink({
payload: body,
workspace,
userId: session.user.id,
});
return NextResponse.json(response, { headers });
}

if (error != null) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}
// Otherwise, proceed with /api/links POST logic
const {
link: processedLink,
error,
code,
} = await processLink({
payload: body,
workspace,
userId: session.user.id,
});

try {
const response = await createLink(link);
return NextResponse.json(response, { headers });
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
}
if (error != null) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}

const response = await createLink(processedLink);

return NextResponse.json(response, { headers });
},
{
requiredPermissions: ["links.write"],
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function fromZodError(error: ZodError): ErrorResponse {
}

export function handleApiError(error: any): ErrorResponse & { status: number } {
console.error("API error occurred", error.message);
console.error(error.message);

// Zod errors
if (error instanceof ZodError) {
Expand Down
70 changes: 67 additions & 3 deletions apps/web/lib/api/links/bulk-create-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ export async function bulkCreateLinks({
links: ProcessedLinkProps[];
skipRedisCache?: boolean;
}) {
if (links.length === 0) return [];
if (links.length === 0) {
return {
validLinks: [],
invalidLinks: [],
};
}

const hasTags = checkIfLinksHaveTags(links);
const hasWebhooks = checkIfLinksHaveWebhooks(links);

// Create a map of shortLinks to their original indices at the start
const shortLinkToIndexMap = new Map(
let shortLinkToIndexMap = new Map(
links.map((link, index) => {
const key = encodeKeyIfCaseSensitive({
domain: link.domain,
Expand All @@ -44,6 +49,60 @@ export async function bulkCreateLinks({
}),
);

// Check if any links already exist
const existingLinks = await prisma.link.findMany({
where: {
shortLink: {
in: Array.from(shortLinkToIndexMap.keys()),
},
},
select: {
shortLink: true,
},
take: shortLinkToIndexMap.size,
});

const invalidLinks: {
error: string;
code: string;
link: ProcessedLinkProps;
}[] = [];

if (existingLinks.length > 0) {
existingLinks.forEach((existingLink) => {
invalidLinks.push({
code: "conflict",
error: "Duplicate key: This short link already exists.",
link: links.find(
(l) =>
existingLink.shortLink ===
linkConstructorSimple({ domain: l.domain, key: l.key }),
) as ProcessedLinkProps,
});
});
}

// Remove existing links from the links array
links = links.filter(
(link) =>
!existingLinks.some(
(l) =>
linkConstructorSimple({ domain: link.domain, key: link.key }) ===
l.shortLink,
),
);

existingLinks.forEach((link) => {
shortLinkToIndexMap.delete(link.shortLink);
});

if (links.length === 0) {
return {
validLinks: [],
invalidLinks,
};
}

// Create all links first using createMany
await prisma.link.createMany({
data: links.map(({ tagId, tagIds, tagNames, webhookIds, ...link }) => {
Expand Down Expand Up @@ -230,5 +289,10 @@ export async function bulkCreateLinks({
return aIndex - bIndex;
});

return createdLinksData.map((link) => transformLink(link));
return {
validLinks: createdLinksData.map(
(link) => transformLink(link) as ProcessedLinkProps,
),
invalidLinks,
};
}
Loading