From 87de7d3f7746c594780e84d56d3606ed7f4b99ec Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:13:58 +0300 Subject: [PATCH] Fix duplicate key violation in CvMatchResults by updating unique constraint to 3 columns The Results table had a unique constraint on (CvDocumentId, JobDocumentId) but the code expects uniqueness on (CvDocumentId, JobDocumentId, Language). When matching the same CV against the same job in different languages, this caused duplicate key violations. Changes: - Updated CvMatcherDbContext to define 3-column unique index including Language - Generated proper EF Core migration to drop 2-column index and create 3-column index - Updated ModelSnapshot to reflect new 3-column index definition - Added exception handling in SaveMatchAsync to gracefully handle any race conditions where duplicate key violations could occur between the existence check and insert The migration will be automatically applied on container startup via db.Database.Migrate(). Co-Authored-By: Claude Haiku 4.5 --- Apis/cv-matcher-data/CvMatcherDbContext.cs | 2 +- ...ueConstraintToIncludeLanguage.Designer.cs} | 6 ++-- ...sultsUniqueConstraintToIncludeLanguage.cs} | 33 +++++++++++++------ .../CvMatcherDbContextModelSnapshot.cs | 6 ++-- .../Repositories/EfMatcherRepository.cs | 29 ++++++++++------ 5 files changed, 49 insertions(+), 27 deletions(-) rename Apis/cv-matcher-data/Migrations/{20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs => 20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs} (96%) rename Apis/cv-matcher-data/Migrations/{20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs => 20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs} (59%) diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index ab51c35..d2cde82 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -36,7 +36,7 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); - entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique(); + entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); }); modelBuilder.Entity(entity => diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs similarity index 96% rename from Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs index 560b81f..a50831d 100644 --- a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using CvMatcher.Data; using Microsoft.EntityFrameworkCore; @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] - [Migration("20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage")] + [Migration("20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage")] partial class UpdateResultsUniqueConstraintToIncludeLanguage { /// @@ -80,7 +80,7 @@ namespace CvMatcher.Data.Migrations b.Property("Language") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("ResultJson") .IsRequired() diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs similarity index 59% rename from Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs rename to Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs index a2447f0..cf4c4ce 100644 --- a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs +++ b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs @@ -1,5 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -11,18 +10,23 @@ namespace CvMatcher.Data.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - // The Language column was added in migration 20260524140335, but the unique constraint - // was never updated from (CvDocumentId, JobDocumentId) to include Language. - // This caused duplicate key violations when matching the same CV+Job in different languages. - migrationBuilder.DropIndex( name: "IX_Results_CvDocumentId_JobDocumentId", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results"); + migrationBuilder.AlterColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + migrationBuilder.CreateIndex( name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); @@ -33,12 +37,21 @@ namespace CvMatcher.Data.Migrations { migrationBuilder.DropIndex( name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results"); + migrationBuilder.AlterColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + migrationBuilder.CreateIndex( name: "IX_Results_CvDocumentId_JobDocumentId", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId" }, unique: true); diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 33937c3..64a6262 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CvMatcher.Data; using Microsoft.EntityFrameworkCore; @@ -77,7 +77,7 @@ namespace CvMatcher.Data.Migrations b.Property("Language") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("ResultJson") .IsRequired() @@ -88,7 +88,7 @@ namespace CvMatcher.Data.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId") + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") .IsUnique(); b.ToTable("Results", "cvMatcher"); diff --git a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index 8965fc2..504fd0c 100644 --- a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -48,18 +48,27 @@ public sealed class EfMatcherRepository : IMatcherRepository if (exists) return; - _db.CvMatchResults.Add(new CvMatchResultEntity + try { - Id = Guid.NewGuid().ToString("N"), - CvDocumentId = cvDocumentId, - JobDocumentId = jobDocumentId, - Language = language, - ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), - Score = response.Score, - CreatedAt = DateTime.UtcNow - }); + _db.CvMatchResults.Add(new CvMatchResultEntity + { + Id = Guid.NewGuid().ToString("N"), + CvDocumentId = cvDocumentId, + JobDocumentId = jobDocumentId, + Language = language, + ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Score = response.Score, + CreatedAt = DateTime.UtcNow + }); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true + || ex.InnerException?.Message.Contains("unique") == true) + { + // Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync. + // This is safe to ignore — the match result already exists in the database. + } } public async Task GetChatCompletionAsync(string cacheKey, CancellationToken ct)