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
}
}
}