diff --git a/src/app/_components/leaderboard.tsx b/src/app/_components/leaderboard.tsx index 701710c..f4c6e1a 100644 --- a/src/app/_components/leaderboard.tsx +++ b/src/app/_components/leaderboard.tsx @@ -12,6 +12,7 @@ import { useState, } from 'react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -131,22 +132,27 @@ export function LeaderboardPage() { const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') // Fetch leaderboard data - const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({ - channel_id: RANKED_CHANNEL, - }) + const [rankedLeaderboardResult] = + api.leaderboard.get_leaderboard.useSuspenseQuery({ + channel_id: RANKED_CHANNEL, + }) - const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery( - { + const [vanillaLeaderboardResult] = + api.leaderboard.get_leaderboard.useSuspenseQuery({ channel_id: VANILLA_CHANNEL, - } - ) + }) + // Get the current leaderboard based on selected tab - const currentLeaderboard = useMemo( + const currentLeaderboardResult = useMemo( () => - leaderboardType === 'ranked' ? rankedLeaderboard : vanillaLeaderboard, - [leaderboardType, rankedLeaderboard, vanillaLeaderboard] + leaderboardType === 'ranked' + ? rankedLeaderboardResult + : vanillaLeaderboardResult, + [leaderboardType, rankedLeaderboardResult, vanillaLeaderboardResult] ) + const currentLeaderboard = currentLeaderboardResult.data + const filteredLeaderboard = useMemo( () => currentLeaderboard.filter((entry) => @@ -238,6 +244,16 @@ export function LeaderboardPage() {
+ {currentLeaderboardResult.isStale && ( + + Stale Data + + The leaderboard data is currently stale due to issues with the + neatqueue service. We're showing you the latest available data. + Please check back later. + + + )} { - return (await service.getLeaderboard( - input.channel_id - )) as LeaderboardEntry[] + const result = await service.getLeaderboard(input.channel_id) + return { + data: result.data as LeaderboardEntry[], + isStale: result.isStale + } }), get_user_rank: publicProcedure .input( @@ -24,6 +26,11 @@ export const leaderboard_router = createTRPCRouter({ }) ) .query(async ({ input }) => { - return await service.getUserRank(input.channel_id, input.user_id) + const result = await service.getUserRank(input.channel_id, input.user_id) + if (!result) return null + return { + data: result.data, + isStale: result.isStale + } }), }) diff --git a/src/server/services/leaderboard.ts b/src/server/services/leaderboard.ts index d15c7d0..cf83e40 100644 --- a/src/server/services/leaderboard.ts +++ b/src/server/services/leaderboard.ts @@ -1,5 +1,18 @@ import { redis } from '../redis' import { type LeaderboardEntry, neatqueue_service } from './neatqueue.service' +import { db } from '@/server/db' +import { metadata } from '@/server/db/schema' +import { eq } from 'drizzle-orm' + +export type LeaderboardResponse = { + data: LeaderboardEntry[] + isStale: boolean +} + +export type UserRankResponse = { + data: LeaderboardEntry + isStale: boolean +} | null export class LeaderboardService { private getZSetKey(channel_id: string) { @@ -14,11 +27,16 @@ export class LeaderboardService { return `user:${user_id}:${channel_id}` } - async refreshLeaderboard(channel_id: string) { + private getBackupKey(channel_id: string) { + return `backup_leaderboard_${channel_id}` + } + + async refreshLeaderboard(channel_id: string): Promise { try { const fresh = await neatqueue_service.get_leaderboard(channel_id) const zsetKey = this.getZSetKey(channel_id) const rawKey = this.getRawKey(channel_id) + const backupKey = this.getBackupKey(channel_id) const pipeline = redis.pipeline() pipeline.setex(rawKey, 180, JSON.stringify(fresh)) @@ -35,38 +53,134 @@ export class LeaderboardService { pipeline.expire(zsetKey, 180) await pipeline.exec() - return fresh + // Store the latest successful leaderboard data in the database + await db + .insert(metadata) + .values({ + key: backupKey, + value: JSON.stringify({ + data: fresh, + timestamp: new Date().toISOString(), + }), + }) + .onConflictDoUpdate({ + target: metadata.key, + set: { + value: JSON.stringify({ + data: fresh, + timestamp: new Date().toISOString(), + }), + }, + }) + + return { data: fresh, isStale: false } } catch (error) { console.error('Error refreshing leaderboard:', error) - throw error + + // If neatqueue fails, try to get the latest backup from the database + const backupKey = this.getBackupKey(channel_id) + const backup = await db + .select() + .from(metadata) + .where(eq(metadata.key, backupKey)) + .limit(1) + .then((res) => res[0]) + + if (backup) { + const parsedBackup = JSON.parse(backup.value) + console.log(`Using backup leaderboard data from ${parsedBackup.timestamp} in refreshLeaderboard`) + return { data: parsedBackup.data as LeaderboardEntry[], isStale: true } + } + + // If no backup exists, return an empty array with isStale flag + console.log('No backup leaderboard data available for refreshLeaderboard, returning empty array') + return { data: [], isStale: true } } } - async getLeaderboard(channel_id: string) { + async getLeaderboard(channel_id: string): Promise { try { + // Try to get from Redis cache first const cached = await redis.get(this.getRawKey(channel_id)) - if (cached) return JSON.parse(cached) + if (cached) return { data: JSON.parse(cached) as LeaderboardEntry[], isStale: false } + // If not in cache, try to refresh from neatqueue return await this.refreshLeaderboard(channel_id) } catch (error) { - console.error('Error getting leaderboard:', error) - throw error + console.error('Error getting leaderboard from neatqueue:', error) + + // If neatqueue fails, try to get the latest backup from the database + const backupKey = this.getBackupKey(channel_id) + const backup = await db + .select() + .from(metadata) + .where(eq(metadata.key, backupKey)) + .limit(1) + .then((res) => res[0]) + + if (backup) { + const parsedBackup = JSON.parse(backup.value) + console.log(`Using backup leaderboard data from ${parsedBackup.timestamp}`) + return { data: parsedBackup.data as LeaderboardEntry[], isStale: true } + } + + // If no backup exists, return an empty array with isStale flag + console.log('No backup leaderboard data available for getLeaderboard, returning empty array') + return { data: [], isStale: true } } } - async getUserRank(channel_id: string, user_id: string) { + async getUserRank(channel_id: string, user_id: string): Promise { try { + // Try to get user data from Redis first const userData = await redis.hgetall(this.getUserKey(user_id, channel_id)) - if (!userData) return null + if (userData) { + return { + data: { + ...userData, + mmr: Number(userData.mmr), + streak: userData.streak, + } as unknown as LeaderboardEntry, + isStale: false + } + } - return { - ...userData, - mmr: Number(userData.mmr), - streak: userData.streak, - } as unknown as LeaderboardEntry + // If not found in Redis, try to refresh the leaderboard + try { + const { data: freshLeaderboard } = await this.refreshLeaderboard(channel_id) + const userEntry = freshLeaderboard.find(entry => entry.id === user_id) + if (userEntry) { + return { data: userEntry, isStale: false } + } + } catch (refreshError) { + console.error('Error refreshing leaderboard for user rank:', refreshError) + // Continue to backup if refresh fails + } + + // If not found in fresh data or refresh failed, try to get from backup + const backupKey = this.getBackupKey(channel_id) + const backup = await db + .select() + .from(metadata) + .where(eq(metadata.key, backupKey)) + .limit(1) + .then((res) => res[0]) + + if (backup) { + const parsedBackup = JSON.parse(backup.value) + const userEntry = parsedBackup.data.find((entry: any) => entry.id === user_id) + if (userEntry) { + console.log(`Using backup leaderboard data for user ${user_id} from ${parsedBackup.timestamp}`) + return { data: userEntry as LeaderboardEntry, isStale: true } + } + } + + // If user not found anywhere + return null } catch (error) { console.error('Error getting user rank:', error) - throw error + // Return null instead of rethrowing the error to prevent the page from breaking + return null } } }