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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:13:58 +03:00
parent 8b143dcb12
commit 87de7d3f77
5 changed files with 49 additions and 27 deletions
+1 -1
View File
@@ -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<CvMatcherChatCacheEntity>(entity =>
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
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
{
/// <inheritdoc />
@@ -80,7 +80,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
@@ -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
/// <inheritdoc />
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<string>(
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<string>(
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);
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
@@ -77,7 +77,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.Property<string>("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");
@@ -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<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)