mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
@@ -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(() => {
|
||||
|
||||
208
desktop/src/features/messages/lib/boundMessageWindows.test.mjs
Normal file
208
desktop/src/features/messages/lib/boundMessageWindows.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
128
desktop/src/features/messages/lib/boundMessageWindows.ts
Normal file
128
desktop/src/features/messages/lib/boundMessageWindows.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
65
desktop/src/features/messages/lib/messageWindowEviction.ts
Normal file
65
desktop/src/features/messages/lib/messageWindowEviction.ts
Normal 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);
|
||||
}
|
||||
51
desktop/src/features/messages/useBoundedMessageWindows.ts
Normal file
51
desktop/src/features/messages/useBoundedMessageWindows.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user