Compare commits

...

3 Commits

Author SHA1 Message Date
Shadow
bf690507bf fix: cover discord thread starter cache TTL (#5274) (thanks @webvijayi) 2026-02-12 16:31:05 -06:00
Web Vijayi
4e6330e2ab fix: use iterator.done check for LRU eviction
Fixes edge case where empty string key would stop eviction early
2026-02-12 16:26:42 -06:00
Web Vijayi
f6be7038b5 fix(discord): add TTL and LRU eviction to thread starter cache
Fixes #5260

The DISCORD_THREAD_STARTER_CACHE Map was growing unbounded during
long-running gateway sessions, causing memory exhaustion.

This fix adds:
- 5-minute TTL expiry (thread starters rarely change)
- Max 500 entries with LRU eviction
- Same caching pattern used by Slack's thread resolver

The implementation mirrors src/slack/monitor/thread-resolution.ts
which already handles this correctly.
2026-02-12 16:26:42 -06:00
3 changed files with 93 additions and 5 deletions

View File

@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
- Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.
- Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.
- Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.
- Discord: add TTL/LRU eviction to the thread starter cache to prevent unbounded growth. (#5274) Thanks @webvijayi.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.

View File

@@ -1,10 +1,12 @@
import type { Client } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import { ChannelType, type Client } from "@buape/carbon";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import {
__resetDiscordThreadStarterCacheForTest,
resolveDiscordAutoThreadContext,
resolveDiscordAutoThreadReplyPlan,
resolveDiscordReplyDeliveryPlan,
resolveDiscordThreadStarter,
} from "./threading.js";
describe("resolveDiscordAutoThreadContext", () => {
@@ -142,3 +144,45 @@ describe("resolveDiscordAutoThreadReplyPlan", () => {
expect(plan.autoThreadContext).toBeNull();
});
});
describe("resolveDiscordThreadStarter cache", () => {
afterEach(() => {
vi.useRealTimers();
__resetDiscordThreadStarterCacheForTest();
});
it("expires cached entries after TTL", async () => {
vi.useFakeTimers();
const baseTime = new Date("2026-02-12T00:00:00Z").getTime();
vi.setSystemTime(baseTime);
const restGet = vi.fn(async () => ({
content: "starter",
author: { username: "starter", id: "user-1" },
timestamp: "2026-02-12T00:00:00Z",
}));
const client = { rest: { get: restGet } } as unknown as Client;
const params = {
channel: { id: "thread-1" },
client,
parentId: "parent-1",
parentType: ChannelType.GuildText,
resolveTimestampMs: () => baseTime,
};
const first = await resolveDiscordThreadStarter(params);
expect(first?.text).toBe("starter");
expect(restGet).toHaveBeenCalledTimes(1);
vi.setSystemTime(baseTime + 60_000);
const second = await resolveDiscordThreadStarter(params);
expect(second).toEqual(first);
expect(restGet).toHaveBeenCalledTimes(1);
vi.setSystemTime(baseTime + 60_000 + 5 * 60_000 + 1);
const third = await resolveDiscordThreadStarter(params);
expect(third).toEqual(first);
expect(restGet).toHaveBeenCalledTimes(2);
});
});

View File

@@ -29,12 +29,54 @@ type DiscordThreadParentInfo = {
type?: ChannelType;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
// Cache entry with timestamp for TTL-based eviction
type DiscordThreadStarterCacheEntry = {
value: DiscordThreadStarter;
updatedAt: number;
};
// Cache configuration: 5 minute TTL (thread starters rarely change), max 500 entries
const DISCORD_THREAD_STARTER_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_THREAD_STARTER_CACHE_MAX = 500;
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarterCacheEntry>();
export function __resetDiscordThreadStarterCacheForTest() {
DISCORD_THREAD_STARTER_CACHE.clear();
}
// Get cached entry with TTL check, refresh LRU position on hit
function getCachedThreadStarter(key: string, now: number): DiscordThreadStarter | undefined {
const entry = DISCORD_THREAD_STARTER_CACHE.get(key);
if (!entry) {
return undefined;
}
// Check TTL expiry
if (now - entry.updatedAt > DISCORD_THREAD_STARTER_CACHE_TTL_MS) {
DISCORD_THREAD_STARTER_CACHE.delete(key);
return undefined;
}
// Refresh LRU position by re-inserting (Map maintains insertion order)
DISCORD_THREAD_STARTER_CACHE.delete(key);
DISCORD_THREAD_STARTER_CACHE.set(key, { ...entry, updatedAt: now });
return entry.value;
}
// Set cached entry with LRU eviction when max size exceeded
function setCachedThreadStarter(key: string, value: DiscordThreadStarter, now: number): void {
// Remove existing entry first (to update LRU position)
DISCORD_THREAD_STARTER_CACHE.delete(key);
DISCORD_THREAD_STARTER_CACHE.set(key, { value, updatedAt: now });
// Evict oldest entries (first in Map) when over max size
while (DISCORD_THREAD_STARTER_CACHE.size > DISCORD_THREAD_STARTER_CACHE_MAX) {
const iter = DISCORD_THREAD_STARTER_CACHE.keys().next();
if (iter.done) {
break;
}
DISCORD_THREAD_STARTER_CACHE.delete(iter.value);
}
}
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
@@ -100,7 +142,8 @@ export async function resolveDiscordThreadStarter(params: {
resolveTimestampMs: (value?: string | null) => number | undefined;
}): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
const now = Date.now();
const cached = getCachedThreadStarter(cacheKey, now);
if (cached) {
return cached;
}
@@ -146,7 +189,7 @@ export async function resolveDiscordThreadStarter(params: {
author,
timestamp: timestamp ?? undefined,
};
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
setCachedThreadStarter(cacheKey, payload, Date.now());
return payload;
} catch {
return null;