fix: make default initialization concurrency-safe
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class ConcurrentInitializationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_rule_reads_share_the_single_created_settings_row()
|
||||||
|
{
|
||||||
|
await using var database = await Database.CreateAsync();
|
||||||
|
await using var first = database.CreateContext(new SaveGate(delay: false));
|
||||||
|
await using var second = database.CreateContext(new SaveGate(delay: true));
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(
|
||||||
|
new RulesController(first).Get(default),
|
||||||
|
new RulesController(second).Get(default));
|
||||||
|
|
||||||
|
Assert.All(results, result => Assert.NotNull(result.Result));
|
||||||
|
await using var verify = database.CreateContext();
|
||||||
|
Assert.Single(await verify.UserRuleSettings.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_checklist_reads_share_the_single_default_item_set()
|
||||||
|
{
|
||||||
|
await using var database = await Database.CreateAsync();
|
||||||
|
int jobId;
|
||||||
|
await using (var setup = database.CreateContext())
|
||||||
|
{
|
||||||
|
var company = new Company { OwnerUserId = Database.Owner, Name = "Acme" };
|
||||||
|
setup.Companies.Add(company);
|
||||||
|
await setup.SaveChangesAsync();
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
OwnerUserId = Database.Owner,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
Status = "Applied",
|
||||||
|
Description = "Needs .NET and SQL.",
|
||||||
|
};
|
||||||
|
setup.JobApplications.Add(job);
|
||||||
|
await setup.SaveChangesAsync();
|
||||||
|
jobId = job.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var first = database.CreateContext(new SaveGate(delay: false));
|
||||||
|
await using var second = database.CreateContext(new SaveGate(delay: true));
|
||||||
|
var results = await Task.WhenAll(
|
||||||
|
new ApplicationChecklistService(first).GetAsync(Database.Owner, jobId, default),
|
||||||
|
new ApplicationChecklistService(second).GetAsync(Database.Owner, jobId, default));
|
||||||
|
|
||||||
|
var expectedCount = results[0]!.Items.Count;
|
||||||
|
Assert.Equal(expectedCount, results[1]!.Items.Count);
|
||||||
|
await using var verify = database.CreateContext();
|
||||||
|
Assert.Equal(expectedCount, await verify.ApplicationChecklistItems.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SaveGate(bool delay) : SaveChangesInterceptor
|
||||||
|
{
|
||||||
|
private static readonly object Sync = new();
|
||||||
|
private static TaskCompletionSource<bool> _bothArrived = NewSignal();
|
||||||
|
private static int _arrivals;
|
||||||
|
private int _used;
|
||||||
|
|
||||||
|
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||||
|
DbContextEventData eventData,
|
||||||
|
InterceptionResult<int> result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _used, 1) != 0) return result;
|
||||||
|
|
||||||
|
Task wait;
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
if (_arrivals == 0 && _bothArrived.Task.IsCompleted) _bothArrived = NewSignal();
|
||||||
|
_arrivals++;
|
||||||
|
if (_arrivals == 2)
|
||||||
|
{
|
||||||
|
_arrivals = 0;
|
||||||
|
_bothArrived.TrySetResult(true);
|
||||||
|
}
|
||||||
|
wait = _bothArrived.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait.WaitAsync(cancellationToken);
|
||||||
|
if (delay) await Task.Delay(100, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource<bool> NewSignal() =>
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Database : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const string Owner = "user-1";
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly DbContextOptions<JobTrackerContext> _setupOptions;
|
||||||
|
private readonly ICurrentUserService _currentUser;
|
||||||
|
|
||||||
|
private Database(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
_setupOptions = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
.UseSqlite($"Data Source={path}")
|
||||||
|
.Options;
|
||||||
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
currentUser.SetupGet(service => service.UserId).Returns(Owner);
|
||||||
|
_currentUser = currentUser.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Database> CreateAsync()
|
||||||
|
{
|
||||||
|
var database = new Database(Path.Combine(Path.GetTempPath(), $"jobtracker-concurrent-{Guid.NewGuid():N}.db"));
|
||||||
|
await using var db = new JobTrackerContext(database._setupOptions, database._currentUser);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobTrackerContext CreateContext(SaveChangesInterceptor? interceptor = null)
|
||||||
|
{
|
||||||
|
var builder = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite($"Data Source={_path}");
|
||||||
|
if (interceptor is not null) builder.AddInterceptors(interceptor);
|
||||||
|
return new JobTrackerContext(builder.Options, _currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
File.Delete(_path);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,13 +27,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
// Per-user rule settings when authenticated.
|
// Per-user rule settings when authenticated.
|
||||||
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
||||||
{
|
{
|
||||||
var u = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
|
var u = await GetOrCreateUserSettingsAsync(_db.CurrentUserId, cancellationToken);
|
||||||
if (u is null)
|
|
||||||
{
|
|
||||||
u = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
|
|
||||||
_db.UserRuleSettings.Add(u);
|
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new RuleSettings
|
return Ok(new RuleSettings
|
||||||
{
|
{
|
||||||
@@ -64,12 +58,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
// Per-user rule settings when authenticated.
|
// Per-user rule settings when authenticated.
|
||||||
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
||||||
{
|
{
|
||||||
var s = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
|
var s = await GetOrCreateUserSettingsAsync(_db.CurrentUserId, cancellationToken);
|
||||||
if (s is null)
|
|
||||||
{
|
|
||||||
s = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
|
|
||||||
_db.UserRuleSettings.Add(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
s.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
|
s.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
|
||||||
s.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
|
s.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
|
||||||
@@ -101,6 +90,27 @@ namespace JobTrackerApi.Controllers
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<UserRuleSettings> GetOrCreateUserSettingsAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||||
|
if (settings is not null) return settings;
|
||||||
|
|
||||||
|
settings = new UserRuleSettings { OwnerUserId = ownerUserId };
|
||||||
|
_db.UserRuleSettings.Add(settings);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
_db.Entry(settings).State = EntityState.Detached;
|
||||||
|
settings = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||||
|
if (settings is null) throw;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int Clamp(int v, int min, int max) => v < min ? min : v > max ? max : v;
|
private static int Clamp(int v, int min, int max) => v < min ? min : v > max ? max : v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService
|
|||||||
var missing = Defaults.Where(t => !existing.Contains(t.Key)).ToList();
|
var missing = Defaults.Where(t => !existing.Contains(t.Key)).ToList();
|
||||||
if (missing.Count == 0) return items;
|
if (missing.Count == 0) return items;
|
||||||
|
|
||||||
|
var seeded = new List<ApplicationChecklistItem>(missing.Count);
|
||||||
var order = 0;
|
var order = 0;
|
||||||
foreach (var template in Defaults)
|
foreach (var template in Defaults)
|
||||||
{
|
{
|
||||||
@@ -377,12 +378,27 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService
|
|||||||
};
|
};
|
||||||
_db.ApplicationChecklistItems.Add(item);
|
_db.ApplicationChecklistItems.Add(item);
|
||||||
items.Add(item);
|
items.Add(item);
|
||||||
|
seeded.Add(item);
|
||||||
}
|
}
|
||||||
order++;
|
order++;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
try
|
||||||
return items;
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
foreach (var item in seeded) _db.Entry(item).State = EntityState.Detached;
|
||||||
|
|
||||||
|
var persisted = await LoadItemsAsync(ownerUserId, jobApplicationId, ct);
|
||||||
|
var persistedKeys = persisted.Where(i => i.SystemKey is not null)
|
||||||
|
.Select(i => i.SystemKey!)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (missing.Any(template => !persistedKeys.Contains(template.Key))) throw;
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncAutoCompletionAsync(List<ApplicationChecklistItem> items, ChecklistSignals signals, CancellationToken ct)
|
private async Task SyncAutoCompletionAsync(List<ApplicationChecklistItem> items, ChecklistSignals signals, CancellationToken ct)
|
||||||
|
|||||||
@@ -25,15 +25,19 @@ export default function RulesSettingsCard() {
|
|||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
api.get<RuleSettings>("/rules")
|
api.get<RuleSettings>("/rules")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
if (!active) return;
|
||||||
setS(r.data);
|
setS(r.data);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
})
|
})
|
||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
|
if (!active) return;
|
||||||
setS(null);
|
setS(null);
|
||||||
setLoadError(getApiErrorMessage(e, t("rulesLoadFailed")));
|
setLoadError(getApiErrorMessage(e, t("rulesLoadFailed")));
|
||||||
});
|
});
|
||||||
|
return () => { active = false; };
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user