mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Three client gaps turn transient relay failures into permanent UI degradation. Under a slow or rate-limited relay: 1. A cold channel's profile batch exhausts its single retry and leaves raw npubs + broken mention chips until the user manually kicks the channel. 2. A thread opened from a notification trusts a successful-but-empty reply read as authoritative and never retries. 3. A rate-limited `CLOSED` on a history subscription immediately rejects the caller rather than retrying after the rate-limit window. All three are addressed without changing global query defaults or the happy-path behavior. ## Changes **Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`, `desktop/src/features/profile/hooks.ts`) Override `retry: 3` with exponential backoff and error-gated `refetchOnWindowFocus: (query) => query.state.status === "error"`, scoped to this query only. The global defaults (`retry: 1`, `refetchOnWindowFocus: false`) are intentional for other queries and are unchanged. After the retry budget exhausts, a window-focus event (e.g. channel-switch) recovers the query automatically — but only when it is already in an error state, preventing unnecessary refetches for successful batches. **Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`, `ChannelScreen.tsx`) Add optional `expectedEventId` parameter. When a completed paged fetch does not contain the expected event, throw `ThreadExpectedEventMissingError` so React Query's built-in retry machinery handles it rather than caching an authoritative empty. `ChannelScreen` passes `threadScrollTargetId` (the notification-linked reply ID) as `expectedEventId`. When notification routing changes `expectedEventId` while the same thread root is already mounted (same query key), an explicit `invalidateQueries` in a `useEffect` triggers a fresh validation pass. The `useEffect` is declared after `useQuery` so TanStack's internal options-update effect installs the new `queryFn` closure first; the refetch therefore uses the current `expectedEventId` rather than the previous null closure. For the cold-start race (target arrives before the first page returns), the effect detects `fetchStatus === "fetching" && status === "pending"` and calls `cancelQueries().then(invalidateQueries)` so the obsolete in-flight response cannot settle as authoritative before the new target's validation closure is active. The query-fn tracks consecutive fetch attempts per target. On attempt 3, it adds the target to `exhaustedTargetsRef` before calling `loadThreadReplies`. `loadThreadReplies` sees the target in the exhausted set and returns the fetched replies directly rather than throwing — the terminal attempt always resolves to success. No re-entrant scheduling: the resolution is synchronous inside the query function itself. Deleted/moderated targets never lock the thread in a terminal error surface. **Fix 3 — CLOSED recovery for history subscriptions** (`relayClosedRecovery.ts`, `relayClientSession.ts`, `relayClientShared.ts`, `relayGateBoundary.ts`) On a rate-limited `CLOSED` the subscription previously rejected the caller immediately. Store `filter` and `timeoutMs` on `HistorySubscription`, then on rate-limited `CLOSED` re-register under a fresh `subId` and defer `sendReq` until the rate-limit window clears — matching the live-sub recovery design already present in `relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries reject immediately so callers are never left waiting indefinitely. A new op-timeout guards the retry REQ against a non-responding relay; when the op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the behavior of the original timeout path) so the relay releases the slot rather than counting it against the per-connection cap. ## Tests - `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send + late-EOSE non-regression, rejecting-`closeSubscription` swallowed without unhandled rejection, wiring source assertion (fails if `relayClientSession.ts` drops the `closeSubscription` callback) — 18 tests - `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests (throw/exhaustion-guard); behavioral hook tests via real `QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data, settled null→target change retries on missing-target page and lands target data (fails if `invalidateQueries` is removed OR if `useQuery` is moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated fetcher — released after rerender, stale empty discarded, replacement fetch settles with target); ChannelScreen wiring source assertion — 9 tests - `profileBatchResilience.test.mjs`: source assertions for `retry: 3`, `retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global defaults — 2 tests --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
/**
|
|
* Gate-aware relay operation boundaries.
|
|
*
|
|
* History REQ operations are issued here rather than inline in RelayClient so
|
|
* the gate-await + op-timeout pattern lives in one place and the op timeout
|
|
* budget starts only after the rate-limit window has cleared.
|
|
*/
|
|
import { collectWithConcurrency } from "@/shared/api/concurrency";
|
|
import { AUX_BACKFILL_CHUNK_SIZE } from "@/shared/api/relayChannelFilters";
|
|
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
|
|
import type {
|
|
RelaySubscription,
|
|
RelaySubscriptionFilter,
|
|
} from "@/shared/api/relayClientShared";
|
|
import type { RelayEvent } from "@/shared/api/types";
|
|
|
|
const AUX_BACKFILL_CONCURRENCY = 4;
|
|
|
|
/**
|
|
* Issue a history REQ on `filter`, waiting for any active rate-limit gate
|
|
* before starting the subscription so the op timeout begins only after
|
|
* back-pressure has cleared.
|
|
*/
|
|
export async function requestHistoryGated(
|
|
subscriptions: Map<string, RelaySubscription>,
|
|
sendRaw: (payload: unknown[]) => Promise<void>,
|
|
closeSubscription: (subId: string) => Promise<void>,
|
|
filter: RelaySubscriptionFilter,
|
|
historyTimeoutMs: number,
|
|
): Promise<RelayEvent[]> {
|
|
// Await the gate before issuing REQ; op timeout starts after the wait.
|
|
await waitForRateLimit();
|
|
|
|
return new Promise<RelayEvent[]>((resolve, reject) => {
|
|
const subId = `history-${crypto.randomUUID()}`;
|
|
const timeout = window.setTimeout(() => {
|
|
subscriptions.delete(subId);
|
|
void closeSubscription(subId);
|
|
reject(new Error("Timed out while loading channel history."));
|
|
}, historyTimeoutMs);
|
|
|
|
subscriptions.set(subId, {
|
|
mode: "history",
|
|
filter,
|
|
events: [],
|
|
resolve,
|
|
reject,
|
|
timeout,
|
|
timeoutMs: historyTimeoutMs,
|
|
});
|
|
|
|
void sendRaw(["REQ", subId, filter]).catch((error) => {
|
|
window.clearTimeout(timeout);
|
|
subscriptions.delete(subId);
|
|
reject(
|
|
error instanceof Error
|
|
? error
|
|
: new Error("Failed to request channel history."),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Issue a REQ that resolves as soon as its first matching event arrives.
|
|
*
|
|
* This keeps single-event lookups from waiting for EOSE while still resolving
|
|
* `null` when the relay completes an empty result set.
|
|
*/
|
|
export async function requestFirstEventGated(
|
|
subscriptions: Map<string, RelaySubscription>,
|
|
sendRaw: (payload: unknown[]) => Promise<void>,
|
|
closeSubscription: (subId: string) => Promise<void>,
|
|
filter: RelaySubscriptionFilter,
|
|
historyTimeoutMs: number,
|
|
): Promise<RelayEvent | null> {
|
|
await waitForRateLimit();
|
|
|
|
return new Promise<RelayEvent | null>((resolve, reject) => {
|
|
const subId = `first-${crypto.randomUUID()}`;
|
|
const timeout = window.setTimeout(() => {
|
|
subscriptions.delete(subId);
|
|
void closeSubscription(subId);
|
|
reject(new Error("Timed out while loading relay event."));
|
|
}, historyTimeoutMs);
|
|
|
|
subscriptions.set(subId, {
|
|
mode: "first",
|
|
onEvent: (event) => {
|
|
window.clearTimeout(timeout);
|
|
subscriptions.delete(subId);
|
|
void closeSubscription(subId);
|
|
resolve(event);
|
|
},
|
|
resolve,
|
|
reject,
|
|
timeout,
|
|
});
|
|
|
|
void sendRaw(["REQ", subId, filter]).catch((error) => {
|
|
window.clearTimeout(timeout);
|
|
subscriptions.delete(subId);
|
|
reject(
|
|
error instanceof Error
|
|
? error
|
|
: new Error("Failed to request relay event."),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch relay history for event IDs in bounded, concurrent chunks and flatten
|
|
* the responses into one list. Returns an empty list without issuing a request
|
|
* when `eventIds` is empty.
|
|
*/
|
|
export async function fetchChunkedHistory(
|
|
eventIds: string[],
|
|
buildFilter: (eventIds: string[]) => RelaySubscriptionFilter,
|
|
fetchHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>,
|
|
): Promise<RelayEvent[]> {
|
|
if (eventIds.length === 0) return [];
|
|
|
|
const chunks: string[][] = [];
|
|
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
|
|
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
|
|
}
|
|
const batches = await collectWithConcurrency(
|
|
chunks,
|
|
AUX_BACKFILL_CONCURRENCY,
|
|
(ids) => fetchHistory(buildFilter(ids)),
|
|
);
|
|
return batches.flat();
|
|
}
|