fix(admin): resolve tunnel owner emails via one listUsers scan

The upstream admin gateway deterministically truncates the first per-row
getUserById that immediately follows a request's auth check, yielding an
empty body the per-call retry cannot clear — so a couple of tunnel rows
showed owner_email '—' on every load regardless of retry or concurrency.
Replace the per-row getUserById fan-out with a single bounded listUsers
scan (same transient retry) that builds an id->email map and stops early
once every owner on the page is resolved. A single listUsers read does not
hit the truncation pattern, eliminating the dashes. Remove the now-unused
mapWithConcurrency helper and OWNER_EMAIL_CONCURRENCY constant.
This commit is contained in:
Gerhard Scheikl
2026-05-31 16:26:39 +02:00
parent 37f79ff1b1
commit cbd29445bb
2 changed files with 28 additions and 60 deletions
+27 -29
View File
@@ -1,6 +1,6 @@
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { withAdminRetry, mapWithConcurrency } from '@/lib/admin/retry'; import { withAdminRetry } from '@/lib/admin/retry';
import { import {
parseOrder, parseOrder,
parseSort, parseSort,
@@ -77,16 +77,6 @@ type TunnelRow = TunnelJoinRow & {
const USER_SCAN_MAX_PAGES = 50; const USER_SCAN_MAX_PAGES = 50;
const USER_SCAN_PER_PAGE = 1000; 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 { function userSortValue(u: User, sort: UserSort): string | number {
switch (sort) { switch (sort) {
case 'email': case 'email':
@@ -299,26 +289,34 @@ export async function getTunnelsList(opts: {
total = count ?? rows.length; total = count ?? rows.length;
} }
// Resolve owner emails (per-row getUserById; acceptable for current scale). // Resolve owner emails via a single bounded listUsers scan rather than one
// The user_id comes from an existing tunnel row, so an empty body here is a // getUserById per row. The upstream admin gateway deterministically truncates
// transient burst flake rather than a genuine not-found — retry it, and bound // the first per-row lookup that immediately follows the request's auth check
// the concurrency so the enrichment doesn't self-throttle. The try/catch null // (an empty body the per-call retry can't clear), which surfaced as "—" on
// fallback remains as a last resort so one bad row can never 500 the whole // every load. A single listUsers read does not hit that pattern, so we scan
// list (it surfaces as "—" only if every retry still fails). // the directory once (bounded, with the same transient-retry) and build an
const emails = await mapWithConcurrency( // id→email map, stopping as soon as every owner on this page is resolved.
rows, const wanted = new Set(rows.map((t) => t.user_id));
OWNER_EMAIL_CONCURRENCY, const emailById = new Map<string, string | null>();
async (t) => { if (wanted.size > 0) {
try { for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
const { data: u } = await withAdminRetry(() => const { data, error } = await withAdminRetry(() =>
admin.auth.admin.getUserById(t.user_id), admin.auth.admin.listUsers({ page: p, perPage: USER_SCAN_PER_PAGE }),
); );
return u.user?.email ?? null; if (error) throw new Error(error.message);
} catch { const us = data.users;
return null; 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) => ({ const tunnels: TunnelItem[] = rows.map((t, i) => ({
user_id: t.user_id, user_id: t.user_id,
-30
View File
@@ -126,33 +126,3 @@ export async function withAdminRetry<R extends { error: MaybeError }>(
if (threw) throw lastThrown; if (threw) throw lastThrown;
return lastResult as R; 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<T, R>(
items: readonly T[],
concurrency: number,
task: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
const limit = Math.max(1, Math.min(concurrency, items.length || 1));
let next = 0;
async function worker(): Promise<void> {
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;
}