From b0dba8ec0e40d52a7512560b91e4e4bcee56f322 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sun, 31 May 2026 15:57:54 +0200 Subject: [PATCH] fix(admin): bound owner-email enrichment concurrency to avoid self-throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/admin/list.ts | 23 ++++++++++++++++------- lib/admin/retry.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/lib/admin/list.ts b/lib/admin/list.ts index 3607ae6..fd7e972 100644 --- a/lib/admin/list.ts +++ b/lib/admin/list.ts @@ -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) => ({ diff --git a/lib/admin/retry.ts b/lib/admin/retry.ts index 9c75f60..2abf939 100644 --- a/lib/admin/retry.ts +++ b/lib/admin/retry.ts @@ -126,3 +126,33 @@ export async function withAdminRetry( if (threw) throw lastThrown; return lastResult as R; } + +/** + * Map over `items` running at most `concurrency` async tasks at a time, while + * preserving result order. + * + * Firing every GoTrue admin lookup at once (e.g. one `getUserById` per tunnel + * row) can self-inflict an upstream throttle: the proxy truncates the tail of a + * large concurrent burst, producing the very empty-body responses we retry on — + * and because the retries fire back into the same saturated window, a few rows + * can still fail. Bounding the concurrency keeps each lookup inside the + * throttle's allowance so {@link withAdminRetry} reliably resolves every row. + */ +export async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + task: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + const limit = Math.max(1, Math.min(concurrency, items.length || 1)); + let next = 0; + async function worker(): Promise { + for (;;) { + const i = next++; + if (i >= items.length) return; + results[i] = await task(items[i], i); + } + } + await Promise.all(Array.from({ length: limit }, () => worker())); + return results; +}