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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user