fix(msteams): bound parent thread cache expiry

This commit is contained in:
Peter Steinberger
2026-05-30 11:49:47 -04:00
parent 0e2694ff47
commit 77761f4a3e
2 changed files with 49 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GraphThreadMessage } from "./graph-thread.js";
import {
resetThreadParentContextCachesForTest,
@@ -95,6 +95,10 @@ describe("fetchParentMessageCached", () => {
resetThreadParentContextCachesForTest();
});
afterEach(() => {
vi.useRealTimers();
});
it("invokes the fetcher on first call", async () => {
const mockMsg: GraphThreadMessage = {
id: "p1",
@@ -150,21 +154,31 @@ describe("fetchParentMessageCached", () => {
it("re-fetches after TTL expires", async () => {
vi.useFakeTimers();
try {
const fetcher = vi.fn(async () => ({
id: "p1",
body: { content: "hi", contentType: "text" },
}));
const fetcher = vi.fn(async () => ({
id: "p1",
body: { content: "hi", contentType: "text" },
}));
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
// 5 min TTL: advance just beyond.
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
// 5 min TTL: advance just beyond.
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("does not cache parent fetches when the expiry would exceed Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const fetcher = vi.fn(async () => ({
id: "p1",
body: { content: "hi", contentType: "text" },
}));
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("evicts oldest entries when exceeding the 100-entry cap", async () => {

View File

@@ -13,6 +13,10 @@
// the same parent is not re-injected on every subsequent reply in the
// thread.
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
import type { GraphThreadMessage } from "./graph-thread.js";
@@ -61,6 +65,13 @@ function buildParentCacheKey(groupId: string, channelId: string, parentId: strin
return `${groupId}\u0000${channelId}\u0000${parentId}`;
}
function resolveParentCacheExpiresAt(nowRaw: number): number | undefined {
const nowMs = asDateTimestampMs(nowRaw);
return nowMs === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(PARENT_CACHE_TTL_MS, { nowMs });
}
/**
* Fetch a channel parent message with an LRU+TTL cache.
*
@@ -75,16 +86,23 @@ export async function fetchParentMessageCached(
fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
): Promise<GraphThreadMessage | undefined> {
const key = buildParentCacheKey(groupId, channelId, parentId);
const now = Date.now();
const now = asDateTimestampMs(Date.now());
const cached = parentCache.get(key);
if (cached && cached.expiresAt > now) {
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
if (cached && now !== undefined && cachedExpiresAt !== undefined && cachedExpiresAt > now) {
// Refresh LRU ordering on hit.
parentCache.delete(key);
parentCache.set(key, cached);
return cached.message;
}
if (cached) {
parentCache.delete(key);
}
const message = await fetchParent(token, groupId, channelId, parentId);
touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
const expiresAt = resolveParentCacheExpiresAt(Date.now());
if (expiresAt !== undefined) {
touchLru(parentCache, key, { message, expiresAt }, PARENT_CACHE_MAX);
}
return message;
}