mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
23
desktop/src/features/channels/refreshChannelsWhenIdle.ts
Normal file
23
desktop/src/features/channels/refreshChannelsWhenIdle.ts
Normal 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();
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
89
desktop/src/shared/lib/trailingDebounce.test.mjs
Normal file
89
desktop/src/shared/lib/trailingDebounce.test.mjs
Normal 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);
|
||||
});
|
||||
40
desktop/src/shared/lib/trailingDebounce.ts
Normal file
40
desktop/src/shared/lib/trailingDebounce.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user