From 9ef5a9afdcb08db64f71e47728f18c911a2ff0bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 10:38:19 -0400 Subject: [PATCH] fix(discord): bound REST entity cache clocks --- .../discord/src/internal/client.test.ts | 41 +++++++++++++++++++ .../discord/src/internal/entity-cache.ts | 16 +++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/internal/client.test.ts b/extensions/discord/src/internal/client.test.ts index 3a6fd72d7d5..1f34a96336a 100644 --- a/extensions/discord/src/internal/client.test.ts +++ b/extensions/discord/src/internal/client.test.ts @@ -342,6 +342,47 @@ describe("Client.deployCommands", () => { await client.fetchChannel("c1"); expect(get).toHaveBeenCalledTimes(2); }); + + it("does not reuse cached REST objects while the process clock is invalid", async () => { + const client = createInternalTestClient(); + const get = vi + .fn() + .mockResolvedValueOnce({ id: "c1", type: 0, name: "old" }) + .mockResolvedValueOnce({ id: "c1", type: 0, name: "fresh" }) + .mockResolvedValueOnce({ id: "c1", type: 0, name: "recovered" }); + attachRestMock(client, { get }); + + const first = await client.fetchChannel("c1"); + expect(first.name).toBe("old"); + + vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001); + const second = await client.fetchChannel("c1"); + + expect(second.name).toBe("fresh"); + + vi.mocked(Date.now).mockReturnValue(1_000); + const third = await client.fetchChannel("c1"); + + expect(third.name).toBe("recovered"); + expect(get).toHaveBeenCalledTimes(3); + }); + + it("does not cache REST objects when the cache expiry would exceed the Date range", async () => { + const client = createInternalTestClient(); + const get = vi + .fn() + .mockResolvedValueOnce({ id: "c1", type: 0, name: "first" }) + .mockResolvedValueOnce({ id: "c1", type: 0, name: "second" }); + attachRestMock(client, { get }); + vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000); + + const first = await client.fetchChannel("c1"); + const second = await client.fetchChannel("c1"); + + expect(first.name).toBe("first"); + expect(second.name).toBe("second"); + expect(get).toHaveBeenCalledTimes(2); + }); }); describe("Client gateway event queue", () => { diff --git a/extensions/discord/src/internal/entity-cache.ts b/extensions/discord/src/internal/entity-cache.ts index b79ed99083f..b8c272b86c3 100644 --- a/extensions/discord/src/internal/entity-cache.ts +++ b/extensions/discord/src/internal/entity-cache.ts @@ -1,4 +1,8 @@ import { GatewayDispatchEvents } from "discord-api-types/v10"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { getChannel, getGuild, getGuildMember, getUser } from "./api.js"; import type { RequestClient } from "./rest.js"; import { Guild, GuildMember, User, channelFactory, type StructureClient } from "./structures.js"; @@ -79,15 +83,23 @@ export class DiscordEntityCache { private async fetchCached(key: string, fetcher: () => Promise): Promise { const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS; + const rawNow = Date.now(); + const now = asDateTimestampMs(rawNow); if (ttl > 0) { const cached = this.entries.get(key) as CacheEntry | undefined; - if (cached && cached.expiresAt > Date.now()) { + if (cached && now !== undefined && cached.expiresAt > now) { return cached.value; } + if (cached) { + this.entries.delete(key); + } } const value = await fetcher(); if (ttl > 0) { - this.entries.set(key, { expiresAt: Date.now() + ttl, value }); + const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow }); + if (expiresAt !== undefined) { + this.entries.set(key, { expiresAt, value }); + } } return value; }