Slack: bound thread starter cache growth

This commit is contained in:
Vignesh Natarajan
2026-02-14 17:55:13 -08:00
parent 1ff15e60d3
commit 6d0cd54ac1
2 changed files with 133 additions and 3 deletions

View File

@@ -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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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);
});
});

View File

@@ -233,17 +233,49 @@ export type SlackThreadStarter = {
files?: SlackFile[];
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
type SlackThreadStarterCacheEntry = {
value: SlackThreadStarter;
cachedAt: number;
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarterCacheEntry>();
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<SlackThreadStarter | null> {
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;