fix(discord): bound channel info cache clocks

This commit is contained in:
Peter Steinberger
2026-05-30 10:34:45 -04:00
parent 2209f71a78
commit d33d6bfafa
2 changed files with 64 additions and 14 deletions

View File

@@ -1,3 +1,7 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelType, Message } from "../internal/discord.js";
@@ -30,6 +34,22 @@ export function resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined {
return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs });
}
function cacheDiscordChannelInfo(
channelId: string,
value: DiscordChannelInfo | null,
ttlMs: number,
nowMs: number,
): void {
const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs);
if (expiresAt !== undefined) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt });
}
}
function normalizeDiscordChannelId(value: unknown): string {
return normalizeOptionalStringifiedId(value) ?? "";
}
@@ -51,9 +71,11 @@ export async function resolveDiscordChannelInfo(
client: DiscordChannelInfoClient,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) {
if (now !== undefined && cached.expiresAt > now) {
return cached.value;
}
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
@@ -61,10 +83,7 @@ export async function resolveDiscordChannelInfo(
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
return null;
}
const channelInfo = resolveDiscordChannelInfoSafe(channel);
@@ -80,17 +99,11 @@ export async function resolveDiscordChannelInfo(
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
return payload;
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
return null;
}
}

View File

@@ -4,7 +4,7 @@ import {
MessageReferenceType,
StickerFormatType,
} from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Client, type Message } from "../internal/discord.js";
const readRemoteMediaBuffer = vi.fn();
@@ -65,6 +65,10 @@ beforeAll(async () => {
} = await import("./message-utils.js"));
});
afterEach(() => {
vi.restoreAllMocks();
});
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
}
@@ -1231,4 +1235,37 @@ describe("resolveDiscordChannelInfo", () => {
expect(second).toBeNull();
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("does not reuse cached channel info while the process clock is invalid", async () => {
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(first?.name).toBe("old");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(second?.name).toBe("fresh");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
it("does not cache channel info when the cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
expect(first?.name).toBe("first");
expect(second?.name).toBe("second");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
});