perf(desktop): bound message-window query cache by an LRU timeline window

Every visited channel and thread leaves its query-cache keys in place for
gcTime (one hour) after the view unmounts, so a session that roams many
channels holds every timeline's window store and flattened render array in
RAM at once — a renderer memory growth behind the v0.5.9 scroll/input lag.
gcTime bounds how long an inactive query lingers, not how many linger
concurrently; this adds the missing concurrency bound.

A cache subscription mounted once in CommunityQueryProvider evicts the
least-recently-updated unpinned timelines past a fixed cap. Channels and
threads are bounded independently so a burst of open threads never evicts
channel scrollback. A unit is pinned while any of its queries has a mounted
observer or holds an optimistic pending send. Eviction removes both channel
keys together and reads back absent, so revisit re-fetches from SQLite and a
guarded background write cannot resurrect an evicted key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-11 15:48:17 -04:00
parent 5b10fe02d9
commit e7f47fa8db
6 changed files with 544 additions and 0 deletions

View File

@@ -59,6 +59,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
import { useBoundedMessageWindows } from "@/features/messages/useBoundedMessageWindows";
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
@@ -213,6 +214,8 @@ function CommunitySwitchGate() {
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);
useBoundedMessageWindows(queryClient);
useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);
useEffect(() => {

View File

@@ -0,0 +1,208 @@
/**
* Store-level tests for enforceMessageWindowBounds against a REAL QueryClient.
*
* These exercise the collect → fold → select → remove path at the cache
* boundary — the layer above the pure policy unit — asserting:
* - channels and threads are bounded independently at their caps;
* - eviction is least-recently-updated first;
* - evicting a channel removes BOTH its window and messages keys together;
* - an active channel (mounted observer) is never evicted, even when stale;
* - a pending send pins its channel — including the cross-key case where the
* optimistic event lands on a non-visible channel-window `liveOverlay`;
* - an evicted channel reads back absent → the freshness seam re-fetches;
* - a guarded background write cannot resurrect an evicted key (durable
* truncation contract).
*/
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import {
channelMessagesKey,
channelWindowKey,
threadRepliesKey,
} from "./messageQueryKeys.ts";
import {
emptyChannelWindowStore,
mergeLiveChannelWindowEvent,
} from "./channelWindowStore.ts";
import { shouldRefreshChannelWindowAfterSubscribe } from "./projectChannelWindow.ts";
import {
enforceMessageWindowBounds,
MAX_RETAINED_MESSAGE_CHANNELS,
MAX_RETAINED_MESSAGE_THREADS,
} from "./boundMessageWindows.ts";
const CHAN_CAP = MAX_RETAINED_MESSAGE_CHANNELS;
const THREAD_CAP = MAX_RETAINED_MESSAGE_THREADS;
let client;
/** A relay event; `pending` marks an un-acked optimistic send. */
function event(id, createdAt, extra = {}) {
return {
id: id.padEnd(64, "0"),
pubkey: "a".repeat(64),
created_at: createdAt,
kind: 9,
tags: [["h", "channel"]],
content: id,
sig: "b".repeat(128),
...extra,
};
}
/**
* Seed a channel's window + messages keys at a given recency. `updatedAt`
* drives `dataUpdatedAt` so the LRU order is deterministic in tests.
*/
function seedChannel(channelId, updatedAt, { pendingSend = false } = {}) {
let store = emptyChannelWindowStore();
if (pendingSend) {
store = mergeLiveChannelWindowEvent(
store,
event(`pending-${channelId}`, updatedAt, { pending: true }),
);
}
client.setQueryData(channelWindowKey(channelId), store, { updatedAt });
client.setQueryData(channelMessagesKey(channelId), [], { updatedAt });
}
function seedThread(channelId, rootId, updatedAt) {
client.setQueryData(threadRepliesKey(channelId, rootId), [], { updatedAt });
}
/** Mount an observer so the query reports `isActive()` — a pinned view. */
function mountChannel(channelId) {
const windowObserver = new QueryObserver(client, {
queryKey: channelWindowKey(channelId),
enabled: true,
});
const messagesObserver = new QueryObserver(client, {
queryKey: channelMessagesKey(channelId),
enabled: true,
});
const unsubWindow = windowObserver.subscribe(() => {});
const unsubMessages = messagesObserver.subscribe(() => {});
return () => {
unsubWindow();
unsubMessages();
};
}
function hasChannel(channelId) {
return (
client.getQueryData(channelWindowKey(channelId)) !== undefined ||
client.getQueryData(channelMessagesKey(channelId)) !== undefined
);
}
describe("enforceMessageWindowBounds", () => {
beforeEach(() => {
client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
});
it("test_channels_up_to_cap_are_all_retained", () => {
for (let i = 0; i < CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
enforceMessageWindowBounds(client);
for (let i = 0; i < CHAN_CAP; i += 1) {
assert.ok(hasChannel(`chan-${i}`), `chan-${i} must be retained at cap`);
}
});
it("test_exceeding_cap_evicts_least_recently_updated_channel", () => {
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
enforceMessageWindowBounds(client);
assert.equal(hasChannel("chan-0"), false, "oldest channel evicted");
for (let i = 1; i <= CHAN_CAP; i += 1) {
assert.ok(hasChannel(`chan-${i}`), `chan-${i} must survive`);
}
});
it("test_evicting_a_channel_removes_both_window_and_messages_keys", () => {
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
enforceMessageWindowBounds(client);
assert.equal(
client.getQueryData(channelWindowKey("chan-0")),
undefined,
"window key gone",
);
assert.equal(
client.getQueryData(channelMessagesKey("chan-0")),
undefined,
"messages key gone",
);
});
it("test_active_channel_is_never_evicted_even_when_least_recent", () => {
// chan-0 is the oldest (recency 0) but mounted → pinned. One extra channel
// over cap forces an eviction; the next-oldest unpinned (chan-1) goes.
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
const unmount = mountChannel("chan-0");
enforceMessageWindowBounds(client);
assert.ok(hasChannel("chan-0"), "active channel pinned");
assert.equal(hasChannel("chan-1"), false, "next-oldest unpinned evicted");
unmount();
});
it("test_pending_send_pins_its_channel_via_window_overlay", () => {
// chan-0 is oldest and inactive but holds an optimistic send in its window
// overlay (the cross-key send-from-thread seam) → pinned. chan-1 evicted.
for (let i = 0; i <= CHAN_CAP; i += 1) {
seedChannel(`chan-${i}`, i, { pendingSend: i === 0 });
}
enforceMessageWindowBounds(client);
assert.ok(hasChannel("chan-0"), "channel with pending send pinned");
assert.equal(hasChannel("chan-1"), false, "next-oldest unpinned evicted");
});
it("test_threads_are_bounded_independently_from_channels", () => {
// Many channels AND many threads over both caps: each bounded to its own
// cap, neither starving the other.
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
for (let i = 0; i <= THREAD_CAP; i += 1) seedThread("c", `root-${i}`, i);
enforceMessageWindowBounds(client);
// Oldest of each evicted; newest survive.
assert.equal(hasChannel("chan-0"), false);
assert.equal(
client.getQueryData(threadRepliesKey("c", "root-0")),
undefined,
"oldest thread evicted",
);
assert.notEqual(
client.getQueryData(threadRepliesKey("c", `root-${THREAD_CAP}`)),
undefined,
"newest thread survives",
);
});
it("test_evicted_channel_reads_absent_so_revisit_refetches", () => {
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
enforceMessageWindowBounds(client);
// Freshness seam: an evicted channel has no messages state, so a resubscribe
// must refresh (returns true) rather than reading a stale window as fresh.
assert.equal(
shouldRefreshChannelWindowAfterSubscribe(client, "chan-0"),
true,
"evicted channel must refetch on revisit",
);
});
it("test_guarded_background_write_does_not_resurrect_evicted_channel", () => {
for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i);
enforceMessageWindowBounds(client);
// The background live path writes messages with a guarded updater that
// no-ops when the key is absent. It must NOT rebuild the evicted key.
client.setQueryData(channelMessagesKey("chan-0"), (current) =>
!current ? current : [...current, event("live", 999)],
);
assert.equal(
client.getQueryData(channelMessagesKey("chan-0")),
undefined,
"guarded write must not resurrect an evicted key",
);
});
});

View File

@@ -0,0 +1,128 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import type { ChannelWindowStore } from "./channelWindowStore";
import type { RelayEvent } from "@/shared/api/types";
import {
selectMessageWindowEvictionUnits,
type MessageWindowUnit,
} from "./messageWindowEviction";
/**
* Maximum number of distinct unpinned channel timelines and thread subtrees
* retained in the query cache. Channels and threads are bounded independently
* so a burst of open threads never evicts channel scrollback and vice versa.
* A revisit re-fetches from the relay (cache miss, not data loss).
*/
export const MAX_RETAINED_MESSAGE_CHANNELS = 12;
export const MAX_RETAINED_MESSAGE_THREADS = 24;
const CHANNEL_WINDOW = "channel-window";
const CHANNEL_MESSAGES = "channel-messages";
const THREAD_REPLIES = "thread-replies";
/** A cached message query keyed by its parsed unit id, tagged by kind. */
type ClassifiedQuery = {
query: Query;
unitId: string;
kind: "channel" | "thread";
};
/**
* Classify a cache query as a channel-timeline key, a thread key, or neither.
* Channel units fold both `channel-window` and `channel-messages` under the
* channel id so they evict together; thread units key on channel + root.
*/
function classifyQuery(query: Query): ClassifiedQuery | null {
const key = query.queryKey;
const head = key[0];
if (
(head === CHANNEL_WINDOW || head === CHANNEL_MESSAGES) &&
typeof key[1] === "string"
) {
return { query, unitId: key[1], kind: "channel" };
}
if (
head === THREAD_REPLIES &&
typeof key[1] === "string" &&
typeof key[2] === "string"
) {
return { query, unitId: `${key[1]}\u0000${key[2]}`, kind: "thread" };
}
return null;
}
/** Whether a query's cached data holds an optimistic pending send. */
function holdsPendingSend(query: Query): boolean {
const data = query.state.data;
if (Array.isArray(data)) {
return (data as RelayEvent[]).some((event) => event.pending);
}
// A channel-window store keeps optimistic sends in its live overlay — the
// seam that catches a send-from-thread landing on a non-visible channel.
const overlay = (data as ChannelWindowStore | undefined)?.liveOverlay;
return Array.isArray(overlay) && overlay.some((event) => event.pending);
}
/**
* Fold classified queries into eviction units. A unit is pinned when ANY of
* its queries has a mounted observer (the view is on screen — the "none"
* placeholder channel is inactive and so never pins) or holds a pending send.
* Recency is the freshest `dataUpdatedAt` across the unit's queries.
*/
function foldUnits(queries: ClassifiedQuery[]): Map<string, MessageWindowUnit> {
const units = new Map<string, MessageWindowUnit>();
for (const { query, unitId } of queries) {
const pinned = query.isActive() || holdsPendingSend(query);
const recency = query.state.dataUpdatedAt;
const existing = units.get(unitId);
if (existing) {
existing.pinned ||= pinned;
existing.recency = Math.max(existing.recency, recency);
} else {
units.set(unitId, { unitId, pinned, recency });
}
}
return units;
}
/**
* Enforce the retained-timeline bound on the query cache. Collects every
* channel and thread query, folds them into pinned/recency units, selects the
* least-recently-updated unpinned units beyond the caps, and removes their
* queries. Channels and threads are bounded independently.
*
* Removal (not invalidation) is deliberate: a removed key reads back as absent,
* so `shouldRefreshChannelWindowAfterSubscribe` re-fetches fresh on revisit and
* a guarded background write (`(current) => !current ? current : merge`) is a
* no-op that cannot silently resurrect the evicted key.
*/
export function enforceMessageWindowBounds(queryClient: QueryClient): void {
const channels: ClassifiedQuery[] = [];
const threads: ClassifiedQuery[] = [];
for (const query of queryClient.getQueryCache().getAll()) {
const classified = classifyQuery(query);
if (!classified) continue;
(classified.kind === "channel" ? channels : threads).push(classified);
}
const evictChannels = new Set(
selectMessageWindowEvictionUnits(
[...foldUnits(channels).values()],
MAX_RETAINED_MESSAGE_CHANNELS,
),
);
const evictThreads = new Set(
selectMessageWindowEvictionUnits(
[...foldUnits(threads).values()],
MAX_RETAINED_MESSAGE_THREADS,
),
);
if (evictChannels.size === 0 && evictThreads.size === 0) return;
for (const { query, unitId, kind } of [...channels, ...threads]) {
const evict = kind === "channel" ? evictChannels : evictThreads;
if (evict.has(unitId)) {
queryClient.getQueryCache().remove(query);
}
}
}

View File

@@ -0,0 +1,89 @@
/**
* Unit tests for selectMessageWindowEvictionUnits — the pure LRU-with-pins
* policy that bounds the message query-cache windows. Exercises the selection
* logic directly, with no React/React-Query runtime.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { selectMessageWindowEvictionUnits } from "./messageWindowEviction.ts";
/** Build one eviction unit. */
function unit(unitId, recency, pinned = false) {
return { unitId, recency, pinned };
}
describe("selectMessageWindowEvictionUnits", () => {
it("test_empty_units_returns_no_eviction", () => {
assert.deepEqual(selectMessageWindowEvictionUnits([], 8), []);
});
it("test_count_below_cap_returns_no_eviction", () => {
const units = [unit("a", 1), unit("b", 2), unit("c", 3)];
assert.deepEqual(selectMessageWindowEvictionUnits(units, 8), []);
});
it("test_count_exactly_at_cap_returns_no_eviction", () => {
const units = Array.from({ length: 8 }, (_, i) => unit(`u-${i}`, i));
assert.deepEqual(selectMessageWindowEvictionUnits(units, 8), []);
});
it("test_over_cap_evicts_least_recently_updated_units", () => {
// 10 units, cap 8 → evict the 2 lowest-recency (u-0, u-1).
const units = Array.from({ length: 10 }, (_, i) => unit(`u-${i}`, i));
const evicted = selectMessageWindowEvictionUnits(units, 8);
assert.deepEqual(evicted.sort(), ["u-0", "u-1"].sort());
});
it("test_pinned_unit_is_never_evicted_even_when_least_recent", () => {
// u-pin is least recent but pinned → survives; u-1 evicted instead.
const units = [
unit("u-pin", 0, true),
unit("u-1", 1),
unit("u-2", 2),
unit("u-3", 3),
];
const evicted = selectMessageWindowEvictionUnits(units, 3);
assert.deepEqual(evicted, ["u-1"]);
});
it("test_pins_exceeding_cap_evict_all_unpinned_but_keep_all_pinned", () => {
// 3 pinned + 2 unpinned, cap 2. Pins alone exceed cap → unpinned budget 0.
const units = [
unit("pin-1", 10, true),
unit("pin-2", 11, true),
unit("pin-3", 12, true),
unit("free-1", 1),
unit("free-2", 2),
];
const evicted = selectMessageWindowEvictionUnits(units, 2);
assert.deepEqual(evicted.sort(), ["free-1", "free-2"].sort());
});
it("test_pinned_units_count_against_cap_for_unpinned_budget", () => {
// 1 pinned + 3 unpinned, cap 2 → unpinned budget = 1. Keep newest unpinned.
const units = [
unit("pin-1", 5, true),
unit("free-1", 1),
unit("free-2", 2),
unit("free-3", 3),
];
const evicted = selectMessageWindowEvictionUnits(units, 2);
assert.deepEqual(evicted.sort(), ["free-1", "free-2"].sort());
});
it("test_tie_broken_deterministically_by_unit_id", () => {
// unit-a and unit-b share recency 1 at the boundary; keep-sort breaks ties
// by ascending unitId, so unit-a is retained and unit-b evicted.
const units = [unit("unit-b", 1), unit("unit-a", 1), unit("unit-keep", 9)];
const evicted = selectMessageWindowEvictionUnits(units, 2);
assert.deepEqual(evicted, ["unit-b"]);
});
it("test_zero_cap_evicts_all_unpinned_units", () => {
const units = [unit("a", 1), unit("b", 2), unit("pin", 3, true)];
const evicted = selectMessageWindowEvictionUnits(units, 0);
assert.deepEqual(evicted.sort(), ["a", "b"].sort());
});
});

View File

@@ -0,0 +1,65 @@
/**
* Pure LRU-with-pins eviction policy for the message query-cache windows.
*
* The channel timeline lives in the React Query cache as two keys per channel
* (`channel-window` holding the authoritative window store and
* `channel-messages` holding the flattened render array) plus one
* `thread-replies` key per open thread root. Every visited channel or thread
* leaves its keys in the cache for `gcTime` (one hour) after the view unmounts,
* so a session that visits many channels accumulates every timeline's window
* store and flattened array at once — a renderer memory leak behind the v0.5.9
* lag. React Query's `gcTime` bounds *how long* an inactive query lingers, not
* *how many* linger concurrently; this policy adds the missing concurrency
* bound.
*
* Eviction is at unit granularity — a channel unit owns both of its keys, a
* thread unit owns its single key — because the two channel keys are one
* timeline: dropping the window store while keeping the flattened array leaves
* a render array that live merges keep re-growing against no backing store.
*
* Pins are inviolable: a unit is never evicted while any of its queries has a
* mounted observer (the view is on screen) or holds an optimistic pending send
* (dropping it would lose an in-flight message the relay has not yet echoed).
* The cap is enforced only against unpinned units, even when pins alone exceed
* it.
*
* Pure and React/React-Query-free so the selection logic is unit-testable in
* isolation, mirroring the Target 1 `observerArchiveEviction` extraction.
*/
export interface MessageWindowUnit {
/** Stable identifier for the eviction unit (channel id, or channel+root). */
unitId: string;
/** Most recent `dataUpdatedAt` across the unit's queries; higher = fresher. */
recency: number;
/** True when the unit must be retained: mounted observer or pending send. */
pinned: boolean;
}
/**
* Select the unit ids to evict so that no more than `cap` distinct unpinned
* units are retained. Pinned units never appear in the result.
*
* @param units One entry per eviction unit (channel or thread).
* @param cap Maximum distinct unpinned units to keep.
*/
export function selectMessageWindowEvictionUnits(
units: readonly MessageWindowUnit[],
cap: number,
): string[] {
const unpinned = units.filter((unit) => !unit.pinned);
const pinnedCount = units.length - unpinned.length;
const unpinnedBudget = Math.max(0, cap - pinnedCount);
if (unpinned.length <= unpinnedBudget) {
return [];
}
// Keep the most-recent `unpinnedBudget` units; evict the rest. Sort by
// recency descending, breaking ties by unitId so selection is deterministic.
const evictable = [...unpinned].sort(
(a, b) =>
b.recency - a.recency ||
(a.unitId < b.unitId ? -1 : a.unitId > b.unitId ? 1 : 0),
);
return evictable.slice(unpinnedBudget).map((unit) => unit.unitId);
}

View File

@@ -0,0 +1,51 @@
import type { QueryClient } from "@tanstack/react-query";
import * as React from "react";
import { enforceMessageWindowBounds } from "./lib/boundMessageWindows";
const BOUNDED_MESSAGE_KEYS = new Set([
"channel-window",
"channel-messages",
"thread-replies",
]);
/**
* Bound the number of channel timelines and thread subtrees retained in the
* query cache. Every visited channel/thread lingers for `gcTime` (one hour)
* after its view unmounts, so a session that roams many channels accumulates
* every timeline's window store and flattened array at once — the renderer
* memory leak behind the v0.5.9 lag. This mounts one cache subscription per
* QueryClient that evicts the least-recently-updated unpinned timelines past a
* fixed cap (see `enforceMessageWindowBounds`).
*
* Mounted once inside `CommunityQueryProvider`, so the bound applies to every
* window that renders it — the main app, the huddle room, and the companion —
* each on its own client.
*
* A new message key entering the cache is the only event that can push the
* count over its cap, so the sweep is triggered on `added` for a bounded key
* and coalesced to one run per microtask (a channel open adds two keys at
* once). Removals emit `removed`, never `added`, so eviction cannot re-trigger
* itself. The sweep runs synchronously in a single tick — collect then remove
* — so a view mounting mid-sweep cannot be misread as inactive.
*/
export function useBoundedMessageWindows(queryClient: QueryClient): void {
React.useEffect(() => {
let scheduled = false;
const sweep = () => {
scheduled = false;
enforceMessageWindowBounds(queryClient);
};
return queryClient.getQueryCache().subscribe((event) => {
if (
event.type !== "added" ||
!BOUNDED_MESSAGE_KEYS.has(event.query.queryKey[0])
) {
return;
}
if (scheduled) return;
scheduled = true;
queueMicrotask(sweep);
});
}, [queryClient]);
}