From 6a432273158465d89d20f5417ea5a58a7d40e24c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Jul 2026 20:38:26 +0200 Subject: [PATCH] perf(gmail): narrow review-decision lookup to the single ThreadId Backlog item 2. CreateSuggestedJob, RelinkThread, and UnlinkThread each upserted exactly one GmailReviewDecision by ThreadId but loaded every review decision for the owner (GmailReviewDecisions.Where(OwnerUserId == x).ToList()) just to linear-scan for the one match. Replaced with FirstOrDefaultAsync filtered on both OwnerUserId and ThreadId, and added a single-row UpsertReviewDecision overload alongside the existing dictionary-based one (still used by the review-queue endpoints, which genuinely need every decision at once to render the queue). 169/169 green. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi/Controllers/GmailController.cs | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 7256bc1..ba05598 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -671,7 +671,9 @@ public sealed class GmailController : ControllerBase imported++; } - UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes); + var suggestedJobReviewDecision = await _db.GmailReviewDecisions + .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken); + UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes); await _db.SaveChangesAsync(cancellationToken); return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped)); } @@ -725,8 +727,9 @@ public sealed class GmailController : ControllerBase imported++; } - var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken); - UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note); + var reviewDecision = await _db.GmailReviewDecisions + .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken); + UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note); await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages)); } @@ -752,10 +755,11 @@ public sealed class GmailController : ControllerBase _db.Correspondences.RemoveRange(messages); } - var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken); + var reviewDecision = await _db.GmailReviewDecisions + .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken); var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant(); if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review"; - UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note); + UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note); await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision)); } @@ -1065,9 +1069,11 @@ public sealed class GmailController : ControllerBase existing.UpdatedAt = DateTimeOffset.UtcNow; } - private void UpsertReviewDecision(List decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note) + // Single-thread upsert: callers acting on exactly one ThreadId should load just that row + // (see the FirstOrDefaultAsync call sites below) rather than every review decision for the + // owner just to scan for one match. + private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note) { - var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId); if (existing is null) { existing = new GmailReviewDecision @@ -1075,7 +1081,6 @@ public sealed class GmailController : ControllerBase OwnerUserId = ownerUserId, ThreadId = threadId, }; - decisions.Add(existing); _db.GmailReviewDecisions.Add(existing); }