diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 905771a4b85..5ed4a67d687 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -152,4 +152,23 @@ describe("createDirectRoomTracker", () => { }), ).resolves.toBe(false); }); + + it("bounds joined-room membership cache size", async () => { + const client = createMockClient({ isDm: false }); + const tracker = createDirectRoomTracker(client); + + for (let i = 0; i <= 1024; i += 1) { + await tracker.isDirectMessage({ + roomId: `!room-${i}:example.org`, + senderId: "@alice:example.org", + }); + } + + await tracker.isDirectMessage({ + roomId: "!room-0:example.org", + senderId: "@alice:example.org", + }); + + expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(1026); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index e58580f04f0..adc4f74a85c 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -11,6 +11,17 @@ type DirectRoomTrackerOptions = { }; const DM_CACHE_TTL_MS = 30_000; +const MAX_TRACKED_DM_ROOMS = 1024; + +function rememberBounded(map: Map, key: string, value: T): void { + map.set(key, value); + if (map.size > MAX_TRACKED_DM_ROOMS) { + const oldest = map.keys().next().value; + if (typeof oldest === "string") { + map.delete(oldest); + } + } +} export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) { const log = opts.log ?? (() => {}); @@ -55,7 +66,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter(Boolean); - joinedMembersCache.set(roomId, { members: normalized, ts: now }); + rememberBounded(joinedMembersCache, roomId, { members: normalized, ts: now }); return normalized; } catch (err) { log(`matrix: dm member lookup failed room=${roomId} (${String(err)})`); diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts new file mode 100644 index 00000000000..ec27c6c4880 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomInfoResolver } from "./room-info.js"; + +function createClientStub() { + return { + getRoomStateEvent: vi.fn( + async ( + roomId: string, + eventType: string, + stateKey: string, + ): Promise> => { + if (eventType === "m.room.name") { + return { name: `Room ${roomId}` }; + } + if (eventType === "m.room.canonical_alias") { + return { + alias: `#alias-${roomId}:example.org`, + alt_aliases: [`#alt-${roomId}:example.org`], + }; + } + if (eventType === "m.room.member") { + return { displayname: `Display ${roomId}:${stateKey}` }; + } + return {}; + }, + ), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; +} + +describe("createMatrixRoomInfoResolver", () => { + it("caches room info and member display names", async () => { + const client = createClientStub(); + const resolver = createMatrixRoomInfoResolver(client); + + await resolver.getRoomInfo("!room:example.org"); + await resolver.getRoomInfo("!room:example.org"); + await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); + await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(3); + }); + + it("bounds cached room and member entries", async () => { + const client = createClientStub(); + const resolver = createMatrixRoomInfoResolver(client); + + for (let i = 0; i <= 1024; i += 1) { + await resolver.getRoomInfo(`!room-${i}:example.org`); + } + await resolver.getRoomInfo("!room-0:example.org"); + + for (let i = 0; i <= 4096; i += 1) { + await resolver.getMemberDisplayName("!room:example.org", `@user-${i}:example.org`); + } + await resolver.getMemberDisplayName("!room:example.org", "@user-0:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(6150); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 095f1dc307a..2b5a01454d0 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -6,8 +6,22 @@ export type MatrixRoomInfo = { altAliases: string[]; }; +const MAX_TRACKED_ROOM_INFO = 1024; +const MAX_TRACKED_MEMBER_DISPLAY_NAMES = 4096; + +function rememberBounded(map: Map, key: string, value: T, maxEntries: number): void { + map.set(key, value); + if (map.size > maxEntries) { + const oldest = map.keys().next().value; + if (typeof oldest === "string") { + map.delete(oldest); + } + } +} + export function createMatrixRoomInfoResolver(client: MatrixClient) { const roomInfoCache = new Map(); + const memberDisplayNameCache = new Map(); const getRoomInfo = async (roomId: string): Promise => { const cached = roomInfoCache.get(roomId); @@ -40,16 +54,27 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { // ignore } const info = { name, canonicalAlias, altAliases }; - roomInfoCache.set(roomId, info); + rememberBounded(roomInfoCache, roomId, info, MAX_TRACKED_ROOM_INFO); return info; }; const getMemberDisplayName = async (roomId: string, userId: string): Promise => { + const cacheKey = `${roomId}:${userId}`; + const cached = memberDisplayNameCache.get(cacheKey); + if (cached) { + return cached; + } try { const memberState = await client .getRoomStateEvent(roomId, "m.room.member", userId) .catch(() => null); if (memberState && typeof memberState.displayname === "string") { + rememberBounded( + memberDisplayNameCache, + cacheKey, + memberState.displayname, + MAX_TRACKED_MEMBER_DISPLAY_NAMES, + ); return memberState.displayname; } return userId;