mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
|
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
|
import {
|
|
isRelayConnectionDegraded,
|
|
useRelayConnection,
|
|
} from "@/shared/api/useRelayConnection";
|
|
import {
|
|
isRateLimited,
|
|
waitForRateLimit,
|
|
} from "@/shared/api/relayRateLimitGate";
|
|
|
|
export const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
|
|
|
|
/**
|
|
* Tracks degraded→connected transitions and fires `onHeal` at most once per
|
|
* `minIntervalMs`. When a transition is suppressed by the rate limiter, a
|
|
* deferred heal is scheduled for the remaining window so the *last* recovery
|
|
* always wins — stale query errors do not persist after reconnect.
|
|
*
|
|
* Injectable deps make this testable without React or DOM.
|
|
*/
|
|
export class RelayAutoHealScheduler {
|
|
private lastHealAt = -Infinity;
|
|
private deferredId: number | null = null;
|
|
private readonly onHeal: () => void;
|
|
private readonly minIntervalMs: number;
|
|
private readonly setTimeoutFn: (fn: () => void, ms: number) => number;
|
|
private readonly clearTimeoutFn: (id: number) => void;
|
|
private readonly nowFn: () => number;
|
|
|
|
constructor(
|
|
onHeal: () => void,
|
|
minIntervalMs: number,
|
|
setTimeoutFn: (fn: () => void, ms: number) => number,
|
|
clearTimeoutFn: (id: number) => void,
|
|
nowFn: () => number = () => Date.now(),
|
|
) {
|
|
this.onHeal = onHeal;
|
|
this.minIntervalMs = minIntervalMs;
|
|
this.setTimeoutFn = setTimeoutFn;
|
|
this.clearTimeoutFn = clearTimeoutFn;
|
|
this.nowFn = nowFn;
|
|
}
|
|
|
|
/**
|
|
* Call on every connection-state change. Fires `onHeal` (immediately or
|
|
* deferred) when the transition is degraded→connected.
|
|
*/
|
|
onTransition(prev: ConnectionState, next: ConnectionState): void {
|
|
if (!(isRelayConnectionDegraded(prev) && next === "connected")) return;
|
|
|
|
// A new recovery supersedes any pending deferred heal.
|
|
if (this.deferredId !== null) {
|
|
this.clearTimeoutFn(this.deferredId);
|
|
this.deferredId = null;
|
|
}
|
|
|
|
const now = this.nowFn();
|
|
const elapsed = now - this.lastHealAt;
|
|
|
|
if (elapsed < this.minIntervalMs) {
|
|
// Rate-limited — schedule for when the window expires.
|
|
const remaining = this.minIntervalMs - elapsed;
|
|
this.deferredId = this.setTimeoutFn(() => {
|
|
this.deferredId = null;
|
|
this.lastHealAt = this.nowFn();
|
|
this.onHeal();
|
|
}, remaining);
|
|
} else {
|
|
this.lastHealAt = now;
|
|
this.onHeal();
|
|
}
|
|
}
|
|
|
|
/** Cancel any pending deferred heal (call on unmount). */
|
|
dispose(): void {
|
|
if (this.deferredId !== null) {
|
|
this.clearTimeoutFn(this.deferredId);
|
|
this.deferredId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-heal: when the connection recovers from a degraded state, invalidate
|
|
* relay-dependent queries so errored queries (e.g. messages, which don't poll)
|
|
* refetch automatically without requiring a manual reconnect action.
|
|
*
|
|
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
|
|
* firing a relay-wide invalidation across active queries with retry:1 every
|
|
* time the relay briefly recovers.
|
|
*
|
|
* When a recovery is suppressed by the rate limiter (an earlier flap consumed
|
|
* the budget), a deferred heal is scheduled for the remaining window so the
|
|
* *last* recovery always wins and stale query errors do not persist.
|
|
*/
|
|
export function useRelayAutoHeal(): void {
|
|
const queryClient = useQueryClient();
|
|
const connectionState = useRelayConnection();
|
|
const prevConnectionStateRef = React.useRef(connectionState);
|
|
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
|
|
|
|
if (schedulerRef.current === null) {
|
|
schedulerRef.current = new RelayAutoHealScheduler(
|
|
() => {
|
|
if (isRateLimited()) {
|
|
// Connection recovered but the relay is still under back-pressure.
|
|
// Defer the invalidate until the rate-limit window clears so queries
|
|
// don't immediately refetch and generate another burst.
|
|
void waitForRateLimit().then(() => {
|
|
void queryClient.invalidateQueries({
|
|
predicate: isRelayDependentQuery,
|
|
});
|
|
});
|
|
} else {
|
|
void queryClient.invalidateQueries({
|
|
predicate: isRelayDependentQuery,
|
|
});
|
|
}
|
|
},
|
|
AUTO_HEAL_MIN_INTERVAL_MS,
|
|
window.setTimeout.bind(window),
|
|
window.clearTimeout.bind(window),
|
|
);
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
schedulerRef.current?.dispose();
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const prev = prevConnectionStateRef.current;
|
|
prevConnectionStateRef.current = connectionState;
|
|
schedulerRef.current?.onTransition(prev, connectionState);
|
|
}, [connectionState]);
|
|
}
|