mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:38:39 +00:00
Slack: bound thread starter cache growth
This commit is contained in:
87
src/slack/monitor/media.thread-starter-cache.test.ts
Normal file
87
src/slack/monitor/media.thread-starter-cache.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,17 +233,49 @@ export type SlackThreadStarter = {
|
|||||||
files?: SlackFile[];
|
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: {
|
export async function resolveSlackThreadStarter(params: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
threadTs: string;
|
threadTs: string;
|
||||||
client: SlackWebClient;
|
client: SlackWebClient;
|
||||||
}): Promise<SlackThreadStarter | null> {
|
}): Promise<SlackThreadStarter | null> {
|
||||||
|
evictThreadStarterCache();
|
||||||
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
||||||
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.cachedAt <= THREAD_STARTER_CACHE_TTL_MS) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = (await params.client.conversations.replies({
|
const response = (await params.client.conversations.replies({
|
||||||
@@ -263,13 +295,24 @@ export async function resolveSlackThreadStarter(params: {
|
|||||||
ts: message.ts,
|
ts: message.ts,
|
||||||
files: message.files,
|
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;
|
return starter;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetSlackThreadStarterCacheForTest(): void {
|
||||||
|
THREAD_STARTER_CACHE.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export type SlackThreadMessage = {
|
export type SlackThreadMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user