leaderboard error handling

This commit is contained in:
2025-06-15 11:59:52 +02:00
parent a72e3016ad
commit 1933c0331d
3 changed files with 166 additions and 29 deletions

View File

@@ -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({
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() {
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-1 flex-col'>
<div className='flex flex-1 flex-col overflow-hidden border-none'>
{currentLeaderboardResult.isStale && (
<Alert className='my-4 border-amber-500 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'>
<AlertTitle>Stale Data</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
<Tabs
defaultValue={leaderboardType}
value={leaderboardType}

View File

@@ -12,9 +12,11 @@ export const leaderboard_router = createTRPCRouter({
})
)
.query(async ({ input }) => {
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
}
}),
})

View File

@@ -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<LeaderboardResponse> {
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<LeaderboardResponse> {
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<UserRankResponse> {
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
} as unknown as LeaderboardEntry,
isStale: false
}
}
// 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
}
}
}