fix(slack): bound thread starter cache clocks

This commit is contained in:
Peter Steinberger
2026-05-30 11:06:47 -04:00
parent 8539e0283a
commit 6736936cbc
2 changed files with 64 additions and 13 deletions

View File

@@ -61,6 +61,45 @@ describe("resolveSlackThreadStarter cache", () => {
expect(replies).toHaveBeenCalledTimes(2);
});
it("drops cached thread starters when the current clock is not a valid date timestamp", async () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const { replies, client } = createThreadStarterRepliesClient();
const first = await resolveSlackThreadStarter({
channelId: "C1",
threadTs: "1000.1",
client,
});
nowSpy.mockReturnValue(Number.NaN);
const second = await resolveSlackThreadStarter({
channelId: "C1",
threadTs: "1000.1",
client,
});
expect(first).toEqual(second);
expect(replies).toHaveBeenCalledTimes(2);
});
it("does not cache thread starters when the expiry timestamp would exceed the valid date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const { replies, client } = createThreadStarterRepliesClient();
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(2);
});
it("does not cache empty starter text", async () => {
const { replies, client } = createThreadStarterRepliesClient({
messages: [{ text: " ", user: "U1", ts: "1000.1" }],

View File

@@ -1,6 +1,10 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { formatSlackFileReferenceList } from "../file-reference.js";
import type { SlackFile } from "../types.js";
import { logVerbose } from "./thread.runtime.js";
@@ -15,7 +19,7 @@ export type SlackThreadStarter = {
type SlackThreadStarterCacheEntry = {
value: SlackThreadStarter;
cachedAt: number;
expiresAt: number;
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarterCacheEntry>();
@@ -23,9 +27,13 @@ const THREAD_STARTER_CACHE_TTL_MS = 6 * 60 * 60_000;
const THREAD_STARTER_CACHE_MAX = 2000;
function evictThreadStarterCache(): void {
const now = Date.now();
const now = asDateTimestampMs(Date.now());
if (now === undefined) {
THREAD_STARTER_CACHE.clear();
return;
}
for (const [cacheKey, entry] of THREAD_STARTER_CACHE.entries()) {
if (now - entry.cachedAt > THREAD_STARTER_CACHE_TTL_MS) {
if (asDateTimestampMs(entry.expiresAt) === undefined || entry.expiresAt <= now) {
THREAD_STARTER_CACHE.delete(cacheKey);
}
}
@@ -44,10 +52,11 @@ export async function resolveSlackThreadStarter(params: {
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) {
const now = asDateTimestampMs(Date.now());
if (now !== undefined && cached.expiresAt > now) {
return cached.value;
}
THREAD_STARTER_CACHE.delete(cacheKey);
}
try {
@@ -78,14 +87,17 @@ export async function resolveSlackThreadStarter(params: {
ts: message.ts,
files,
};
if (THREAD_STARTER_CACHE.has(cacheKey)) {
THREAD_STARTER_CACHE.delete(cacheKey);
const expiresAt = resolveExpiresAtMsFromDurationMs(THREAD_STARTER_CACHE_TTL_MS);
if (expiresAt !== undefined) {
if (THREAD_STARTER_CACHE.has(cacheKey)) {
THREAD_STARTER_CACHE.delete(cacheKey);
}
THREAD_STARTER_CACHE.set(cacheKey, {
value: starter,
expiresAt,
});
evictThreadStarterCache();
}
THREAD_STARTER_CACHE.set(cacheKey, {
value: starter,
cachedAt: Date.now(),
});
evictThreadStarterCache();
return starter;
} catch (err) {
logVerbose(