store transcripts in redis/pg to alleviate the load on nq

This commit is contained in:
2025-07-05 23:57:21 +02:00
parent 7b86fb9d82
commit 86c6d98b80
4 changed files with 102 additions and 20 deletions

View File

@@ -18,6 +18,7 @@ import {
} from '@/components/ui/table' } from '@/components/ui/table'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { SelectGames } from '@/server/db/types' import type { SelectGames } from '@/server/db/types'
import { api } from '@/trpc/react'
import { import {
type SortingState, type SortingState,
createColumnHelper, createColumnHelper,
@@ -42,11 +43,6 @@ const numberFormatter = new Intl.NumberFormat('en-US', {
}) })
const columnHelper = createColumnHelper<SelectGames>() const columnHelper = createColumnHelper<SelectGames>()
function getTranscript(gameNumber: number) {
return fetch(
`https://api.neatqueue.com/api/transcript/1226193436521267223/${gameNumber}`
).then((res) => res.json())
}
// This function is now moved inside the GamesTable component // This function is now moved inside the GamesTable component
const useColumns = (openTranscriptFn?: (gameNumber: number) => void) => { const useColumns = (openTranscriptFn?: (gameNumber: number) => void) => {
const format = useFormatter() const format = useFormatter()
@@ -197,22 +193,25 @@ const useColumns = (openTranscriptFn?: (gameNumber: number) => void) => {
export function GamesTable({ games }: { games: SelectGames[] }) { export function GamesTable({ games }: { games: SelectGames[] }) {
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [transcriptContent, setTranscriptContent] = useState<string>('')
const [transcriptGameNumber, setTranscriptGameNumber] = useState< const [transcriptGameNumber, setTranscriptGameNumber] = useState<
number | null number | null
>(null) >(null)
// Use the tRPC useQuery hook to fetch the transcript
const { data: transcriptContent, isLoading } = api.history.getTranscript.useQuery(
{ gameNumber: transcriptGameNumber ?? 0 },
{
// Only fetch when we have a game number and the dialog is open
enabled: transcriptGameNumber !== null && isDialogOpen,
// Don't refetch on window focus
refetchOnWindowFocus: false,
}
)
// New openTranscript function that sets state instead of opening a new window // New openTranscript function that sets state instead of opening a new window
const openTranscript = (gameNumber: number): void => { const openTranscript = (gameNumber: number): void => {
setTranscriptGameNumber(gameNumber) setTranscriptGameNumber(gameNumber)
getTranscript(gameNumber) setIsDialogOpen(true)
.then((html: string) => {
setTranscriptContent(html)
setIsDialogOpen(true)
})
.catch((err) => {
console.error('Failed to load transcript:', err)
})
} }
// Pass the openTranscript function to useColumns // Pass the openTranscript function to useColumns
@@ -308,12 +307,25 @@ export function GamesTable({ games }: { games: SelectGames[] }) {
</DialogHeader> </DialogHeader>
{/* Use iframe to isolate the transcript content and prevent style leakage */} {/* Use iframe to isolate the transcript content and prevent style leakage */}
<div className='!h-[60vh] mt-4 w-full'> <div className='!h-[60vh] mt-4 w-full'>
<iframe {isLoading ? (
srcDoc={transcriptContent} <div className='flex h-full w-full items-center justify-center'>
title={`Game Transcript #${transcriptGameNumber || ''}`} <div className='text-center'>
className='h-full w-full border-0' <div className='mb-2 h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-gray-900 dark:border-gray-100'></div>
sandbox='allow-same-origin' <p>Loading transcript...</p>
/> </div>
</div>
) : transcriptContent ? (
<iframe
srcDoc={transcriptContent}
title={`Game Transcript #${transcriptGameNumber || ''}`}
className='h-full w-full border-0'
sandbox='allow-same-origin'
/>
) : (
<div className='flex h-full w-full items-center justify-center'>
<p>Failed to load transcript. Please try again.</p>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -5,8 +5,18 @@ import { and, desc, eq, gt, lt, sql } from 'drizzle-orm'
import ky from 'ky' import ky from 'ky'
import { chunk } from 'remeda' import { chunk } from 'remeda'
import { z } from 'zod' import { z } from 'zod'
import { neatqueue_service } from '@/server/services/neatqueue.service'
export const history_router = createTRPCRouter({ export const history_router = createTRPCRouter({
getTranscript: publicProcedure
.input(
z.object({
gameNumber: z.number(),
})
)
.query(async ({ input }) => {
return await neatqueue_service.get_transcript(input.gameNumber);
}),
games_per_hour: publicProcedure games_per_hour: publicProcedure
.input( .input(
z z

View File

@@ -189,3 +189,10 @@ export const leaderboardSnapshots = pgTable('leaderboard_snapshots', {
}) })
export const leaderboardSnapshotsRelations = relations(leaderboardSnapshots, ({}) => ({})) export const leaderboardSnapshotsRelations = relations(leaderboardSnapshots, ({}) => ({}))
export const transcripts = pgTable('transcripts', {
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
gameNumber: integer('game_number').notNull().unique(),
content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})

View File

@@ -1,4 +1,8 @@
import ky from 'ky' import ky from 'ky'
import { db } from '../db'
import { redis } from '../redis'
import { transcripts } from '../db/schema'
import { eq } from 'drizzle-orm'
const NEATQUEUE_URL = 'https://api.neatqueue.com/api' const NEATQUEUE_URL = 'https://api.neatqueue.com/api'
@@ -9,6 +13,9 @@ const instance = ky.create({
const BMM_SERVER_ID = '1226193436521267223' const BMM_SERVER_ID = '1226193436521267223'
// Redis key for transcript cache
export const TRANSCRIPT_CACHE_KEY = (gameNumber: number) => `transcript:${gameNumber}`
export const neatqueue_service = { export const neatqueue_service = {
get_leaderboard: async (channel_id: string) => { get_leaderboard: async (channel_id: string) => {
const res = await instance const res = await instance
@@ -44,6 +51,52 @@ export const neatqueue_service = {
.json() .json()
return response return response
}, },
get_transcript: async (gameNumber: number, server_id: string = BMM_SERVER_ID) => {
// Try to get from Redis cache first (fastest)
const cacheKey = TRANSCRIPT_CACHE_KEY(gameNumber)
const cachedTranscript = await redis.get(cacheKey)
if (cachedTranscript) {
console.log(`Transcript #${gameNumber} found in Redis cache`)
return cachedTranscript
}
// If not in Redis, try to get from database
const dbTranscript = await db.query.transcripts.findFirst({
where: eq(transcripts.gameNumber, gameNumber)
})
if (dbTranscript) {
console.log(`Transcript #${gameNumber} found in database`)
// Store in Redis for future quick access
await redis.set(cacheKey, dbTranscript.content)
return dbTranscript.content
}
// If not in database, fetch from neatqueue
console.log(`Fetching transcript #${gameNumber} from neatqueue`)
try {
const response = await instance
.get(`transcript/${server_id}/${gameNumber}`)
.json<string>()
// Store in both database and Redis
await db.insert(transcripts).values({
gameNumber,
content: response
}).onConflictDoUpdate({
target: transcripts.gameNumber,
set: { content: response }
})
await redis.set(cacheKey, response)
return response
} catch (error) {
console.error(`Error fetching transcript #${gameNumber}:`, error)
throw error
}
},
} }
export type Data = { export type Data = {
mmr: number mmr: number