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:
+28
-30
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user