perf: fix discover page 4s load — parallel fetches + per-user caching
Three compounding issues caused the 4+ second load: 1. getAllRatings() ran sequentially after the first Promise.all group, adding it unnecessarily to the critical path. Now runs in parallel with listBooks/getVotedSlugs/getSavedSlugs (all 4 concurrent). 2. discovery_votes was fetched twice on every page load — once inside getBooksForDiscovery (via getVotedSlugs) and again by getVotedBooks. Fixed by caching getVotedSlugs results with a 30s TTL so the second call hits cache instead of PocketBase. 3. getVotedSlugs and getSavedSlugs were always uncached, hitting PocketBase on every navigation. Added short-TTL per-user Valkey cache entries (voted: 30s, saved: 60s). Cache is invalidated immediately after each write (upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook) so stale data is never served.
This commit is contained in:
@@ -659,11 +659,16 @@ function libraryFilter(sessionId: string, userId?: string): string {
|
||||
|
||||
/** Returns all slugs the user has explicitly saved to their library. */
|
||||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<UserLibraryEntry>(
|
||||
'user_library',
|
||||
libraryFilter(sessionId, userId)
|
||||
);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Returns whether a specific slug is saved. */
|
||||
@@ -710,7 +715,11 @@ export async function saveBook(
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||||
return;
|
||||
}
|
||||
// Invalidate saved-slugs cache so the next discover load excludes this book.
|
||||
const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
await cache.invalidate(savedKey);
|
||||
}
|
||||
|
||||
/** Remove a book from the user's library. */
|
||||
@@ -2151,12 +2160,27 @@ function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */
|
||||
const VOTED_SLUGS_TTL = 30;
|
||||
const SAVED_SLUGS_TTL = 60;
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Invalidate the voted-slugs cache entry after a vote is recorded. */
|
||||
async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise<void> {
|
||||
const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
await cache.invalidate(key);
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
@@ -2179,6 +2203,7 @@ export async function upsertDiscoveryVote(
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
@@ -2189,6 +2214,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
@@ -2283,10 +2309,13 @@ export async function getBooksForDiscovery(
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
// Fetch all 4 independent data sources in parallel — previously getAllRatings
|
||||
// ran sequentially after the first group, adding it to the critical path.
|
||||
const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
getSavedSlugs(sessionId, userId),
|
||||
getAllRatings(),
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
@@ -2305,10 +2334,7 @@ export async function getBooksForDiscovery(
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
// Build slug→avg rating map
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
@@ -2384,6 +2410,7 @@ export async function undoDiscoveryVote(
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user