Cache user cards to id matches mid page and lfg page

This commit is contained in:
Kalle
2026-08-15 16:29:05 +03:00
parent c5f882a040
commit 64dcfb34b0
4 changed files with 74 additions and 10 deletions

View File

@@ -21,7 +21,7 @@ export const loader = async () => {
return {
posts,
tiersMap: await postsUsersTiersMap(posts),
...(await UserCardRepository.findAllByUserIds({
...(await UserCardRepository.findAllByUserIdsCached({
userIds: cardUserIds,
})),
};

View File

@@ -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,

View File

@@ -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),

View File

@@ -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<UserCardData | undefined> }
>({ 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<number>;
/** Opt-in fields skipped by default; defaults to `false` each. */
include?: { friendCode?: boolean };
}): Promise<{ userCards: Map<number, UserCardData> }> {
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<number, string>();
const userCards = new Map<number, UserCardData>();
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<number>) {
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<number>) {
const friendCodes = new Map<number, string>();
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