fix(line): prevent profile cache growth across users (#109750)

* fix(line): prevent profile cache growth across users

* docs: explain LINE profile cache eviction order

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-07-17 10:23:51 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent d023e8b412
commit 72f4cbd8f2
2 changed files with 34 additions and 1 deletions
+13
View File
@@ -515,6 +515,19 @@ describe("LINE send helpers", () => {
expect(getProfileMock).toHaveBeenCalledTimes(1);
});
it("bounds profile cache entries across distinct users", async () => {
getProfileMock.mockImplementation(async (userId: string) => ({
displayName: userId,
}));
for (let index = 0; index <= 1000; index += 1) {
await sendModule.getUserProfile(`U-profile-${index}`, { cfg: LINE_TEST_CFG });
}
await sendModule.getUserProfile("U-profile-0", { cfg: LINE_TEST_CFG });
expect(getProfileMock).toHaveBeenCalledTimes(1002);
});
it("continues when loading animation is unsupported", async () => {
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
+21 -1
View File
@@ -1,6 +1,7 @@
// Line plugin module implements send behavior.
import { messagingApi } from "@line/bot-sdk";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -29,6 +30,25 @@ const userProfileCache = new Map<
{ displayName: string; pictureUrl?: string; fetchedAt: number }
>();
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROFILE_CACHE_MAX_ENTRIES = 1000;
function cacheUserProfile(
userId: string,
profile: { displayName: string; pictureUrl?: string; fetchedAt: number },
): void {
// Refresh insertion order so overflow evicts expired entries first, then the oldest live fetch.
userProfileCache.delete(userId);
userProfileCache.set(userId, profile);
if (userProfileCache.size <= PROFILE_CACHE_MAX_ENTRIES) {
return;
}
for (const [key, cached] of userProfileCache) {
if (profile.fetchedAt - cached.fetchedAt >= PROFILE_CACHE_TTL_MS) {
userProfileCache.delete(key);
}
}
pruneMapToMaxSize(userProfileCache, PROFILE_CACHE_MAX_ENTRIES);
}
interface LineSendOpts {
cfg: OpenClawConfig;
@@ -511,7 +531,7 @@ export async function getUserProfile(
pictureUrl: profile.pictureUrl,
};
userProfileCache.set(userId, {
cacheUserProfile(userId, {
...result,
fetchedAt: Date.now(),
});