From c8dbde8dc8f6cabd5071151856aa945666661c42 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:39 -0500 Subject: [PATCH] feat: support concurrency in json file storage provider --- .../Storage/JsonFileStorageProvider.cs | 51 +++++++++++++++---- .../Storage/JsonFileStorageProviderTests.cs | 19 +++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs index 9fcb45f..8fd8482 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs @@ -4,6 +4,7 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage; public sealed class JsonFileStorageProvider(JsonStorageOptions options) { + private static readonly SemaphoreSlim FileLock = new(1, 1); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -13,7 +14,7 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public async Task 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)) { @@ -25,22 +26,54 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) public Task> ReadAllAsync(CancellationToken ct = default) { - return LoadAsync(ct); + return LoadWithLockAsync(ct); } public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default) { - var data = await LoadAsync(ct); - data[key] = encryptedData; - await SaveAsync(data, ct); + await FileLock.WaitAsync(ct); + + try + { + var data = await LoadAsync(ct); + data[key] = encryptedData; + await SaveAsync(data, ct); + } + finally + { + FileLock.Release(); + } } public async Task DeleteAsync(string key, CancellationToken ct = default) { - var data = await LoadAsync(ct); - var result = data.Remove(key); - await SaveAsync(data, ct); - return result; + await FileLock.WaitAsync(ct); + + try + { + var data = await LoadAsync(ct); + var result = data.Remove(key); + await SaveAsync(data, ct); + return result; + } + finally + { + FileLock.Release(); + } + } + + private async Task> LoadWithLockAsync(CancellationToken ct) + { + await FileLock.WaitAsync(ct); + + try + { + return await LoadAsync(ct); + } + finally + { + FileLock.Release(); + } } private async Task> LoadAsync(CancellationToken ct) diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs index 69eac9f..922e827 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Storage/JsonFileStorageProviderTests.cs @@ -79,4 +79,23 @@ public class JsonFileStorageProviderTests : IDisposable GC.SuppressFinalize(this); } + + [Fact] + public async Task WriteAsync_WhenCalledConcurrently_ItShouldNotThrowFileInUseException() + { + const int numberOfWrites = 50; + var tasks = new List(); + + 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); + } } \ No newline at end of file