perf(desktop): debounce channel-list refetch + profile get_channels (#1310)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-26 12:50:21 -06:00
committed by GitHub
parent 8d40150c83
commit c6e3e947ab
6 changed files with 272 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ use crate::{
#[tauri::command]
pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>, String> {
let _profile_start = std::time::Instant::now();
let my_pubkey = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
keys.public_key().to_hex()
@@ -40,6 +41,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
all
};
#[cfg(debug_assertions)]
let t_members = _profile_start.elapsed();
let mut channel_ids: Vec<String> = member_events
.iter()
.filter_map(|ev| {
@@ -75,6 +79,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
Vec::new()
};
#[cfg(debug_assertions)]
let t_member_meta = _profile_start.elapsed();
// Step 3: fetch ALL open channel metadata so the channel browser can show
// discoverable channels the user hasn't joined yet. The relay's access
// control allows reading kind:39000 for open channels regardless of membership.
@@ -87,6 +94,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
)
.await?;
#[cfg(debug_assertions)]
let t_open_meta = _profile_start.elapsed();
// Merge: member channels (marked as member) + open channels (not yet joined).
let member_d_tags: std::collections::HashSet<String> = meta_events
.iter()
@@ -156,6 +166,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
}
}
#[cfg(debug_assertions)]
let t_member_counts = _profile_start.elapsed();
// Populate last_message_at by fetching the most recent human message per
// channel. Uses per-channel filters (single #h value each) so the relay can
// push the query to its indexed channel_id column. Multi-value #h is NOT
@@ -201,6 +214,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
}
}
#[cfg(debug_assertions)]
let t_last_message = _profile_start.elapsed();
// NIP-DV: drop DMs the viewer has hidden. The relay maintains a per-viewer
// parameterized-replaceable snapshot (kind:30622, d=my pubkey) whose `h`
// tags list currently-hidden DM channel ids. The snapshot also carries
@@ -236,6 +252,22 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id));
}
#[cfg(debug_assertions)]
{
let total = _profile_start.elapsed();
eprintln!(
"buzz-desktop: get_channels profile channels={} members={:?} member_meta={:?} open_meta={:?} member_counts={:?} last_message={:?} hidden_dm={:?} total={:?}",
channels.len(),
t_members,
t_member_meta - t_members,
t_open_meta - t_member_meta,
t_member_counts - t_open_meta,
t_last_message - t_member_counts,
total - t_last_message,
total,
);
}
Ok(channels)
}

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle.ts";
function spy() {
const fn = () => {
fn.calls++;
};
fn.calls = 0;
return fn;
}
test("invalidates when no fetch is in flight", () => {
const invalidate = spy();
const reArm = spy();
refreshChannelsWhenIdle({ isFetching: () => 0, invalidate, reArm });
assert.equal(invalidate.calls, 1);
assert.equal(reArm.calls, 0);
});
test("re-arms instead of invalidating while a fetch is in flight", () => {
const invalidate = spy();
const reArm = spy();
refreshChannelsWhenIdle({ isFetching: () => 1, invalidate, reArm });
assert.equal(
invalidate.calls,
0,
"must not invalidate mid-flight (would drop the dirty signal)",
);
assert.equal(reArm.calls, 1);
});
test("re-arm then idle eventually invalidates exactly once", () => {
const invalidate = spy();
const reArm = spy();
// First pass: fetch in flight -> re-arm.
let fetching = 1;
const deps = {
isFetching: () => fetching,
invalidate,
// Simulate the debounce re-firing the same action.
reArm: () => {
reArm.calls++;
refreshChannelsWhenIdle(deps);
},
};
// Once re-armed, the fetch has finished.
reArm.calls = 0;
const reArmOnce = deps.reArm;
deps.reArm = () => {
fetching = 0;
reArmOnce();
};
refreshChannelsWhenIdle(deps);
assert.equal(reArm.calls, 1, "re-armed once while fetching");
assert.equal(invalidate.calls, 1, "invalidated once after going idle");
});

View File

@@ -0,0 +1,23 @@
type RefreshDeps = {
/** >0 when the channels query is currently fetching. */
isFetching: () => number;
/** Mark the channels query dirty so it refetches. */
invalidate: () => void;
/** Re-arm the trailing debounce to retry after the next quiet window. */
reArm: () => void;
};
/**
* Refresh the channel list without overlapping or dropping a dirty signal.
*
* If get_channels is mid-flight, invalidating now would be silently undone: the
* in-flight fetch resolves with pre-event data and clears the dirty flag. So we
* re-arm instead and let a clean refetch land once the query is idle.
*/
export function refreshChannelsWhenIdle(deps: RefreshDeps): void {
if (deps.isFetching() > 0) {
deps.reArm();
return;
}
deps.invalidate();
}

View File

@@ -15,8 +15,13 @@ import {
CHANNEL_MESSAGE_EVENT_KINDS,
} from "@/shared/constants/kinds";
import type { Channel, RelayEvent } from "@/shared/api/types";
import {
createTrailingDebounce,
type TrailingDebounce,
} from "@/shared/lib/trailingDebounce";
import { isDmNotifiableKind } from "./isDmNotifiableKind";
import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle";
export type UseLiveChannelUpdatesOptions = {
currentPubkey?: string;
@@ -66,6 +71,11 @@ export type UseLiveChannelUpdatesOptions = {
const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000;
// get_channels is an expensive O(channels) relay fan-out. Incoming traffic for
// non-active channels arrives in bursts, so coalesce the refetch into a single
// trailing invalidation instead of one per event.
const CHANNELS_INVALIDATE_DEBOUNCE_MS = 500;
// Only "new content" kinds should bump unread state. Shared with the
// catch-up query in useUnreadChannels so the two paths stay in lockstep.
const UNREAD_TRIGGER_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
@@ -103,6 +113,22 @@ export function useLiveChannelUpdates(
const normalizedCurrentPubkey =
options.currentPubkey?.trim().toLowerCase() ?? "";
const seenMentionEventIdsRef = React.useRef(new Set<string>());
const channelsInvalidateRef = React.useRef<TrailingDebounce | null>(null);
if (channelsInvalidateRef.current === null) {
channelsInvalidateRef.current = createTrailingDebounce(() => {
refreshChannelsWhenIdle({
isFetching: () =>
queryClient.isFetching({ queryKey: channelsQueryKey }),
invalidate: () => {
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
reArm: () => channelsInvalidateRef.current?.trigger(),
});
}, CHANNELS_INVALIDATE_DEBOUNCE_MS);
}
const invalidateChannelsDebounced = React.useCallback(() => {
channelsInvalidateRef.current?.trigger();
}, []);
const liveChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
@@ -186,7 +212,7 @@ export function useLiveChannelUpdates(
if (!liveChannelIds.has(channelId)) {
if (channelId !== activeChannelId) {
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
invalidateChannelsDebounced();
}
return;
}
@@ -470,6 +496,8 @@ export function useLiveChannelUpdates(
React.useEffect(() => {
return () => {
channelsInvalidateRef.current?.cancel();
for (const dispose of liveSubsRef.current.values()) {
void dispose().catch(() => {});
}

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createTrailingDebounce } from "./trailingDebounce.ts";
// Deterministic timer host: timers fire only when we call advance(), so no real
// time elapses. Mirrors the injectable-timer style of relayStallWatchdog.
function makeHost() {
let nextId = 1;
const pending = new Map();
return {
host: {
setTimeout: (handler, ms) => {
const id = nextId++;
pending.set(id, { handler, remaining: ms });
return id;
},
clearTimeout: (id) => {
pending.delete(id);
},
},
advance: (ms) => {
for (const [id, t] of [...pending]) {
t.remaining -= ms;
if (t.remaining <= 0) {
pending.delete(id);
t.handler();
}
}
},
pendingCount: () => pending.size,
};
}
test("coalesces a burst into a single trailing call", () => {
const { host, advance } = makeHost();
let calls = 0;
const d = createTrailingDebounce(() => calls++, 500, host);
d.trigger();
advance(100);
d.trigger(); // restarts the window
advance(100);
d.trigger(); // restarts again
assert.equal(calls, 0, "must not fire during the burst");
advance(500); // quiet period elapses after the last trigger
assert.equal(calls, 1, "fires exactly once after quiet");
});
test("restarts the quiet window on every trigger", () => {
const { host, advance } = makeHost();
let calls = 0;
const d = createTrailingDebounce(() => calls++, 500, host);
d.trigger();
advance(499);
d.trigger(); // reset just before firing
advance(499);
assert.equal(calls, 0, "reset prevents the earlier timer from firing");
advance(1);
assert.equal(calls, 1);
});
test("cancel() drops the pending call", () => {
const { host, advance, pendingCount } = makeHost();
let calls = 0;
const d = createTrailingDebounce(() => calls++, 500, host);
d.trigger();
d.cancel();
assert.equal(pendingCount(), 0, "no timer left pending");
advance(500);
assert.equal(calls, 0, "cancelled action never runs");
});
test("a later trigger after firing schedules a fresh call", () => {
const { host, advance } = makeHost();
let calls = 0;
const d = createTrailingDebounce(() => calls++, 500, host);
d.trigger();
advance(500);
assert.equal(calls, 1);
d.trigger();
advance(500);
assert.equal(calls, 2);
});

View File

@@ -0,0 +1,40 @@
export type TrailingDebounce = {
/** Schedule the action, restarting the quiet window on every call. */
trigger: () => void;
/** Cancel any pending action (e.g. on unmount). */
cancel: () => void;
};
type TimerHost = {
setTimeout: (handler: () => void, ms: number) => number;
clearTimeout: (id: number) => void;
};
/**
* Trailing debounce: a burst of `trigger()` calls collapses into a single
* `action()` that runs once, `delayMs` after the last call. The timer is
* restarted on every trigger, so a sustained burst keeps deferring until quiet.
*/
export function createTrailingDebounce(
action: () => void,
delayMs: number,
host: TimerHost = window,
): TrailingDebounce {
let timeoutId: number | undefined;
const cancel = () => {
if (timeoutId !== undefined) {
host.clearTimeout(timeoutId);
timeoutId = undefined;
}
};
return {
trigger: () => {
cancel();
timeoutId = host.setTimeout(() => {
timeoutId = undefined;
action();
}, delayMs);
},
cancel,
};
}