From 8ee93cfa89d9240fa020b4b629beb8eed78b7a4b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:31:24 +0300 Subject: [PATCH] Fix counts a season once when the cache is filled concurrently --- .../core/widgets/leaderboard-cache.test.ts | 28 +++++++++++++++ .../user-page/core/widgets/utils.server.ts | 34 ++++++++++++------- 2 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 app/features/user-page/core/widgets/leaderboard-cache.test.ts diff --git a/app/features/user-page/core/widgets/leaderboard-cache.test.ts b/app/features/user-page/core/widgets/leaderboard-cache.test.ts new file mode 100644 index 000000000..41e9cb715 --- /dev/null +++ b/app/features/user-page/core/widgets/leaderboard-cache.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +// One finished season, one user sitting at rank 5 (so: top-10 AND top-100). +vi.mock("~/features/mmr/core/Seasons", () => ({ + allFinished: () => [1], +})); +vi.mock("~/features/leaderboards/LeaderboardRepository.server", () => ({ + findUserSPLeaderboard: async () => [{ id: 100, placementRank: 5 }], +})); + +import { cachedUserSQLeaderboardTopData } from "./utils.server"; + +describe("SendouQ leaderboard widget cache", () => { + it("counts a season once when the cache is filled concurrently", async () => { + // This is exactly how UserRepository.findWidgetsByUserId drives it: the + // top-10-seasons and top-100-seasons widgets both call the cache inside the + // same Promise.all, so a cold cache is populated by two concurrent callers. + const [cache] = await Promise.all([ + cachedUserSQLeaderboardTopData(), + cachedUserSQLeaderboardTopData(), + ]); + + const user = cache.get(100)!; + + expect(user.TOP_10.times).toBe(1); + expect(user.TOP_10.seasons).toEqual([1]); + }); +}); diff --git a/app/features/user-page/core/widgets/utils.server.ts b/app/features/user-page/core/widgets/utils.server.ts index 9b45346f9..af6182597 100644 --- a/app/features/user-page/core/widgets/utils.server.ts +++ b/app/features/user-page/core/widgets/utils.server.ts @@ -1,27 +1,37 @@ +import { cachified } from "@epic-web/cachified"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; type LeaderboardTopData = { times: number; seasons: number[]; }; -const sqLeaderboardTopCache = new Map< +type UserSQLeaderboardTopData = Map< number, { TOP_10: LeaderboardTopData; TOP_100: LeaderboardTopData; } ->(); +>; -export async function cachedUserSQLeaderboardTopData() { - if (sqLeaderboardTopCache.size > 0) { - return sqLeaderboardTopCache; - } +const SQ_LEADERBOARD_TOP_CACHE_KEY = "sq-leaderboard-top"; - const allSeasons = Seasons.allFinished(); +/** How many times & in what seasons each user placed in the SendouQ leaderboard top 10 and top 100. */ +export function cachedUserSQLeaderboardTopData() { + return cachified({ + key: SQ_LEADERBOARD_TOP_CACHE_KEY, + cache, + ttl: ttl(IN_MILLISECONDS.TWO_HOURS), + getFreshValue: userSQLeaderboardTopData, + }); +} - for (const season of allSeasons) { +async function userSQLeaderboardTopData(): Promise { + const result: UserSQLeaderboardTopData = new Map(); + + for (const season of Seasons.allFinished()) { const leaderboard = await LeaderboardRepository.findUserSPLeaderboard(season); @@ -29,14 +39,14 @@ export async function cachedUserSQLeaderboardTopData() { const userId = entry.id; const placementRank = entry.placementRank; - if (!sqLeaderboardTopCache.has(userId)) { - sqLeaderboardTopCache.set(userId, { + if (!result.has(userId)) { + result.set(userId, { TOP_10: { times: 0, seasons: [] }, TOP_100: { times: 0, seasons: [] }, }); } - const userData = sqLeaderboardTopCache.get(userId)!; + const userData = result.get(userId)!; if (placementRank <= 10) { userData.TOP_10.times += 1; @@ -50,5 +60,5 @@ export async function cachedUserSQLeaderboardTopData() { } } - return sqLeaderboardTopCache; + return result; }