fix(admin): bound owner-email enrichment concurrency to avoid self-throttle

A large concurrent burst of getUserById calls during the tunnels-list
email enrichment self-inflicts an upstream throttle (truncated/empty
bodies) that even the per-call retry can't fully escape, intermittently
rendering owner_email as '—'. Add mapWithConcurrency and resolve owner
emails at most a few at a time so each lookup stays inside the throttle
allowance; retry + null fallback preserved.
This commit is contained in:
Gerhard Scheikl
2026-05-31 15:57:54 +02:00
parent 17fe642168
commit b0dba8ec0e
2 changed files with 46 additions and 7 deletions
+16 -7
View File
@@ -1,6 +1,6 @@
import type { User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { withAdminRetry } from '@/lib/admin/retry';
import { withAdminRetry, mapWithConcurrency } from '@/lib/admin/retry';
import {
parseOrder,
parseSort,
@@ -77,6 +77,12 @@ type TunnelRow = TunnelJoinRow & {
const USER_SCAN_MAX_PAGES = 50;
const USER_SCAN_PER_PAGE = 1000;
// Resolve tunnel owner emails a few at a time rather than all at once: a large
// concurrent burst of getUserById calls self-inflicts an upstream throttle
// (truncated/empty bodies) that even per-call retries can't fully escape, which
// is what intermittently rendered owner_email as "—" under load.
const OWNER_EMAIL_CONCURRENCY = 4;
function userSortValue(u: User, sort: UserSort): string | number {
switch (sort) {
case 'email':
@@ -291,11 +297,14 @@ export async function getTunnelsList(opts: {
// Resolve owner emails (per-row getUserById; acceptable for current scale).
// The user_id comes from an existing tunnel row, so an empty body here is a
// transient burst flake rather than a genuine not-found — retry it. The
// try/catch null fallback remains as a last resort so one bad row can never
// 500 the whole list (it surfaces as "—" only if every retry still fails).
const emails = await Promise.all(
rows.map(async (t) => {
// transient burst flake rather than a genuine not-found — retry it, and bound
// the concurrency so the enrichment doesn't self-throttle. The try/catch null
// fallback remains as a last resort so one bad row can never 500 the whole
// list (it surfaces as "—" only if every retry still fails).
const emails = await mapWithConcurrency(
rows,
OWNER_EMAIL_CONCURRENCY,
async (t) => {
try {
const { data: u } = await withAdminRetry(() =>
admin.auth.admin.getUserById(t.user_id),
@@ -304,7 +313,7 @@ export async function getTunnelsList(opts: {
} catch {
return null;
}
}),
},
);
const tunnels: TunnelItem[] = rows.map((t, i) => ({