diff --git a/app/features/lfg/loaders/lfg.server.ts b/app/features/lfg/loaders/lfg.server.ts index 0b25e56d8..f7e5ac13b 100644 --- a/app/features/lfg/loaders/lfg.server.ts +++ b/app/features/lfg/loaders/lfg.server.ts @@ -21,7 +21,7 @@ export const loader = async () => { return { posts, tiersMap: await postsUsersTiersMap(posts), - ...(await UserCardRepository.findAllByUserIds({ + ...(await UserCardRepository.findAllByUserIdsCached({ userIds: cardUserIds, })), }; diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 368f62965..a38dd1262 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -239,7 +239,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { : null; return { - ...(await UserCardRepository.findAllByUserIds({ + ...(await UserCardRepository.findAllByUserIdsCached({ userIds: match.players.map((p) => p.id), include: { friendCode: isParticipant || isSiteStaff || isTournamentStaff, diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts index 67d826f68..797c3924b 100644 --- a/app/features/user-card/UserCardRepository.server.test.ts +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -4,6 +4,8 @@ import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; import { db } from "~/db/sql"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { withNoUser, withUserId } from "~/utils/Test"; import * as UserCardRepository from "./UserCardRepository.server"; import type { UserCardData } from "./user-card-types"; @@ -487,6 +489,39 @@ describe("UserCardRepository.findAllByUserIdsCached", () => { expect(userCards.get(target.id)?.shortBio).toBe("edited"); }); + test("overlays the latest friend code only when opted in", async () => { + const user = await UserFactory.create({ friendCode: null }); + for (const [friendCode, createdAt] of [ + ["1111-2222-3333", new Date("2024-01-01")], + ["4444-5555-6666", new Date("2025-01-01")], + ] as const) { + await UserRepository.insertFriendCode({ + userId: user.id, + submitterUserId: user.id, + friendCode, + createdAt: dateToDatabaseTimestamp(createdAt), + }); + } + + const withoutInclude = await cachedCard(user.id); + expect(withoutInclude.userCards.get(user.id)?.friendCode).toBeNull(); + + // served from the entry the call above cached, with the friend code overlaid on top + const withInclude = await withNoUser(() => + UserCardRepository.findAllByUserIdsCached({ + userIds: [user.id], + include: { friendCode: true }, + }), + ); + expect(withInclude.userCards.get(user.id)?.friendCode).toBe( + "4444-5555-6666", + ); + + // the include must not have stuck to the cached entry + const withoutIncludeAgain = await cachedCard(user.id); + expect(withoutIncludeAgain.userCards.get(user.id)?.friendCode).toBeNull(); + }); + test("coalesces concurrent misses for the same user into one query", async () => { const [first, second] = await Promise.all([ cachedCard(target.id, viewer.id), diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts index 342fa86de..0a4e7cbb2 100644 --- a/app/features/user-card/UserCardRepository.server.ts +++ b/app/features/user-card/UserCardRepository.server.ts @@ -70,19 +70,19 @@ export async function findAllByUserIds({ } const CARD_CACHE_TTL_MS = 30 * 1000; -const CARD_CACHE_MAX_ENTRIES = 2_000; +const CARD_CACHE_MAX_ENTRIES = 3_000; const cardCache = new LRUCache< number, { storedAt: number; card: Promise } >({ max: CARD_CACHE_MAX_ENTRIES }); /** - * Like {@link findAllByUserIds} (with the default options) but serves the - * viewer-independent card data from a short-lived in-memory cache, querying only users - * whose entry is missing or stale. The per-viewer `privateNote` is overlaid fresh on - * every call so a cached card is never viewer-specific. For high-frequency views (the - * SendouQ looking page) where broadcast-driven revalidation makes many clients rebuild - * the same cards at once; cards may be up to 30 seconds stale. + * Like {@link findAllByUserIds} but serves the viewer-independent card data from a + * short-lived in-memory cache, querying only users whose entry is missing or stale. + * The per-viewer `privateNote` and the opt-in `friendCode` are overlaid fresh on + * every call so a cached card is never viewer- or caller-specific. For high-frequency + * views (the SendouQ looking page) where broadcast-driven revalidation makes many + * clients rebuild the same cards at once; cards may be up to 30 seconds stale. * * The cache holds the in-flight query rather than its result, so concurrent misses for * the same user (exactly what a revalidation burst causes) await one shared query @@ -90,10 +90,13 @@ const cardCache = new LRUCache< */ export async function findAllByUserIdsCached({ userIds, + include, }: { userIds: Array; + /** Opt-in fields skipped by default; defaults to `false` each. */ + include?: { friendCode?: boolean }; }): Promise<{ userCards: Map }> { - if (ServerConfig.disableCache) return findAllByUserIds({ userIds }); + if (ServerConfig.disableCache) return findAllByUserIds({ userIds, include }); if (userIds.length === 0) return { userCards: new Map() }; const now = Date.now(); @@ -122,12 +125,16 @@ export async function findAllByUserIdsCached({ } const privateNotes = await findPrivateNotesByTargetIds([...cards.keys()]); + const friendCodes = include?.friendCode + ? await findFriendCodesByUserIds([...cards.keys()]) + : new Map(); const userCards = new Map(); for (const [userId, card] of cards) { userCards.set(userId, { ...card, privateNote: privateNotes.get(userId) ?? null, + friendCode: friendCodes.get(userId) ?? null, }); } @@ -230,6 +237,28 @@ async function findPrivateNotesByTargetIds(userIds: Array) { return notes; } +/** + * Latest friend code of each given user, keyed by their user id. Scanned oldest to newest so the + * newest code is the one left in the map, matching {@link friendCodeScalar}. + */ +async function findFriendCodesByUserIds(userIds: Array) { + const friendCodes = new Map(); + if (userIds.length === 0) return friendCodes; + + const rows = await db + .selectFrom("UserFriendCode") + .select(["UserFriendCode.userId", "UserFriendCode.friendCode"]) + .where("UserFriendCode.userId", "in", userIds) + .orderBy("UserFriendCode.createdAt", "asc") + .execute(); + + for (const row of rows) { + friendCodes.set(row.userId, row.friendCode); + } + + return friendCodes; +} + /** * Raw card fields the edit form needs that are not part of {@link UserCardData}: the uploaded banner * image (id + preview url, for the image field's default value), the self-reported peak XP, the