fix(cv): make profile saves atomic

This commit is contained in:
cesnimda
2026-08-29 23:12:33 +02:00
parent 487c5f59a9
commit 8288923e5d
8 changed files with 127 additions and 27 deletions
@@ -175,6 +175,12 @@ public static class CvVariantSettingsJson
{
section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble");
section.Columns = section.Columns is 1 or 2 ? section.Columns : null;
section.ItemOrder = section.ItemOrder?
.Where(key => !string.IsNullOrWhiteSpace(key))
.Select(key => key.Trim())
.Distinct(StringComparer.Ordinal)
.Take(500)
.ToList();
}
foreach (var section in s.CustomSections)
{
+43 -16
View File
@@ -53,6 +53,13 @@ public sealed class CareerProfileService : ICareerProfileService
AssignStableIds(profile);
NormalizeDates(profile);
// Publish the profile row, relational children, and history revision atomically. Without
// this boundary a concurrent outline/read can observe the new profile before its children,
// enter the legacy backfill path, and insert a second copy of every child row.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(cancellationToken)
: null;
var json = StructuredCvProfileJson.SerializePersisted(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
@@ -90,6 +97,7 @@ public sealed class CareerProfileService : ICareerProfileService
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return profile;
}
@@ -115,12 +123,12 @@ public sealed class CareerProfileService : ICareerProfileService
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
}
var experiences = await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var education = await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var skills = await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var projects = await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var certifications = await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var languages = await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
@@ -130,12 +138,12 @@ public sealed class CareerProfileService : ICareerProfileService
var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var experiences = UniqueChildren(await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
// No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
// the blob so a public CV still renders. Read-only, so we don't backfill here.
@@ -195,26 +203,45 @@ public sealed class CareerProfileService : ICareerProfileService
private static void AssignStableIds(StructuredCvProfile profile)
{
var used = new HashSet<string>(StringComparer.Ordinal);
foreach (var job in profile.Jobs)
{
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
job.Id = UniqueItemId(job.Id, used);
}
foreach (var education in profile.Education)
{
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
education.Id = UniqueItemId(education.Id, used);
}
foreach (var certification in profile.Certifications)
{
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
certification.Id = UniqueItemId(certification.Id, used);
}
foreach (var project in profile.Projects)
{
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
project.Id = UniqueItemId(project.Id, used);
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
private static string UniqueItemId(string? candidate, HashSet<string> used)
{
var normalized = candidate?.Trim();
if (!string.IsNullOrWhiteSpace(normalized) && used.Add(normalized)) return normalized;
string generated;
do generated = NewItemId(); while (!used.Add(generated));
return generated;
}
// Old race-affected databases can contain exact duplicate rows with one stable ItemKey. Keep
// reads deterministic and avoid duplicate CV output while preserving the stored rows for an
// explicit repair migration/backup workflow.
private static List<T> UniqueChildren<T>(List<T> items) where T : CareerChildEntity =>
items.GroupBy(item => item.ItemKey, StringComparer.Ordinal)
.Select(group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).First())
.OrderBy(item => item.SortOrder).ThenBy(item => item.Id)
.ToList();
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)