fix(notifications): D6 regression — lone space must get full drain throughput

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.
This commit is contained in:
Moheeb Zara
2026-05-19 01:23:52 -07:00
parent 6568bc9c10
commit 6105047ad7
+10 -9
View File
@@ -22,10 +22,8 @@ export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const BATCH = 20 const BATCH = 20
// Per-space cap within one drain so one tenant's billing burst cannot // Oldest-first candidate window we fairness-balance across spaces. Larger
// monopolize the shared global FIFO and starve every other space's email. // than BATCH so a burst space cannot crowd smaller spaces out of the window.
const PER_SPACE = 5
// Oldest-first candidate window we fairness-balance across spaces.
const CANDIDATES = 200 const CANDIDATES = 200
// ~4.5 sends/sec, under Resend's 5 req/sec team limit. // ~4.5 sends/sec, under Resend's 5 req/sec team limit.
const SPACING_MS = 220 const SPACING_MS = 220
@@ -62,15 +60,18 @@ export async function POST(req: NextRequest) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 }) if (error) return NextResponse.json({ error: error.message }, { status: 500 })
// Fair drain: round-robin the oldest-first candidates across spaces with a // Fair drain: round-robin the oldest-first candidates across spaces so one
// per-space cap, so a single tenant's burst can't head-of-line-block every // tenant's burst can't head-of-line-block others. Fairness binds only under
// other space's notifications in the shared dispatcher. // 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<string, typeof candidates>() const bySpace = new Map<string, typeof candidates>()
for (const c of candidates ?? []) { for (const c of candidates ?? []) {
const k = c.space_id as string const k = c.space_id as string
if (!bySpace.has(k)) bySpace.set(k, []) if (!bySpace.has(k)) bySpace.set(k, [])
const arr = bySpace.get(k)! bySpace.get(k)!.push(c)
if (arr.length < PER_SPACE) arr.push(c)
} }
const rows: NonNullable<typeof candidates> = [] const rows: NonNullable<typeof candidates> = []
let added = true let added = true