diff --git a/lib/admin/list.ts b/lib/admin/list.ts index 1398aa7..34d1f05 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, mapWithConcurrency } from '@/lib/admin/retry'; +import { withAdminRetry } from '@/lib/admin/retry'; import { parseOrder, parseSort, @@ -77,16 +77,6 @@ type TunnelRow = TunnelJoinRow & { const USER_SCAN_MAX_PAGES = 50; const USER_SCAN_PER_PAGE = 1000; -// Resolve tunnel owner emails a couple at a time rather than all at once. The -// upstream tolerates a small concurrent burst, but a large fan-out of -// getUserById calls (one per row, all in flight) trips an upstream throttle that -// truncates the tail of the burst into empty/partial bodies. Those truncations -// arrive faster than the per-call retry window can clear them, so a few rows -// were deterministically rendered as "—" on every list load. A low concurrency -// keeps each lookup inside the throttle allowance; measured 0 failures at 2 and -// consistent truncations at 4, so we stay conservative here. -const OWNER_EMAIL_CONCURRENCY = 2; - function userSortValue(u: User, sort: UserSort): string | number { switch (sort) { case 'email': @@ -299,26 +289,34 @@ export async function getTunnelsList(opts: { total = count ?? rows.length; } - // 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, 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), - ); - return u.user?.email ?? null; - } catch { - return null; + // Resolve owner emails via a single bounded listUsers scan rather than one + // getUserById per row. The upstream admin gateway deterministically truncates + // the first per-row lookup that immediately follows the request's auth check + // (an empty body the per-call retry can't clear), which surfaced as "—" on + // every load. A single listUsers read does not hit that pattern, so we scan + // the directory once (bounded, with the same transient-retry) and build an + // id→email map, stopping as soon as every owner on this page is resolved. + const wanted = new Set(rows.map((t) => t.user_id)); + const emailById = new Map(); + if (wanted.size > 0) { + for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) { + const { data, error } = await withAdminRetry(() => + admin.auth.admin.listUsers({ page: p, perPage: USER_SCAN_PER_PAGE }), + ); + if (error) throw new Error(error.message); + const us = data.users; + if (us.length === 0) break; + for (const u of us) { + if (wanted.has(u.id)) emailById.set(u.id, u.email ?? null); } - }, - ); + // Stop early once every owner on this tunnels page has been found. + if (rows.every((t) => emailById.has(t.user_id))) break; + if (us.length < USER_SCAN_PER_PAGE) break; + } + } + // Any owner not found in the scan falls back to "—" (null) — graceful, and a + // missing owner can never 500 the whole list. + const emails = rows.map((t) => emailById.get(t.user_id) ?? null); const tunnels: TunnelItem[] = rows.map((t, i) => ({ user_id: t.user_id, diff --git a/lib/admin/retry.ts b/lib/admin/retry.ts index 2abf939..9c75f60 100644 --- a/lib/admin/retry.ts +++ b/lib/admin/retry.ts @@ -126,33 +126,3 @@ 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; -}