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
+28 -30
View File
@@ -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<string, string | null>();
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,