From 6105047ad79764c43940c2b9cd9170392ed65886 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 19 May 2026 01:23:52 -0700 Subject: [PATCH] =?UTF-8?q?fix(notifications):=20D6=20regression=20?= =?UTF-8?q?=E2=80=94=20lone=20space=20must=20get=20full=20drain=20throughp?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D6's pre-truncation capped every space's bucket at PER_SPACE=5 before the round-robin, so a single-space deployment (the common single- hackerspace case) drained only 5 emails/min instead of 20 — a 4x slowdown introduced by the fairness fix. Fairness must bind only under contention: per-space queues are now bounded solely by the CANDIDATES fetch and the round-robin itself caps any one space's share when multiple compete; a lone space drains the full BATCH again. Caught verifying the D1-D10 batch. --- app/api/cron/notifications/route.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/api/cron/notifications/route.ts b/app/api/cron/notifications/route.ts index dd4a39a..9c2eb6c 100644 --- a/app/api/cron/notifications/route.ts +++ b/app/api/cron/notifications/route.ts @@ -22,10 +22,8 @@ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const BATCH = 20 -// Per-space cap within one drain so one tenant's billing burst cannot -// monopolize the shared global FIFO and starve every other space's email. -const PER_SPACE = 5 -// Oldest-first candidate window we fairness-balance across spaces. +// Oldest-first candidate window we fairness-balance across spaces. Larger +// than BATCH so a burst space cannot crowd smaller spaces out of the window. const CANDIDATES = 200 // ~4.5 sends/sec, under Resend's 5 req/sec team limit. const SPACING_MS = 220 @@ -62,15 +60,18 @@ export async function POST(req: NextRequest) { if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - // Fair drain: round-robin the oldest-first candidates across spaces with a - // per-space cap, so a single tenant's burst can't head-of-line-block every - // other space's notifications in the shared dispatcher. + // Fair drain: round-robin the oldest-first candidates across spaces so one + // tenant's burst can't head-of-line-block others. Fairness binds only under + // contention: with a single space pending (the common single-hackerspace + // deployment) it still drains the full BATCH; the round-robin itself caps + // any one space's share when multiple spaces compete. Per-space queues are + // bounded only by the CANDIDATES fetch (oldest-first), and Map insertion + // order preserves that oldest-first ordering across spaces. const bySpace = new Map() for (const c of candidates ?? []) { const k = c.space_id as string if (!bySpace.has(k)) bySpace.set(k, []) - const arr = bySpace.get(k)! - if (arr.length < PER_SPACE) arr.push(c) + bySpace.get(k)!.push(c) } const rows: NonNullable = [] let added = true