mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 19:16:09 -05:00
Cache user cards to id matches mid page and lfg page
This commit is contained in:
@@ -21,7 +21,7 @@ export const loader = async () => {
|
||||
return {
|
||||
posts,
|
||||
tiersMap: await postsUsersTiersMap(posts),
|
||||
...(await UserCardRepository.findAllByUserIds({
|
||||
...(await UserCardRepository.findAllByUserIdsCached({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user