perf(desktop): limit reconnect query invalidation to relay data

Finding L3-2: auto-heal and manual reconnect used broad queryClient.invalidateQueries(), refetching unrelated Tauri/disk/local queries after relay recovery.

Route both reconnect paths through a shared relay-dependent query predicate. The taxonomy includes relay-backed messages/channels/profiles/social/workflows/projects state and excludes local-only agent/runtime/workspace-icon/repo queries.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-07-08 10:29:56 -04:00
parent 9da294968c
commit 980b7e8ed9
4 changed files with 183 additions and 11 deletions

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isRelayDependentQueryKey } from "./relayQueryInvalidation.ts";
test("relay invalidation includes relay-backed channel and profile queries", () => {
for (const queryKey of [
["channels"],
["channels", "channel-1", "members"],
["channel-messages", "channel-1"],
["thread-replies", "channel-1", "root-1"],
["forum-posts", "channel-1"],
["home-feed"],
["users-batch", "alice"],
["presence", "alice"],
["user-status", "alice"],
["relay-agents"],
["relayMembers"],
["archivedIdentities"],
["oaOwner", "alice"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
});
test("relay invalidation includes social/workflow relay queries", () => {
for (const queryKey of [
["global-notes"],
["liked-notes", "alice"],
["pulse-reactions", "note-1"],
["workflows", "channel-1"],
["workflows-all", "channel-1"],
["workflow-runs", "workflow-1"],
["run-approvals", "workflow-1", "run-1"],
["reminders", "alice"],
["custom-emoji"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
});
test("relay invalidation excludes local Tauri and disk-only query roots", () => {
for (const queryKey of [
["identity"],
["managed-agents"],
["personas"],
["teams"],
["acp-runtimes"],
["backend-providers"],
["managed-agent-log", "agent-1", 200],
["workspace-icon", "wss://relay.example"],
["agent-memory", "agent-1"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
}
});
test("relay invalidation separates relay project queries from local repo work", () => {
for (const queryKey of [
["projects"],
["project", "project-1"],
["project", "project-1", "issues"],
["project", "project-1", "pull-requests"],
["projects", "issues", ["project-1"]],
["projects", "activity-summaries", ["addr-1"]],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
for (const queryKey of [
["project", "project-1", "repo-state"],
["project", "project-1", "repo-snapshot", "main"],
["project", "project-1", "repo-diff", "main"],
["project", "project-1", "local-repo-diff"],
["project", "project-1", "commit-diff", "remote"],
["projects", "local-repositories", "default"],
["projects", "repo-snapshots", "default", ["project-1"]],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
}
});

View File

@@ -0,0 +1,84 @@
const RELAY_QUERY_ROOTS = new Set<string>([
"archivedIdentities",
"channel-canvas",
"channel-messages",
"channels",
"contact-list",
"custom-emoji",
"custom-emoji-own",
"forum-posts",
"forum-thread",
"global-notes",
"home-feed",
"liked-notes",
"my-notes",
"myRelayMembership",
"oaOwner",
"presence",
"profile",
"pulse-note",
"pulse-reactions",
"pulse-timeline",
"relay-agents",
"relayMembers",
"reminders",
"run-approvals",
"search-messages",
"thread-replies",
"user-profile",
"user-search",
"user-status",
"users-batch",
"workflow",
"workflow-runs",
"workflows",
"workflows-all",
]);
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
"activity-summaries",
"issues",
"pull-requests",
]);
const LOCAL_PROJECT_QUERY_PARTS = new Set<string>([
"commit-diff",
"local-repo-diff",
"local-repo-snapshot",
"local-repositories",
"repo-diff",
"repo-snapshot",
"repo-state",
"repo-sync-status",
]);
function isRelayDependentProjectQueryKey(queryKey: readonly unknown[]) {
if (queryKey[0] === "projects") {
const scope = queryKey[1];
if (scope === undefined) return true;
if (typeof scope !== "string") return false;
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
return RELAY_PROJECT_QUERY_PARTS.has(scope);
}
if (queryKey[0] === "project") {
const scope = queryKey[2];
if (scope === undefined) return true;
if (typeof scope !== "string") return false;
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
return RELAY_PROJECT_QUERY_PARTS.has(scope);
}
return false;
}
export function isRelayDependentQueryKey(queryKey: readonly unknown[]) {
const root = queryKey[0];
if (typeof root !== "string") return false;
if (RELAY_QUERY_ROOTS.has(root)) return true;
return isRelayDependentProjectQueryKey(queryKey);
}
export function isRelayDependentQuery(query: { queryKey: readonly unknown[] }) {
return isRelayDependentQueryKey(query.queryKey);
}

View File

@@ -16,6 +16,7 @@ import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { relayClient } from "@/shared/api/relayClient";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import { relayReconnectController } from "@/shared/api/relayReconnectController";
function buildDeps(onSuccess: () => void, onBackstop: () => void) {
@@ -59,12 +60,14 @@ export function useReconnectRelay(): {
onSuccessRef.current = React.useCallback(() => {
// Defer query invalidation so callers render the recovered state first.
window.setTimeout(() => {
void queryClient.invalidateQueries().catch((err) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
err,
);
});
void queryClient
.invalidateQueries({ predicate: isRelayDependentQuery })
.catch((err) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
err,
);
});
}, 0);
}, [queryClient]);

View File

@@ -3,6 +3,7 @@ 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,
@@ -82,12 +83,12 @@ export class RelayAutoHealScheduler {
/**
* Auto-heal: when the connection recovers from a degraded state, invalidate
* all queries so errored queries (e.g. messages, which don't poll) refetch
* automatically without requiring a manual reconnect action.
* 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 an unfiltered invalidation — ~20-40 requests across active queries
* with retry:1 — every time the relay briefly recovers.
* 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
@@ -101,7 +102,10 @@ export function useRelayAutoHeal(): void {
if (schedulerRef.current === null) {
schedulerRef.current = new RelayAutoHealScheduler(
() => void queryClient.invalidateQueries(),
() =>
void queryClient.invalidateQueries({
predicate: isRelayDependentQuery,
}),
AUTO_HEAL_MIN_INTERVAL_MS,
window.setTimeout.bind(window),
window.clearTimeout.bind(window),