feat: support concurrency in json file storage provider

This commit is contained in:
Stevan Freeborn
2026-03-29 21:25:39 -05:00
parent 8453381fe0
commit c8dbde8dc8
2 changed files with 61 additions and 9 deletions
@@ -4,6 +4,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
public sealed class JsonFileStorageProvider(JsonStorageOptions options) public sealed class JsonFileStorageProvider(JsonStorageOptions options)
{ {
private static readonly SemaphoreSlim FileLock = new(1, 1);
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
@@ -13,7 +14,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options)
public async Task<string> ReadAsync(string key, CancellationToken ct = default) public async Task<string> ReadAsync(string key, CancellationToken ct = default)
{ {
var data = await LoadAsync(ct); var data = await LoadWithLockAsync(ct);
if (data is not null && data.TryGetValue(key, out var v)) if (data is not null && data.TryGetValue(key, out var v))
{ {
@@ -25,23 +26,55 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options)
public Task<Dictionary<string, string>> ReadAllAsync(CancellationToken ct = default) public Task<Dictionary<string, string>> ReadAllAsync(CancellationToken ct = default)
{ {
return LoadAsync(ct); return LoadWithLockAsync(ct);
} }
public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
{
await FileLock.WaitAsync(ct);
try
{ {
var data = await LoadAsync(ct); var data = await LoadAsync(ct);
data[key] = encryptedData; data[key] = encryptedData;
await SaveAsync(data, ct); await SaveAsync(data, ct);
} }
finally
{
FileLock.Release();
}
}
public async Task<bool> DeleteAsync(string key, CancellationToken ct = default) public async Task<bool> DeleteAsync(string key, CancellationToken ct = default)
{
await FileLock.WaitAsync(ct);
try
{ {
var data = await LoadAsync(ct); var data = await LoadAsync(ct);
var result = data.Remove(key); var result = data.Remove(key);
await SaveAsync(data, ct); await SaveAsync(data, ct);
return result; return result;
} }
finally
{
FileLock.Release();
}
}
private async Task<Dictionary<string, string>> LoadWithLockAsync(CancellationToken ct)
{
await FileLock.WaitAsync(ct);
try
{
return await LoadAsync(ct);
}
finally
{
FileLock.Release();
}
}
private async Task<Dictionary<string, string>> LoadAsync(CancellationToken ct) private async Task<Dictionary<string, string>> LoadAsync(CancellationToken ct)
{ {
@@ -79,4 +79,23 @@ public class JsonFileStorageProviderTests : IDisposable
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
[Fact]
public async Task WriteAsync_WhenCalledConcurrently_ItShouldNotThrowFileInUseException()
{
const int numberOfWrites = 50;
var tasks = new List<Task>();
foreach (var index in Enumerable.Range(0, numberOfWrites))
{
tasks.Add(Task.Run(() => _sut.WriteAsync($"Key{index}", $"Val{index}")));
}
var act = async () => await Task.WhenAll(tasks);
await act.Should().NotThrowAsync();
var allData = await _sut.ReadAllAsync();
allData.Should().HaveCount(numberOfWrites);
}
} }