fix(rd21): guard career and job saves with durable revisions

This commit is contained in:
cesnimda
2026-10-23 08:07:23 +02:00
parent 8f2cad67f7
commit 12f342c09c
32 changed files with 1185 additions and 277 deletions
+122 -61
View File
@@ -15,12 +15,17 @@ public interface ICareerProfileService
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
// Includes Identity projection and import metadata in the canonical commit.
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
// Reads the master profile from the relational children (the source of truth). If the relational
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
// backfilled from the blob first. Returns an empty profile if the user has none.
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
// back to that blob without writing. Returns an empty profile if the user has none.
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
// The append-only version history, newest first.
@@ -29,7 +34,7 @@ public interface ICareerProfileService
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
// and the current state are both preserved; the restore is itself reversible). Returns the
// restored profile, or null if the version does not exist.
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
@@ -38,6 +43,8 @@ public interface ICareerProfileService
}
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
public sealed class CareerProjectionException(string message) : Exception(message);
public sealed class CareerProfileService : ICareerProfileService
{
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
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);
if (existing is null)
try
{
existing = new CareerProfile
var result = await action();
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return result;
}
catch
{
try
{
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
}
finally
{
// A later SaveChanges must never publish an operation whose response failed.
_db.ChangeTracker.Clear();
}
throw;
}
}
public async Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken)
{
// Hold a read snapshot across the canonical row and relational children.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken)
: null;
var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId)
.Select(x => new
{
x.Version,
CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(),
}).FirstOrDefaultAsync(cancellationToken);
var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0,
await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken))
: new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return snapshot;
}
public Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) =>
ExecuteAtomicAsync(async () =>
{
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion)
throw new DbUpdateConcurrencyException("The career profile changed after it was loaded.");
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.SerializePersisted(profile);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex))
{
throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex);
}
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Version = 1,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
});
return profile;
}, cancellationToken);
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
await _db.SaveChangesAsync(cancellationToken);
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return profile;
}
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
{
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
MySqlConnector.MySqlException mysql => mysql.Number == 1062
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
_ => false,
};
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
// A read cannot materialize unversioned children during a concurrent replacement.
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
AssignStableIds(fromBlob);
NormalizeDates(fromBlob);
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
return fromBlob;
}
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));
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().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);
}
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
}
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return null;
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
// history, so a restore can itself be undone by restoring the version before it.
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
}
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the