Bound concurrent D1 upserts in saved-keyword metric refresh (#338)

refreshSavedKeywordMetrics fanned out one Promise per keyword across an
entire location/language group, so a project with thousands of saved
keywords could trigger thousands of simultaneous D1 upserts, pressuring
Worker memory/CPU and D1 concurrency (potential OOM / availability DoS).

Restore bounded DB-write batching: upsert in chunks of 100 so concurrent
writes stay capped regardless of group size.
This commit is contained in:
Ben Senescu 2026-07-05 17:35:58 -04:00 committed by GitHub
parent 1a74904b67
commit c993ef4931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -7,6 +7,11 @@ import {
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { RefreshSavedKeywordMetricsInput } from "@/types/schemas/keywords"; import type { RefreshSavedKeywordMetricsInput } from "@/types/schemas/keywords";
// Cap concurrent D1 upserts per group. A project can accumulate thousands of
// saved keywords in one location/language, and fanning out one promise each
// would flood D1/Worker resources; write in bounded chunks instead.
const REFRESH_UPSERT_BATCH_SIZE = 100;
export async function refreshSavedKeywordMetrics( export async function refreshSavedKeywordMetrics(
input: RefreshSavedKeywordMetricsInput, input: RefreshSavedKeywordMetricsInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
@ -41,8 +46,10 @@ export async function refreshSavedKeywordMetrics(
metrics.map((metric) => [metric.keyword.toLowerCase(), metric]), metrics.map((metric) => [metric.keyword.toLowerCase(), metric]),
); );
for (let i = 0; i < groupRows.length; i += REFRESH_UPSERT_BATCH_SIZE) {
const chunk = groupRows.slice(i, i + REFRESH_UPSERT_BATCH_SIZE);
await Promise.all( await Promise.all(
groupRows.map((r) => { chunk.map((r) => {
const metric = byKeyword.get(r.row.keyword.toLowerCase()); const metric = byKeyword.get(r.row.keyword.toLowerCase());
if (!metric) return Promise.resolve(); if (!metric) return Promise.resolve();
return KeywordResearchRepository.upsertKeywordMetric({ return KeywordResearchRepository.upsertKeywordMetric({
@ -59,6 +66,7 @@ export async function refreshSavedKeywordMetrics(
}); });
}), }),
); );
}
updated += byKeyword.size; updated += byKeyword.size;
} }