From 6d0cd54ac17bf2f8720157861f2cce6b1eb9e1e7 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 17:55:13 -0800 Subject: [PATCH] Slack: bound thread starter cache growth --- .../media.thread-starter-cache.test.ts | 87 +++++++++++++++++++ src/slack/monitor/media.ts | 49 ++++++++++- 2 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/slack/monitor/media.thread-starter-cache.test.ts diff --git a/src/slack/monitor/media.thread-starter-cache.test.ts b/src/slack/monitor/media.thread-starter-cache.test.ts new file mode 100644 index 000000000..45278acfe --- /dev/null +++ b/src/slack/monitor/media.thread-starter-cache.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetSlackThreadStarterCacheForTest, resolveSlackThreadStarter } from "./media.js"; + +describe("resolveSlackThreadStarter cache", () => { + afterEach(() => { + resetSlackThreadStarterCacheForTest(); + vi.useRealTimers(); + }); + + it("returns cached thread starter without refetching within ttl", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + const first = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + const second = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(first).toEqual(second); + expect(replies).toHaveBeenCalledTimes(1); + }); + + it("expires stale cache entries and refetches after ttl", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + vi.setSystemTime(new Date("2026-01-01T07:00:00.000Z")); + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(replies).toHaveBeenCalledTimes(2); + }); + + it("evicts oldest entries once cache exceeds bounded size", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. + for (let i = 0; i <= 2000; i += 1) { + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: `1000.${i}`, + client, + }); + } + const callsAfterFill = replies.mock.calls.length; + + // Oldest key should be evicted and require fetch again. + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.0", + client, + }); + + expect(replies.mock.calls.length).toBe(callsAfterFill + 1); + }); +}); diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 33390e45a..c943b9daa 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -233,17 +233,49 @@ export type SlackThreadStarter = { files?: SlackFile[]; }; -const THREAD_STARTER_CACHE = new Map(); +type SlackThreadStarterCacheEntry = { + value: SlackThreadStarter; + cachedAt: number; +}; + +const THREAD_STARTER_CACHE = new Map(); +const THREAD_STARTER_CACHE_TTL_MS = 6 * 60 * 60_000; +const THREAD_STARTER_CACHE_MAX = 2000; + +function evictThreadStarterCache(): void { + const now = Date.now(); + for (const [cacheKey, entry] of THREAD_STARTER_CACHE.entries()) { + if (now - entry.cachedAt > THREAD_STARTER_CACHE_TTL_MS) { + THREAD_STARTER_CACHE.delete(cacheKey); + } + } + if (THREAD_STARTER_CACHE.size <= THREAD_STARTER_CACHE_MAX) { + return; + } + const excess = THREAD_STARTER_CACHE.size - THREAD_STARTER_CACHE_MAX; + let removed = 0; + for (const cacheKey of THREAD_STARTER_CACHE.keys()) { + THREAD_STARTER_CACHE.delete(cacheKey); + removed += 1; + if (removed >= excess) { + break; + } + } +} export async function resolveSlackThreadStarter(params: { channelId: string; threadTs: string; client: SlackWebClient; }): Promise { + evictThreadStarterCache(); const cacheKey = `${params.channelId}:${params.threadTs}`; const cached = THREAD_STARTER_CACHE.get(cacheKey); + if (cached && Date.now() - cached.cachedAt <= THREAD_STARTER_CACHE_TTL_MS) { + return cached.value; + } if (cached) { - return cached; + THREAD_STARTER_CACHE.delete(cacheKey); } try { const response = (await params.client.conversations.replies({ @@ -263,13 +295,24 @@ export async function resolveSlackThreadStarter(params: { ts: message.ts, files: message.files, }; - THREAD_STARTER_CACHE.set(cacheKey, starter); + if (THREAD_STARTER_CACHE.has(cacheKey)) { + THREAD_STARTER_CACHE.delete(cacheKey); + } + THREAD_STARTER_CACHE.set(cacheKey, { + value: starter, + cachedAt: Date.now(), + }); + evictThreadStarterCache(); return starter; } catch { return null; } } +export function resetSlackThreadStarterCacheForTest(): void { + THREAD_STARTER_CACHE.clear(); +} + export type SlackThreadMessage = { text: string; userId?: string;