From 7725b3d2ea237408a5695c7edba2f3fe19d05fab Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 18 Jun 2026 11:40:41 +0800 Subject: [PATCH] perf: replace N+1 dedup queries with batch pre-fetch in convertCandidates - convertQuizCandidates: batch findMany all stems before loop, dedup in-memory - convertFlashcardCandidates: batch findMany all fronts before loop, dedup in-memory - 50 items now = 1 query instead of 50 - Update tests: mock findMany instead of per-item findFirst Co-Authored-By: Claude Opus 4.7 --- .../internal/runtime-internal.service.spec.ts | 20 +++++----- .../internal/runtime-internal.service.ts | 40 +++++++++++-------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts index ba6d42a..f69ac02 100644 --- a/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts +++ b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts @@ -67,8 +67,8 @@ describe('RuntimeInternalService', () => { mockQuestionGenerationPlan = { updateMany: jest.fn() }; mockFlashcardGenerationPlan = { updateMany: jest.fn() }; mockQuiz = { create: jest.fn(), update: jest.fn() }; - mockQuizQuestion = { findFirst: jest.fn(), create: jest.fn() }; - mockFlashcard = { findFirst: jest.fn(), create: jest.fn() }; + mockQuizQuestion = { findMany: jest.fn(), create: jest.fn() }; + mockFlashcard = { findMany: jest.fn(), create: jest.fn() }; mockKnowledgeItem = { findMany: jest.fn().mockResolvedValue([]) }; mockNotification = { create: jest.fn() }; @@ -558,7 +558,7 @@ describe('RuntimeInternalService', () => { mockAiRuntimeJob.update.mockResolvedValue({}); mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); mockQuiz.create.mockResolvedValue({ id: 'quiz-1' }); - mockQuizQuestion.findFirst.mockResolvedValue(null); + mockQuizQuestion.findMany.mockResolvedValue([]); mockQuizQuestion.create.mockResolvedValue({}); await service.submitResult('j1', qDto); @@ -576,7 +576,7 @@ describe('RuntimeInternalService', () => { mockAiRuntimeResult.create.mockResolvedValue({}); mockAiRuntimeJob.update.mockResolvedValue({}); mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); - mockFlashcard.findFirst.mockResolvedValue(null); + mockFlashcard.findMany.mockResolvedValue([]); mockFlashcard.create.mockResolvedValue({}); await service.submitResult('j1', fcDto); @@ -860,7 +860,7 @@ describe('RuntimeInternalService', () => { mockAiRuntimeJob.update.mockResolvedValue({}); mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); mockQuiz.create.mockResolvedValue({ id: 'quiz-1' }); - mockQuizQuestion.findFirst.mockResolvedValue(null); + mockQuizQuestion.findMany.mockResolvedValue([]); mockQuizQuestion.create.mockResolvedValue({}); }); @@ -892,8 +892,8 @@ describe('RuntimeInternalService', () => { it('skips duplicate questions (same stem for user)', async () => { const loggerSpy = jest.spyOn((service as any).logger, 'warn'); mockAiRuntimeJob.findUnique.mockResolvedValue(qJob); - mockQuizQuestion.findFirst.mockResolvedValueOnce({ id: 'existing-q' }); // duplicate - mockQuizQuestion.findFirst.mockResolvedValueOnce(null); // unique + // Batch dedup: findMany returns existing stems; 'Duplicate?' is duplicate, 'Unique?' is new + mockQuizQuestion.findMany.mockResolvedValue([{ stem: 'Duplicate?' }]); mockQuiz.update.mockResolvedValue({}); await service.submitResult('j1', { @@ -946,7 +946,7 @@ describe('RuntimeInternalService', () => { mockAiRuntimeResult.create.mockResolvedValue({}); mockAiRuntimeJob.update.mockResolvedValue({}); mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); - mockFlashcard.findFirst.mockResolvedValue(null); + mockFlashcard.findMany.mockResolvedValue([]); mockFlashcard.create.mockResolvedValue({}); }); @@ -966,8 +966,8 @@ describe('RuntimeInternalService', () => { it('skips duplicate cards (same front for user)', async () => { const loggerSpy = jest.spyOn((service as any).logger, 'warn'); mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob); - mockFlashcard.findFirst.mockResolvedValueOnce({ id: 'dup-1' }); - mockFlashcard.findFirst.mockResolvedValueOnce(null); + // Batch dedup: findMany returns existing fronts; 'Dup' is duplicate, 'Unique' is new + mockFlashcard.findMany.mockResolvedValue([{ front: 'Dup' }]); await service.submitResult('j1', { runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', diff --git a/src/modules/ai-runtime/internal/runtime-internal.service.ts b/src/modules/ai-runtime/internal/runtime-internal.service.ts index 90aa65d..2f3c444 100644 --- a/src/modules/ai-runtime/internal/runtime-internal.service.ts +++ b/src/modules/ai-runtime/internal/runtime-internal.service.ts @@ -458,19 +458,24 @@ export class RuntimeInternalService { }); let created = 0; - for (let i = 0; i < valid.length; i++) { - const q = valid[i]; - const stem = q.stem ?? q.question; - // Dedup: skip if same stem already exists for this user in any quiz - const existing = await this.prisma.quizQuestion.findFirst({ + // Batch pre-fetch existing stems for dedup (avoids N+1 per-item queries) + const stems = valid.map(q => q.stem ?? q.question); + const existingStems = new Set( + (await this.prisma.quizQuestion.findMany({ where: { - stem, + stem: { in: stems }, quiz: { userId: job.userId }, }, - select: { id: true }, - }); - if (existing) { + select: { stem: true }, + })).map(r => r.stem), + ); + + for (let i = 0; i < valid.length; i++) { + const q = valid[i]; + const stem = stems[i]; + + if (existingStems.has(stem)) { this.logger.warn(`convertQuizCandidates: skipping duplicate question stem="${stem.substring(0, 50)}"`); continue; } @@ -521,6 +526,15 @@ export class RuntimeInternalService { ) : new Set(); + // Batch pre-fetch existing fronts for dedup (avoids N+1 per-item queries) + const fronts = valid.map(c => c.front ?? c.question); + const existingFronts = new Set( + (await this.prisma.flashcard.findMany({ + where: { userId: job.userId, front: { in: fronts }, deletedAt: null }, + select: { front: true }, + })).map(r => r.front), + ); + for (const card of valid) { const rawIds: string[] = Array.isArray(card.sourceBlockIds) ? card.sourceBlockIds : []; const validIds = rawIds.filter(id => existingBlockIds.has(id)); @@ -528,14 +542,8 @@ export class RuntimeInternalService { this.logger.warn(`convertFlashcardCandidates: filtered ${rawIds.length - validIds.length} invalid sourceBlockIds`); } - - // Dedup: skip if same front already exists for this user const front = card.front ?? card.question; - const dupCheck = await this.prisma.flashcard.findFirst({ - where: { userId: job.userId, front, deletedAt: null }, - select: { id: true }, - }); - if (dupCheck) { + if (existingFronts.has(front)) { this.logger.warn(`convertFlashcardCandidates: skipping duplicate front="${front.substring(0, 50)}"`); continue; }