From 2a08b95a63f63c7a165d5b3d79162a9915526305 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 8 Apr 2026 18:36:29 +0300 Subject: [PATCH 1/7] feat: implement cancellation token support and use in many APIs --- CHANGELOG.md | 8 ++ README.md | 22 ++-- src/ArrowDbCore/ArrowDb.Factory.cs | 26 +++-- src/ArrowDbCore/ArrowDb.GetOrAdd.cs | 18 +++- src/ArrowDbCore/ArrowDb.Serialization.cs | 17 +-- src/ArrowDbCore/ArrowDb.cs | 5 +- src/ArrowDbCore/ArrowDbCore.csproj | 2 +- src/ArrowDbCore/ArrowDbTransactionScope.cs | 14 ++- src/ArrowDbCore/IDbSerializer.cs | 8 +- src/ArrowDbCore/Readme.Nuget.md | 4 + .../Serializers/BaseFileSerializer.cs | 6 +- .../Serializers/InMemorySerializer.cs | 6 +- .../ArrowDbCore.Tests.Integrity.csproj | 1 + .../ArrowDbCore.Tests.Unit.Isolated.csproj | 1 + .../ArrowDbCore.Tests.Unit.csproj | 1 + tests/ArrowDbCore.Tests.Unit/Cancellation.cs | 102 ++++++++++++++++++ tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs | 46 ++++++-- tests/ArrowDbCore.Tests.Unit/RollbackRace.cs | 6 +- .../SerializationPendingChanges.cs | 6 +- 19 files changed, 240 insertions(+), 59 deletions(-) create mode 100644 tests/ArrowDbCore.Tests.Unit/Cancellation.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce24b7..1092674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog (Sorted by Date in Descending Order) +## 2.0.0.0 + +- Added optional `CancellationToken` parameters to ArrowDb async APIs, including factory initialization, `SerializeAsync`, `RollbackAsync`, `GetOrAddAsync`, and `BeginTransaction`. +- Updated `GetOrAddAsync` factory delegates to receive the active `CancellationToken`. +- Updated the public `IDbSerializer` contract to receive an optional `CancellationToken` for serialization and deserialization. +- Transaction scopes can now carry a cancellation token into the outermost implicit serialize during disposal. +- This is a breaking release for callers implementing `IDbSerializer` or calling `GetOrAddAsync` with the old delegate shapes. + ## 1.6.0.0 - Improve correctness of internal change counting to ensure that changes that happened during serialization are still tracked. diff --git a/README.md b/README.md index 871936e..0360036 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This policy does not affect value types (`structs`); their `default` values (e.g Installation is done via NuGet: `dotnet add package ArrowDbCore` -Initializing the db is done via the factory methods, they return the instance as `ValueTask` and may or may not be asynchronous depending on the selected serializer implementation. The default serializer is `FileSerializer`, which serializes the db to a file on disk. The following example demonstrates its usage, and more details on serializers will be discussed later. +Initializing the db is done via the factory methods, they return the instance as `ValueTask` and may or may not be asynchronous depending on the selected serializer implementation. The default serializer is `FileSerializer`, which serializes the db to a file on disk. These async APIs accept an optional `CancellationToken`. The following example demonstrates the basic usage, and more details on serializers will be discussed later. ```csharp // manual instance creation @@ -82,6 +82,8 @@ Up until now, the data was stored in-memory, to finalize and persist the changes ```csharp await db.SerializeAsync(); +// or +await db.SerializeAsync(cancellationToken); ``` ## APIs @@ -242,11 +244,11 @@ A common code pattern for caching usually consists of some `GetOrAdd` method, th `ArrowDb` supports this via the `async ValueTask` method: ```csharp -async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory); -async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument); +async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, CancellationToken cancellationToken = default); +async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument, CancellationToken cancellationToken = default); ``` -If the value exists, the asynchronous factory method is not called, and the value is returned synchronously. Otherwise the factory will produce the value, `Upsert` it, then return it. +If the value exists, the asynchronous factory method is not called, and the value is returned synchronously. Otherwise the factory will receive the key and the supplied `CancellationToken`, produce the value, `Upsert` it, then return it. ### Concurrency Note @@ -283,8 +285,8 @@ The `IDbSerializer` is exposed and can be used to implement custom serializers: ```csharp public interface IDbSerializer { - ValueTask> DeserializeAsync(); - ValueTask SerializeAsync(ConcurrentDictionary data); + ValueTask> DeserializeAsync(CancellationToken cancellationToken = default); + ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default); } ``` @@ -306,6 +308,8 @@ In case you want to rollback the changes, you can call the following method: ```csharp await db.RollbackAsync(); +// or +await db.RollbackAsync(cancellationToken); ``` `RollbackAsync` restores the last persisted state (as returned by your current serializer) by: @@ -335,18 +339,18 @@ While the above definition explains how users can manually control the transacti ```csharp var db = await ArrowDb.CreateFromFile("path.db"); // this uses a "using" statement. -await using (var scope = db.BeginTransaction()) { +await using (var scope = db.BeginTransaction(cancellationToken)) { db.Upsert(john.Name, john, MyJsonContext.Default.Person); } // the scope was disposed, and SerializeAsync was called implicitly // The same also works with a "using" declaration, that will bind to the containing scope void SomeMethod() { - await using var scope = db.BeginTransaction(); + await using var scope = db.BeginTransaction(cancellationToken); db.Upsert(john.Name, john, MyJsonContext.Default.Person); } // the function scope ends here, and implicitly closes the scope of the transaction ``` -Using a transaction scope ensures that `SerializeAsync` is always called, even if an `Exception` is thrown. These scopes can be nested, and serialization will only occur when the outermost scope is disposed. +Using a transaction scope ensures that `SerializeAsync` is always called, even if an `Exception` is thrown. These scopes can be nested, and serialization will only occur when the outermost scope is disposed. If the `CancellationToken` passed to the outermost scope is canceled before disposal commits, the implicit serialize throws `OperationCanceledException` and the pending changes remain in memory until you retry `SerializeAsync` or call `RollbackAsync`. `ArrowDbTransactionScope` also implements the regular `IDisposable` interface, meaning it can be used in a non-`async` method. However it internally calls the `DisposeAsync` method in a blocking manner, with the built in file-based serializers (`FileSerializer` and `AesFileSerializer`) it is completely safe as they naturally operate synchronously. However if you implemented a remote serializer or an `async` one, you should use the `Async Disposable` pattern accordingly. diff --git a/src/ArrowDbCore/ArrowDb.Factory.cs b/src/ArrowDbCore/ArrowDb.Factory.cs index 179e1cd..847882d 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -9,10 +9,12 @@ public partial class ArrowDb { /// Initializes a file/disk backed database at the specified path /// /// The path that the file that backs the database + /// A cancellation token. /// A database instance - public static async ValueTask CreateFromFile(string path) { + public static async ValueTask CreateFromFile(string path, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); - var data = await serializer.DeserializeAsync(); + var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer); } @@ -21,20 +23,24 @@ public static async ValueTask CreateFromFile(string path) { /// /// The path that the file that backs the database /// The instance to use + /// A cancellation token. /// A database instance - public static async ValueTask CreateFromFileWithAes(string path, Aes aes) { + public static async ValueTask CreateFromFileWithAes(string path, Aes aes, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); - var data = await serializer.DeserializeAsync(); + var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer); } /// /// Initializes an in-memory database /// + /// A cancellation token. /// A database instance - public static async ValueTask CreateInMemory() { + public static async ValueTask CreateInMemory(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var serializer = new InMemorySerializer(); - var data = await serializer.DeserializeAsync(); + var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer); } @@ -42,9 +48,11 @@ public static async ValueTask CreateInMemory() { /// Initializes a database with a custom implementation /// /// A custom implementation + /// A cancellation token. /// A database instance - public static async ValueTask CreateCustom(IDbSerializer serializer) { - var data = await serializer.DeserializeAsync(); + public static async ValueTask CreateCustom(IDbSerializer serializer, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer); } @@ -74,4 +82,4 @@ private static class TypeNameCache { /// public static readonly string TypeName = typeof(T).Name; } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs index 4182bdd..c0918fd 100644 --- a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs +++ b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs @@ -11,6 +11,7 @@ public partial class ArrowDb { /// The key at which to find or add the value /// The json type info for the value type /// The function used to generate a value for the key + /// A cancellation token. /// The value after finding or adding it /// /// @@ -22,11 +23,14 @@ public partial class ArrowDb { /// If you need single-invocation semantics for (e.g. the factory has side-effects or is expensive), guard the call site with a keyed lock. /// /// - public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory) { + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, CancellationToken cancellationToken = default) { if (Lookup.TryGetValue(key, out var source)) { return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; } - var val = await valueFactory(key); + + cancellationToken.ThrowIfCancellationRequested(); + var val = await valueFactory(key, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); Upsert(key, val, jsonTypeInfo); return val; } @@ -40,6 +44,7 @@ public async ValueTask GetOrAddAsync(string key, JsonTypeInfoThe json type info for the value type /// The function used to generate a value for the key /// An argument that could be provided to the valueFactory function to avoid a closure + /// A cancellation token. /// The value after finding or adding it /// /// @@ -51,12 +56,15 @@ public async ValueTask GetOrAddAsync(string key, JsonTypeInfo (e.g. the factory has side-effects or is expensive), guard the call site with a keyed lock. /// /// - public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument) { + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument, CancellationToken cancellationToken = default) { if (Lookup.TryGetValue(key, out var source)) { return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; } - var val = await valueFactory(key, factoryArgument); + + cancellationToken.ThrowIfCancellationRequested(); + var val = await valueFactory(key, factoryArgument, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); Upsert(key, val, jsonTypeInfo); return val; } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.Serialization.cs b/src/ArrowDbCore/ArrowDb.Serialization.cs index 75a0202..44f1dc5 100644 --- a/src/ArrowDbCore/ArrowDb.Serialization.cs +++ b/src/ArrowDbCore/ArrowDb.Serialization.cs @@ -9,14 +9,16 @@ public partial class ArrowDb { /// /// If there are no pending updates, this method does nothing, otherwise it serializes the database and resets the pending updates counter /// - public async Task SerializeAsync() { + /// A cancellation token. + public async Task SerializeAsync(CancellationToken cancellationToken = default) { if (Interlocked.Read(ref _pendingChanges) == 0) { return; } + + await Semaphore.WaitAsync(cancellationToken); try { - await Semaphore.WaitAsync(); var observedPendingChanges = Interlocked.Read(ref _pendingChanges); - await Serializer.SerializeAsync(Source); + await Serializer.SerializeAsync(Source, cancellationToken); Interlocked.CompareExchange(ref _pendingChanges, 0, observedPendingChanges); // reset pending changes only if unchanged } finally { Semaphore.Release(); @@ -37,11 +39,12 @@ private void WaitIfSerializing() { /// /// Rolls the entire database to the last persisted state /// - public async Task RollbackAsync() { + /// A cancellation token. + public async Task RollbackAsync(CancellationToken cancellationToken = default) { + await Semaphore.WaitAsync(cancellationToken); try { - await Semaphore.WaitAsync(); Interlocked.Increment(ref StateEpoch); - var prevState = await Serializer.DeserializeAsync(); + var prevState = await Serializer.DeserializeAsync(cancellationToken); Source.Clear(); Interlocked.Exchange(ref Source, prevState); Lookup = Source.GetAlternateLookup>(); @@ -50,4 +53,4 @@ public async Task RollbackAsync() { Semaphore.Release(); } } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.cs b/src/ArrowDbCore/ArrowDb.cs index 0d87f78..0a26f6f 100644 --- a/src/ArrowDbCore/ArrowDb.cs +++ b/src/ArrowDbCore/ArrowDb.cs @@ -97,6 +97,7 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// The implements both and , allowing it to be used in both synchronous and asynchronous contexts. /// + /// A cancellation token for the outermost implicit serialize operation. /// A new instance. - public ArrowDbTransactionScope BeginTransaction() => new(this); -} \ No newline at end of file + public ArrowDbTransactionScope BeginTransaction(CancellationToken cancellationToken = default) => new(this, cancellationToken); +} diff --git a/src/ArrowDbCore/ArrowDbCore.csproj b/src/ArrowDbCore/ArrowDbCore.csproj index ad63200..de566b4 100644 --- a/src/ArrowDbCore/ArrowDbCore.csproj +++ b/src/ArrowDbCore/ArrowDbCore.csproj @@ -4,7 +4,7 @@ net9.0;net10.0 enable enable - 1.6.0 + 2.0.0 true true true diff --git a/src/ArrowDbCore/ArrowDbTransactionScope.cs b/src/ArrowDbCore/ArrowDbTransactionScope.cs index 7bf6e4c..b387cc1 100644 --- a/src/ArrowDbCore/ArrowDbTransactionScope.cs +++ b/src/ArrowDbCore/ArrowDbTransactionScope.cs @@ -6,14 +6,17 @@ namespace ArrowDbCore; /// public sealed class ArrowDbTransactionScope : IAsyncDisposable, IDisposable { private readonly ArrowDb _database; + private readonly CancellationToken _cancellationToken; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The database instance - internal ArrowDbTransactionScope(ArrowDb database) { + /// A cancellation token for the outermost implicit serialize operation. + internal ArrowDbTransactionScope(ArrowDb database, CancellationToken cancellationToken) { _database = database; + _cancellationToken = cancellationToken; Interlocked.Increment(ref _database.TransactionDepth); } @@ -24,10 +27,11 @@ public async ValueTask DisposeAsync() { if (_disposed) { return; } + + _disposed = true; if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) { - await _database.SerializeAsync().ConfigureAwait(false); + await _database.SerializeAsync(_cancellationToken).ConfigureAwait(false); } - _disposed = true; } /// @@ -37,9 +41,9 @@ public void Dispose() { #pragma warning disable CA2012 var task = DisposeAsync(); #pragma warning restore CA2012 - if (task.IsCompleted) { + if (task.IsCompletedSuccessfully) { return; } task.GetAwaiter().GetResult(); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/IDbSerializer.cs b/src/ArrowDbCore/IDbSerializer.cs index 3352e51..2be2a44 100644 --- a/src/ArrowDbCore/IDbSerializer.cs +++ b/src/ArrowDbCore/IDbSerializer.cs @@ -9,11 +9,13 @@ public interface IDbSerializer { /// /// Deserializes the database from the underlying storage /// - ValueTask> DeserializeAsync(); + /// A cancellation token. + ValueTask> DeserializeAsync(CancellationToken cancellationToken = default); /// /// Serializes the database to the underlying storage /// /// The data to serialize - ValueTask SerializeAsync(ConcurrentDictionary data); -} \ No newline at end of file + /// A cancellation token. + ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default); +} diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index 123342e..a4ef4a3 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -22,3 +22,7 @@ Information on usage can be found in the [README](https://github.com/dusrdev/Arr ## Concurrency note: `GetOrAddAsync` `GetOrAddAsync` is intentionally **not atomic**. Under concurrency, the factory may be invoked multiple times for the same key, and the final stored value is last-writer-wins (because the value is persisted via `Upsert`). If you need single-invocation semantics for the factory (e.g. side-effects/expensive work), guard the call site with a keyed lock. + +## Cancellation support + +ArrowDb 2.0 adds optional `CancellationToken` parameters to its async APIs, including database initialization, `SerializeAsync`, `RollbackAsync`, `GetOrAddAsync`, and the public `IDbSerializer` contract. Custom serializer implementations should update their method signatures accordingly. diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs index dd03266..23ef94c 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -30,7 +30,7 @@ protected BaseFileSerializer(string path) { } /// - public ValueTask> DeserializeAsync() { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { if (!File.Exists(_dbFilePath) || new FileInfo(_dbFilePath).Length == 0) { return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -45,7 +45,7 @@ public ValueTask> DeserializeAsync() { } /// - public ValueTask SerializeAsync(ConcurrentDictionary data) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { _mutex.WaitOne(); try { using (var fileStream = File.Create(_tempFilePath)) { @@ -81,4 +81,4 @@ public void Dispose() { _disposed = true; GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/Serializers/InMemorySerializer.cs b/src/ArrowDbCore/Serializers/InMemorySerializer.cs index 46225bb..5382be1 100644 --- a/src/ArrowDbCore/Serializers/InMemorySerializer.cs +++ b/src/ArrowDbCore/Serializers/InMemorySerializer.cs @@ -10,10 +10,10 @@ public sealed class InMemorySerializer : IDbSerializer { /// /// Returns an empty dictionary /// - public ValueTask> DeserializeAsync() => ValueTask.FromResult(new ConcurrentDictionary()); + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(new ConcurrentDictionary()); /// /// Does nothing /// - public ValueTask SerializeAsync(ConcurrentDictionary data) => ValueTask.CompletedTask; -} \ No newline at end of file + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} diff --git a/tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj b/tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj index 625bf21..9813802 100644 --- a/tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj +++ b/tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj @@ -7,6 +7,7 @@ net10.0 true true + $(NoWarn);xUnit1051 diff --git a/tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj b/tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj index 0712225..00c81f7 100644 --- a/tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj +++ b/tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj @@ -8,6 +8,7 @@ net10.0 true true + $(NoWarn);xUnit1051 diff --git a/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj b/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj index 09eae78..24f4d08 100644 --- a/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj +++ b/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj @@ -8,6 +8,7 @@ net10.0 true true + $(NoWarn);xUnit1051 diff --git a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs new file mode 100644 index 0000000..0dcf5a6 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs @@ -0,0 +1,102 @@ +using System.Collections.Concurrent; + +using ArrowDbCore.Tests.Common; + +namespace ArrowDbCore.Tests.Unit; + +public class Cancellation { + [Fact] + public async Task CreateInMemory_WhenCanceled_ThrowsOperationCanceledException() { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() => ArrowDb.CreateInMemory(cancellationTokenSource.Token).AsTask()); + } + + [Fact] + public async Task CreateCustom_WhenCanceled_ThrowsOperationCanceledException() { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() => ArrowDb.CreateCustom(new CancellationSerializer(), cancellationTokenSource.Token).AsTask()); + } + + [Fact] + public async Task SerializeAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndDoesNotStartSecondSerialize() { + var serializer = new CancellationSerializer(); + var db = await ArrowDb.CreateCustom(serializer); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + + Task firstSerializeTask = db.SerializeAsync(); + await serializer.SerializeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + using var cancellationTokenSource = new CancellationTokenSource(); + Task secondSerializeTask = db.SerializeAsync(cancellationTokenSource.Token); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() => secondSerializeTask); + Assert.Equal(1, Volatile.Read(ref serializer.SerializeCalls)); + Assert.Equal(1, db.PendingChanges); + + serializer.AllowSerializeToFinish.TrySetResult(); + await firstSerializeTask; + } + + [Fact] + public async Task RollbackAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndLeavesStateUnchanged() { + var serializer = new CancellationSerializer(); + var db = await ArrowDb.CreateCustom(serializer); + int deserializeCallsBeforeRollback = Volatile.Read(ref serializer.DeserializeCalls); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + + Task serializeTask = db.SerializeAsync(); + await serializer.SerializeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + using var cancellationTokenSource = new CancellationTokenSource(); + Task rollbackTask = db.RollbackAsync(cancellationTokenSource.Token); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() => rollbackTask); + Assert.True(db.ContainsKey("seed")); + Assert.Equal(1, db.PendingChanges); + Assert.Equal(deserializeCallsBeforeRollback, Volatile.Read(ref serializer.DeserializeCalls)); + + serializer.AllowSerializeToFinish.TrySetResult(); + await serializeTask; + } + + [Fact] + public async Task TransactionScope_WhenOuterTokenCanceled_ThrowsAndLeavesPendingChanges() { + var db = await ArrowDb.CreateInMemory(); + using var cancellationTokenSource = new CancellationTokenSource(); + + var scope = db.BeginTransaction(cancellationTokenSource.Token); + db.Upsert("1", 1, JContext.Default.Int32); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => scope.DisposeAsync().AsTask()); + Assert.True(db.ContainsKey("1")); + Assert.Equal(1, db.PendingChanges); + + await db.SerializeAsync(); + Assert.Equal(0, db.PendingChanges); + } +} + +internal sealed class CancellationSerializer : IDbSerializer { + public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + public readonly TaskCompletionSource AllowSerializeToFinish = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int DeserializeCalls; + public int SerializeCalls; + + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + Interlocked.Increment(ref DeserializeCalls); + return ValueTask.FromResult(new ConcurrentDictionary()); + } + + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + Interlocked.Increment(ref SerializeCalls); + SerializeStarted.TrySetResult(); + return new ValueTask(AllowSerializeToFinish.Task); + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs index 55ce15e..927397c 100644 --- a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs +++ b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs @@ -10,7 +10,7 @@ public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); // add before - var task = db.GetOrAddAsync("1", JContext.Default.Int32, async _ => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => { await Task.Delay(1000); return 1; }); @@ -26,7 +26,7 @@ public async Task GetOrAddAsync_WithTArg_ReturnsSynchronously_WhenExists() { Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); // add before // using a static delegate ensures that closure cannot be allocated - var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => { await Task.Delay(1000); return value; }, 1); @@ -42,7 +42,7 @@ public async Task GetOrAddAsync_ReturnsAsynchronously_WhenNotExists() { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); // doesn't exist - var task = db.GetOrAddAsync("1", JContext.Default.Int32, async _ => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => { await Task.Delay(1000); return 1; }); @@ -56,7 +56,7 @@ public async Task GetOrAddAsync_WithTArg_ReturnsAsynchronously_WhenNotExists() { Assert.Equal(0, db.Count); // doesn't exist // using a static delegate ensures that closure cannot be allocated - var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => { await Task.Delay(1000); return value; }, 1); @@ -71,7 +71,7 @@ public async Task GetOrAddAsync_FailingFactory_DoesNotAddItem() { // Act & Assert await Assert.ThrowsAsync(() => - db.GetOrAddAsync("key", JContext.Default.Int32, _ => + db.GetOrAddAsync("key", JContext.Default.Int32, (_, _) => ValueTask.FromException(new InvalidOperationException("Factory failed")) ).AsTask() ); @@ -79,4 +79,38 @@ await Assert.ThrowsAsync(() => Assert.Equal(0, db.Count); Assert.False(db.ContainsKey("key")); } -} \ No newline at end of file + + [Fact] + public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists_EvenIfCanceled() { + var db = await ArrowDb.CreateInMemory(); + db.Upsert("1", 1, JContext.Default.Int32); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + bool factoryCalled = false; + + var task = db.GetOrAddAsync("1", JContext.Default.Int32, (_, _) => { + factoryCalled = true; + return ValueTask.FromResult(2); + }, cancellationTokenSource.Token); + + Assert.True(task.IsCompletedSuccessfully); + Assert.False(factoryCalled); + Assert.Equal(1, await task); + } + + [Fact] + public async Task GetOrAddAsync_WhenCanceledAfterFactory_ReturnsCanceledAndDoesNotAddItem() { + var db = await ArrowDb.CreateInMemory(); + using var cancellationTokenSource = new CancellationTokenSource(); + + await Assert.ThrowsAsync(() => + db.GetOrAddAsync("key", JContext.Default.Int32, (_, cancellationToken) => { + cancellationTokenSource.Cancel(); + return ValueTask.FromResult(1); + }, cancellationTokenSource.Token).AsTask() + ); + + Assert.Equal(0, db.Count); + Assert.False(db.ContainsKey("key")); + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs index 242d31e..c833fb8 100644 --- a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs +++ b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs @@ -63,7 +63,7 @@ internal sealed class RollbackRaceBlockingSerializer : IDbSerializer { public void BlockNextDeserialize() => Interlocked.Exchange(ref _blockNextDeserialize, 1); - public ValueTask> DeserializeAsync() { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) { return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -77,7 +77,7 @@ private async Task> WaitAndReturnEmptyAsync return new ConcurrentDictionary(); } - public ValueTask SerializeAsync(ConcurrentDictionary data) => ValueTask.CompletedTask; + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; } internal sealed class RollbackRaceHooks { @@ -135,4 +135,4 @@ public override void Write(Utf8JsonWriter writer, RollbackRaceValue value, JsonS [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(RollbackRaceValue))] -internal partial class RollbackRaceJsonContext : JsonSerializerContext { } \ No newline at end of file +internal partial class RollbackRaceJsonContext : JsonSerializerContext { } diff --git a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs index 7f5ec38..7b183b4 100644 --- a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs +++ b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs @@ -53,11 +53,11 @@ private sealed class BlockingSerializer : IDbSerializer { public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource AllowSerializeToFinish = new(TaskCreationOptions.RunContinuationsAsynchronously); - public ValueTask> DeserializeAsync() { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { return ValueTask.FromResult(new ConcurrentDictionary()); } - public ValueTask SerializeAsync(ConcurrentDictionary data) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { SerializeStarted.TrySetResult(); return new ValueTask(AllowSerializeToFinish.Task); } @@ -122,4 +122,4 @@ public override void Write(Utf8JsonWriter writer, PendingChangesDuringSerializeV [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(PendingChangesDuringSerializeValue))] -internal partial class PendingChangesDuringSerializeJsonContext : JsonSerializerContext { } \ No newline at end of file +internal partial class PendingChangesDuringSerializeJsonContext : JsonSerializerContext { } From 6f991a1efdb563430366fea435e2ee376a25a081 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 8 Apr 2026 20:24:55 +0300 Subject: [PATCH 2/7] feat: implement snapshot ownership --- ArrowDbCore.slnx | 1 + CHANGELOG.md | 2 + README.md | 8 +- src/ArrowDbCore/ArrowDb.Factory.cs | 6 ++ src/ArrowDbCore/ArrowDbOwnershipException.cs | 27 +++++ src/ArrowDbCore/Readme.Nuget.md | 4 + .../Serializers/BaseFileSerializer.cs | 58 ++++++---- .../FileBackedTestHelpers.cs | 29 +++++ .../ArrowDbCore.Tests.Integrity/LargeFile.cs | 19 +++- .../OverwriteForceClear.cs | 11 +- .../ReadWriteCycles.cs | 13 ++- ...owDbCore.Tests.Probes.FileOwnership.csproj | 15 +++ .../OwnershipProbeMarker.cs | 3 + .../Program.cs | 18 ++++ .../ArrowDbCore.Tests.Unit.csproj | 1 + tests/ArrowDbCore.Tests.Unit/Concurrency.cs | 50 +++++---- .../FileBackedTestHelpers.cs | 29 +++++ tests/ArrowDbCore.Tests.Unit/FileOwnership.cs | 94 ++++++++++++++++ tests/ArrowDbCore.Tests.Unit/Serialization.cs | 30 ++++-- tests/ArrowDbCore.Tests.Unit/Transactions.cs | 100 +++++++++++------- 20 files changed, 416 insertions(+), 102 deletions(-) create mode 100644 src/ArrowDbCore/ArrowDbOwnershipException.cs create mode 100644 tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs create mode 100644 tests/ArrowDbCore.Tests.Probes.FileOwnership/ArrowDbCore.Tests.Probes.FileOwnership.csproj create mode 100644 tests/ArrowDbCore.Tests.Probes.FileOwnership/OwnershipProbeMarker.cs create mode 100644 tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs create mode 100644 tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs create mode 100644 tests/ArrowDbCore.Tests.Unit/FileOwnership.cs diff --git a/ArrowDbCore.slnx b/ArrowDbCore.slnx index 87413d9..8e9110e 100644 --- a/ArrowDbCore.slnx +++ b/ArrowDbCore.slnx @@ -11,6 +11,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1092674..ca8c472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Updated the public `IDbSerializer` contract to receive an optional `CancellationToken` for serialization and deserialization. - Transaction scopes can now carry a cancellation token into the outermost implicit serialize during disposal. - This is a breaking release for callers implementing `IDbSerializer` or calling `GetOrAddAsync` with the old delegate shapes. +- Built-in file-backed serializers now use single-owner writable semantics and fail fast with `ArrowDbOwnershipException` if another process already owns the same database path. +- Removed the previous cross-process writable safety claim from the built-in file serializer path; the persisted file remains a snapshot of the owning process state. ## 1.6.0.0 diff --git a/README.md b/README.md index 0360036..8c489a9 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ public interface IDbSerializer { } ``` -The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic and multi-process safe writes out of the box. +The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic writes and single-owner writable file semantics out of the box. Being that they return a `ValueTask`, the implementations can be async. This means that you can even implement serializers to persist the db to a remote server, or cloud, or whatever else you want. @@ -319,6 +319,12 @@ await db.RollbackAsync(cancellationToken); 3. The db source reference is atomically replaced with the persisted version. 4. Pending changes counter is reset to 0. +## File-backed ownership + +The built-in file-backed serializers (`FileSerializer` and `AesFileSerializer`) are single-owner writable. The first process that opens a database file owns it for the lifetime of that serializer instance. A second writable open against the same path fails fast with `ArrowDbOwnershipException`. + +This is intentional: ArrowDb keeps the live state in-process and persists snapshots to disk. The persisted file is not a shared live database between processes. + ### Concurrency note: `RollbackAsync` and writers `RollbackAsync` is intended to be a rare operation. For best results, avoid running it concurrently with writers. diff --git a/src/ArrowDbCore/ArrowDb.Factory.cs b/src/ArrowDbCore/ArrowDb.Factory.cs index 847882d..ddb87e0 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -11,6 +11,9 @@ public partial class ArrowDb { /// The path that the file that backs the database /// A cancellation token. /// A database instance + /// + /// Thrown when another process already owns the same file-backed database path. + /// public static async ValueTask CreateFromFile(string path, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); @@ -25,6 +28,9 @@ public static async ValueTask CreateFromFile(string path, CancellationT /// The instance to use /// A cancellation token. /// A database instance + /// + /// Thrown when another process already owns the same file-backed database path. + /// public static async ValueTask CreateFromFileWithAes(string path, Aes aes, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); diff --git a/src/ArrowDbCore/ArrowDbOwnershipException.cs b/src/ArrowDbCore/ArrowDbOwnershipException.cs new file mode 100644 index 0000000..b2e4492 --- /dev/null +++ b/src/ArrowDbCore/ArrowDbOwnershipException.cs @@ -0,0 +1,27 @@ +namespace ArrowDbCore; + +/// +/// Thrown when a file-backed cannot acquire exclusive ownership +/// of the underlying database file. +/// +public sealed class ArrowDbOwnershipException : IOException { + /// + /// Initializes a new instance of the class. + /// + public ArrowDbOwnershipException() { } + + /// + /// Initializes a new instance of the class. + /// + /// The exception message. + public ArrowDbOwnershipException(string? message) + : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The exception message. + /// The underlying exception. + public ArrowDbOwnershipException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index a4ef4a3..adca7a3 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -26,3 +26,7 @@ Information on usage can be found in the [README](https://github.com/dusrdev/Arr ## Cancellation support ArrowDb 2.0 adds optional `CancellationToken` parameters to its async APIs, including database initialization, `SerializeAsync`, `RollbackAsync`, `GetOrAddAsync`, and the public `IDbSerializer` contract. Custom serializer implementations should update their method signatures accordingly. + +## File-backed ownership + +The built-in file-backed serializers are single-owner writable. If another process already opened the same database path through ArrowDb's built-in file serializer path, the next writable open fails fast with `ArrowDbOwnershipException`. diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs index 23ef94c..6f614bc 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -1,32 +1,43 @@ using System.Collections.Concurrent; +using Microsoft.Win32.SafeHandles; namespace ArrowDbCore.Serializers; /// -/// Provides a base implementation for file-based serializers that ensures atomic and multi-process safe writes. +/// Provides a base implementation for file-based serializers that ensures atomic writes +/// and single-owner writable semantics for the underlying database file. /// public abstract class BaseFileSerializer : IDbSerializer, IDisposable { private readonly string _dbFilePath; - private readonly string _tempFilePath; - private readonly Mutex _mutex; + private readonly SafeFileHandle? _ownershipHandle; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The path to the database file. + /// + /// Thrown when another ArrowDb process already owns the same file-backed database path. + /// protected BaseFileSerializer(string path) { _dbFilePath = Path.GetFullPath(path); - _tempFilePath = $"{_dbFilePath}.tmp"; - string mutexName = $"Global\\ArrowDb-{Extensions.ToSHA256Hash(_dbFilePath)}"; - _mutex = new Mutex(false, mutexName); + string? directory = Path.GetDirectoryName(_dbFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + throw new DirectoryNotFoundException($"The directory '{directory}' does not exist."); + } + + _ownershipHandle = AcquireOwnershipHandle(_dbFilePath); } /// - /// Finalizer to ensure the system-wide mutex is released when the serializer is garbage collected. + /// Finalizer to ensure the ownership handle is released when the serializer is garbage collected. /// ~BaseFileSerializer() { - Dispose(); + try { + _ownershipHandle?.Dispose(); + } catch { + // Finalizers must never throw. + } } /// @@ -35,25 +46,23 @@ public ValueTask> DeserializeAsync(Cancella return ValueTask.FromResult(new ConcurrentDictionary()); } - _mutex.WaitOne(); - try { - using var fileStream = File.OpenRead(_dbFilePath); - return DeserializeData(fileStream); - } finally { - _mutex.ReleaseMutex(); - } + using var fileStream = File.OpenRead(_dbFilePath); + return DeserializeData(fileStream); } /// public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { - _mutex.WaitOne(); + string tempFilePath = $"{_dbFilePath}.{Guid.NewGuid():N}.tmp"; try { - using (var fileStream = File.Create(_tempFilePath)) { + using (var fileStream = File.Create(tempFilePath)) { SerializeData(fileStream, data); } - File.Move(_tempFilePath, _dbFilePath, true); + + File.Move(tempFilePath, _dbFilePath, true); } finally { - _mutex.ReleaseMutex(); + if (File.Exists(tempFilePath)) { + File.Delete(tempFilePath); + } } return ValueTask.CompletedTask; @@ -77,8 +86,17 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance public void Dispose() { if (_disposed) return; - _mutex.Dispose(); + _ownershipHandle?.Dispose(); _disposed = true; GC.SuppressFinalize(this); } + + private static SafeFileHandle AcquireOwnershipHandle(string dbFilePath) { + string lockFilePath = $"{dbFilePath}.lock"; + try { + return File.OpenHandle(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } catch (IOException ex) { + throw new ArrowDbOwnershipException($"The database file '{dbFilePath}' is already owned by another process.", ex); + } + } } diff --git a/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs new file mode 100644 index 0000000..a0364b2 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs @@ -0,0 +1,29 @@ +namespace ArrowDbCore.Tests.Integrity; + +internal static class FileBackedTestHelpers { + public static void ReleaseOwnership(ArrowDb db) { + if (db.Serializer is IDisposable disposable) { + disposable.Dispose(); + } + } + + public static void DeleteArtifacts(string path) { + string? directory = Path.GetDirectoryName(path); + string fileName = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + File.Delete(tempFilePath); + } + } + + DeleteIfExists(path); + DeleteIfExists($"{path}.lock"); + } + + private static void DeleteIfExists(string path) { + if (File.Exists(path)) { + File.Delete(path); + } + } +} diff --git a/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs b/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs index 92e88b3..8b7b4e9 100644 --- a/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs +++ b/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs @@ -11,6 +11,8 @@ namespace ArrowDbCore.Tests.Integrity; public class LargeFile { private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func> factory) { const int itemCount = 500_000; + ArrowDb? db = null; + ArrowDb? db2 = null; var faker = new Faker(); faker.UseSeed(1337); @@ -22,7 +24,7 @@ private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func ArrowDb.CreateFromFileWithAes(path, aes)); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs b/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs index fc64161..2242dad 100644 --- a/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs +++ b/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs @@ -11,6 +11,7 @@ namespace ArrowDbCore.Tests.Integrity; public class OverwriteForceClear { private static async Task SerializeOverwritesExistingFile(string path, Func> factory) { const int itemCount = 1_000; + ArrowDb? db = null; var faker = new Faker(); faker.UseSeed(1337); @@ -22,7 +23,7 @@ private static async Task SerializeOverwritesExistingFile(string path, Func ArrowDb.CreateFromFileWithAes(path, aes)); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs b/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs index 68b83aa..ec8a118 100644 --- a/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs +++ b/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs @@ -12,6 +12,7 @@ public class ReadWriteCycles { private static async Task FileIO_Passes_ReadWriteCycles(string path, Func> factory) { const int iterations = 200; const int itemCount = 100; + ArrowDb? db = null; var faker = new Faker(); faker.UseSeed(1337); @@ -24,7 +25,7 @@ private static async Task FileIO_Passes_ReadWriteCycles(string path, Func ArrowDb.CreateFromFileWithAes(path, aes)); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Probes.FileOwnership/ArrowDbCore.Tests.Probes.FileOwnership.csproj b/tests/ArrowDbCore.Tests.Probes.FileOwnership/ArrowDbCore.Tests.Probes.FileOwnership.csproj new file mode 100644 index 0000000..0ff2e7e --- /dev/null +++ b/tests/ArrowDbCore.Tests.Probes.FileOwnership/ArrowDbCore.Tests.Probes.FileOwnership.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + ArrowDbCore.Tests.Probes.FileOwnership + + + + + + + diff --git a/tests/ArrowDbCore.Tests.Probes.FileOwnership/OwnershipProbeMarker.cs b/tests/ArrowDbCore.Tests.Probes.FileOwnership/OwnershipProbeMarker.cs new file mode 100644 index 0000000..4266374 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Probes.FileOwnership/OwnershipProbeMarker.cs @@ -0,0 +1,3 @@ +namespace ArrowDbCore.Tests.Probes.FileOwnership; + +public sealed class OwnershipProbeMarker; diff --git a/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs b/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs new file mode 100644 index 0000000..0d409ea --- /dev/null +++ b/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs @@ -0,0 +1,18 @@ +namespace ArrowDbCore.Tests.Probes.FileOwnership; + +internal static class Program { + private static async Task Main(string[] args) { + if (args.Length != 2 || !string.Equals(args[0], "hold", StringComparison.Ordinal)) { + Console.Error.WriteLine("Usage: hold "); + return 1; + } + + ArrowDb db = await ArrowDb.CreateFromFile(args[1]); + Console.WriteLine("READY"); + Console.Out.Flush(); + + string? _ = Console.ReadLine(); + GC.KeepAlive(db); + return 0; + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj b/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj index 24f4d08..f5e24c7 100644 --- a/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj +++ b/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs index 5af99f7..67dcd08 100644 --- a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs +++ b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs @@ -12,24 +12,38 @@ public async Task Concurrent_Writes_ShouldBe_ThreadSafe(bool useAes) { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); - var db = await CreateDb(path, useAes, aes); - var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; - var taskCount = 100; - var tasks = new Task[taskCount]; - - // Act - for (var i = 0; i < taskCount; i++) { - var key = $"key{i}"; - tasks[i] = Task.Run(() => db.Upsert(key, person, JContext.Default.Person)); + ArrowDb? db = null; + ArrowDb? db2 = null; + try { + db = await CreateDb(path, useAes, aes); + var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; + var taskCount = 100; + var tasks = new Task[taskCount]; + + // Act + for (var i = 0; i < taskCount; i++) { + var key = $"key{i}"; + tasks[i] = Task.Run(() => db.Upsert(key, person, JContext.Default.Person)); + } + + await Task.WhenAll(tasks); + await db.SerializeAsync(); + FileBackedTestHelpers.ReleaseOwnership(db); + + // Assert + db2 = await CreateDb(path, useAes, aes); + Assert.Equal(taskCount, db2.Count); + } finally { + if (db2 is not null) { + FileBackedTestHelpers.ReleaseOwnership(db2); + } + + if (db is not null) { + FileBackedTestHelpers.ReleaseOwnership(db); + } + + FileBackedTestHelpers.DeleteArtifacts(path); } - - await Task.WhenAll(tasks); - await db.SerializeAsync(); - - // Assert - var db2 = await CreateDb(path, useAes, aes); - Assert.Equal(taskCount, db2.Count); - File.Delete(path); } private async Task CreateDb(string path, bool useAes, Aes? aes = null) { @@ -39,4 +53,4 @@ private async Task CreateDb(string path, bool useAes, Aes? aes = null) return await ArrowDb.CreateFromFile(path); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs new file mode 100644 index 0000000..a0ee949 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs @@ -0,0 +1,29 @@ +namespace ArrowDbCore.Tests.Unit; + +internal static class FileBackedTestHelpers { + public static void ReleaseOwnership(ArrowDb db) { + if (db.Serializer is IDisposable disposable) { + disposable.Dispose(); + } + } + + public static void DeleteArtifacts(string path) { + string? directory = Path.GetDirectoryName(path); + string fileName = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + File.Delete(tempFilePath); + } + } + + DeleteIfExists(path); + DeleteIfExists($"{path}.lock"); + } + + private static void DeleteIfExists(string path) { + if (File.Exists(path)) { + File.Delete(path); + } + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs b/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs new file mode 100644 index 0000000..f760a2f --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using System.Security.Cryptography; + +using ArrowDbCore.Serializers; +using ArrowDbCore.Tests.Probes.FileOwnership; + +namespace ArrowDbCore.Tests.Unit; + +public sealed class FileOwnership { + [Fact] + public void FileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() { + string path = Path.GetTempFileName(); + FileSerializer? serializer = null; + + try { + serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + + ArrowDbOwnershipException exception = Assert.Throws(() => + new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); + + Assert.Contains(path, exception.Message, StringComparison.Ordinal); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public void AesFileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() { + string path = Path.GetTempFileName(); + using Aes aes = Aes.Create(); + AesFileSerializer? serializer = null; + + try { + serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + + ArrowDbOwnershipException exception = Assert.Throws(() => + new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); + + Assert.Contains(path, exception.Message, StringComparison.Ordinal); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task CreateFromFile_WhenOwnedByAnotherProcess_ThrowsUntilOwnerExits() { + string path = Path.GetTempFileName(); + Process? process = null; + + try { + process = StartOwnershipProbe(path); + await WaitForReady(process); + + await Assert.ThrowsAsync(() => ArrowDb.CreateFromFile(path).AsTask()); + + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + + ArrowDb db = await ArrowDb.CreateFromFile(path); + FileBackedTestHelpers.ReleaseOwnership(db); + } finally { + if (process is not null) { + process.Dispose(); + } + + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + private static Process StartOwnershipProbe(string path) { + string probeAssemblyPath = typeof(OwnershipProbeMarker).Assembly.Location; + var startInfo = new ProcessStartInfo("dotnet") { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add(probeAssemblyPath); + startInfo.ArgumentList.Add("hold"); + startInfo.ArgumentList.Add(path); + return Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ownership probe process."); + } + + private static async Task WaitForReady(Process process) { + string? line = await process.StandardOutput.ReadLineAsync(TestContext.Current.CancellationToken); + if (string.Equals(line, "READY", StringComparison.Ordinal)) { + return; + } + + string error = await process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + throw new InvalidOperationException($"Ownership probe did not become ready. Stdout: '{line ?? ""}'. Stderr: '{error}'."); + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/Serialization.cs b/tests/ArrowDbCore.Tests.Unit/Serialization.cs index a0af9c6..e9962b1 100644 --- a/tests/ArrowDbCore.Tests.Unit/Serialization.cs +++ b/tests/ArrowDbCore.Tests.Unit/Serialization.cs @@ -76,20 +76,28 @@ public async Task DeferredSerializationScope_Serialize_After_Dispose() { } private static async Task File_Serializes_And_Deserializes_As_Expected(string path, Func> factory) { + ArrowDb? db = null; + ArrowDb? db2 = null; try { - var db = await factory(); + db = await factory(); db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); Assert.Equal(1, db.Count); Assert.Equal(1, db.PendingChanges); await db.SerializeAsync(); - var db2 = await factory(); + FileBackedTestHelpers.ReleaseOwnership(db); + db2 = await factory(); Assert.Equal(db2.Source, db.Source); } finally { - // cleanup - if (File.Exists(path)) { - File.Delete(path); + if (db2 is not null) { + FileBackedTestHelpers.ReleaseOwnership(db2); } + + if (db is not null) { + FileBackedTestHelpers.ReleaseOwnership(db); + } + + FileBackedTestHelpers.DeleteArtifacts(path); } } @@ -109,8 +117,9 @@ public async Task AesFileSerializer_Serializes_And_Deserializes_As_Expected() { } private static async Task File_Serializes_And_Rollback_As_Expected(string path, Func> factory) { + ArrowDb? db = null; try { - var db = await factory(); + db = await factory(); db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); Assert.Equal(1, db.Count); @@ -130,10 +139,11 @@ private static async Task File_Serializes_And_Rollback_As_Expected(string path, Assert.True(db.TryGetValue("1", JContext.Default.Int32, out var value)); Assert.Equal(1, value); } finally { - // cleanup - if (File.Exists(path)) { - File.Delete(path); + if (db is not null) { + FileBackedTestHelpers.ReleaseOwnership(db); } + + FileBackedTestHelpers.DeleteArtifacts(path); } } @@ -151,4 +161,4 @@ public async Task AesFileSerializer_Serializes_And_Rollback_As_Expected() { aes.GenerateIV(); await File_Serializes_And_Rollback_As_Expected(path, () => ArrowDb.CreateFromFileWithAes(path, aes)); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Transactions.cs b/tests/ArrowDbCore.Tests.Unit/Transactions.cs index 055bfcc..b74bf45 100644 --- a/tests/ArrowDbCore.Tests.Unit/Transactions.cs +++ b/tests/ArrowDbCore.Tests.Unit/Transactions.cs @@ -12,31 +12,43 @@ public async Task NestedTransactionScope_SerializesOnce(bool useAes) { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); - var db = await CreateDb(path, useAes, aes); - var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; + ArrowDb? db = null; + ArrowDb? db3 = null; + try { + db = await CreateDb(path, useAes, aes); + var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; - // Act - await using (var scope1 = db.BeginTransaction()) { - db.Upsert("key1", person, JContext.Default.Person); - Assert.Equal(1, db.PendingChanges); + // Act + await using (var scope1 = db.BeginTransaction()) { + db.Upsert("key1", person, JContext.Default.Person); + Assert.Equal(1, db.PendingChanges); - await using (var scope2 = db.BeginTransaction()) { - db.Upsert("key2", person, JContext.Default.Person); + await using (var scope2 = db.BeginTransaction()) { + db.Upsert("key2", person, JContext.Default.Person); + Assert.Equal(2, db.PendingChanges); + + // Still shouldn't serialize + } Assert.Equal(2, db.PendingChanges); + Assert.Equal(0, new FileInfo(path).Length); + } - // Still shouldn't serialize + // Assert + FileBackedTestHelpers.ReleaseOwnership(db); + db3 = await CreateDb(path, useAes, aes); + Assert.Equal(2, db3.Count); + Assert.Equal(0, db3.PendingChanges); + } finally { + if (db3 is not null) { + FileBackedTestHelpers.ReleaseOwnership(db3); } - var db2 = await CreateDb(path, useAes, aes); - Assert.Equal(0, db2.Count); - Assert.Equal(2, db.PendingChanges); - } + if (db is not null) { + FileBackedTestHelpers.ReleaseOwnership(db); + } - // Assert - var db3 = await CreateDb(path, useAes, aes); - Assert.Equal(2, db3.Count); - Assert.Equal(0, db3.PendingChanges); - File.Delete(path); + FileBackedTestHelpers.DeleteArtifacts(path); + } } [Theory] @@ -46,27 +58,35 @@ public async Task RollbackAsync_RevertsChanges(bool useAes) { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); - var db = await CreateDb(path, useAes, aes); - var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; - - db.Upsert("key1", person, JContext.Default.Person); - await db.SerializeAsync(); - Assert.Equal(1, db.Count); - Assert.Equal(0, db.PendingChanges); - - // Act - db.Upsert("key2", person, JContext.Default.Person); - Assert.Equal(2, db.Count); - Assert.Equal(1, db.PendingChanges); - - await db.RollbackAsync(); - - // Assert - Assert.Equal(1, db.Count); - Assert.Equal(0, db.PendingChanges); - Assert.True(db.ContainsKey("key1")); - Assert.False(db.ContainsKey("key2")); - File.Delete(path); + ArrowDb? db = null; + try { + db = await CreateDb(path, useAes, aes); + var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; + + db.Upsert("key1", person, JContext.Default.Person); + await db.SerializeAsync(); + Assert.Equal(1, db.Count); + Assert.Equal(0, db.PendingChanges); + + // Act + db.Upsert("key2", person, JContext.Default.Person); + Assert.Equal(2, db.Count); + Assert.Equal(1, db.PendingChanges); + + await db.RollbackAsync(); + + // Assert + Assert.Equal(1, db.Count); + Assert.Equal(0, db.PendingChanges); + Assert.True(db.ContainsKey("key1")); + Assert.False(db.ContainsKey("key2")); + } finally { + if (db is not null) { + FileBackedTestHelpers.ReleaseOwnership(db); + } + + FileBackedTestHelpers.DeleteArtifacts(path); + } } private async Task CreateDb(string path, bool useAes, Aes? aes = null) { @@ -76,4 +96,4 @@ private async Task CreateDb(string path, bool useAes, Aes? aes = null) return await ArrowDb.CreateFromFile(path); } -} \ No newline at end of file +} From 02324129687e5372b8cec79246c405d3e6b3a61b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 8 Apr 2026 21:18:41 +0300 Subject: [PATCH 3/7] feat: make file based serializers async --- CHANGELOG.md | 2 + README.md | 6 +- src/ArrowDbCore/Readme.Nuget.md | 2 + .../Serializers/AesFileSerializer.cs | 16 +-- .../Serializers/BaseFileSerializer.cs | 76 ++++++++--- src/ArrowDbCore/Serializers/FileSerializer.cs | 12 +- tests/ArrowDbCore.Tests.Unit/Cancellation.cs | 27 ++++ .../FileSerializerAsync.cs | 125 ++++++++++++++++++ 8 files changed, 232 insertions(+), 34 deletions(-) create mode 100644 tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8c472..c3ed85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - This is a breaking release for callers implementing `IDbSerializer` or calling `GetOrAddAsync` with the old delegate shapes. - Built-in file-backed serializers now use single-owner writable semantics and fail fast with `ArrowDbOwnershipException` if another process already owns the same database path. - Removed the previous cross-process writable safety claim from the built-in file serializer path; the persisted file remains a snapshot of the owning process state. +- Built-in file-backed serializers now perform true async file and JSON I/O internally instead of synchronous work behind async signatures. +- This is also a breaking release for custom types inheriting `BaseFileSerializer`, which must implement the new async protected override surface. ## 1.6.0.0 diff --git a/README.md b/README.md index 8c489a9..1e5af32 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ var db = await ArrowDb.CreateFromFile("path.db"); // or with dependency injection builder.Services.AddSingleton(_ => ArrowDb.CreateFromFile("path.db").GetAwaiter().GetResult()); // the default DI container doesn't support async, so we hack it with GetAwaiter().GetResult() -// in the case of ArrowDb FileSerializer, this ValueTask is actually synchronous so this is fine +// this will block during startup while the serializer performs file I/O // in cases of different serializers, you can use Lazy or other workarounds ``` @@ -290,7 +290,7 @@ public interface IDbSerializer { } ``` -The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic writes and single-owner writable file semantics out of the box. +The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic writes, single-owner writable file semantics, and async file I/O out of the box. Being that they return a `ValueTask`, the implementations can be async. This means that you can even implement serializers to persist the db to a remote server, or cloud, or whatever else you want. @@ -358,7 +358,7 @@ void SomeMethod() { Using a transaction scope ensures that `SerializeAsync` is always called, even if an `Exception` is thrown. These scopes can be nested, and serialization will only occur when the outermost scope is disposed. If the `CancellationToken` passed to the outermost scope is canceled before disposal commits, the implicit serialize throws `OperationCanceledException` and the pending changes remain in memory until you retry `SerializeAsync` or call `RollbackAsync`. -`ArrowDbTransactionScope` also implements the regular `IDisposable` interface, meaning it can be used in a non-`async` method. However it internally calls the `DisposeAsync` method in a blocking manner, with the built in file-based serializers (`FileSerializer` and `AesFileSerializer`) it is completely safe as they naturally operate synchronously. However if you implemented a remote serializer or an `async` one, you should use the `Async Disposable` pattern accordingly. +`ArrowDbTransactionScope` also implements the regular `IDisposable` interface, meaning it can be used in a non-`async` method. However it internally calls the `DisposeAsync` method in a blocking manner. This works with the built-in file-based serializers, but it will block on file I/O during commit. In asynchronous code, prefer the `Async Disposable` pattern accordingly. ## Subscribing to Changes diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index adca7a3..694fb7f 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -30,3 +30,5 @@ ArrowDb 2.0 adds optional `CancellationToken` parameters to its async APIs, incl ## File-backed ownership The built-in file-backed serializers are single-owner writable. If another process already opened the same database path through ArrowDb's built-in file serializer path, the next writable open fails fast with `ArrowDbOwnershipException`. + +The built-in file-backed serializers also perform true async file I/O internally. Custom types inheriting from `BaseFileSerializer` should implement the async protected override surface. diff --git a/src/ArrowDbCore/Serializers/AesFileSerializer.cs b/src/ArrowDbCore/Serializers/AesFileSerializer.cs index 5f01109..2e54df8 100644 --- a/src/ArrowDbCore/Serializers/AesFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/AesFileSerializer.cs @@ -25,17 +25,17 @@ public AesFileSerializer(string path, Aes aes, JsonTypeInfo - protected override void SerializeData(Stream stream, ConcurrentDictionary data) { + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { using var encryptor = _aes.CreateEncryptor(); - using var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write); - JsonSerializer.Serialize(cryptoStream, data, _jsonTypeInfo); + await using var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write, leaveOpen: true); + await JsonSerializer.SerializeAsync(cryptoStream, data, _jsonTypeInfo, cancellationToken); } /// - protected override ValueTask> DeserializeData(Stream stream) { + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { using var decryptor = _aes.CreateDecryptor(); - using var cryptoStream = new CryptoStream(stream, decryptor, CryptoStreamMode.Read); - var res = JsonSerializer.Deserialize(cryptoStream, _jsonTypeInfo); - return ValueTask.FromResult(res ?? new ConcurrentDictionary()); + await using var cryptoStream = new CryptoStream(stream, decryptor, CryptoStreamMode.Read, leaveOpen: true); + ConcurrentDictionary? result = await JsonSerializer.DeserializeAsync(cryptoStream, _jsonTypeInfo, cancellationToken); + return result ?? new ConcurrentDictionary(); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs index 6f614bc..71a4f35 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Security.Cryptography; using Microsoft.Win32.SafeHandles; namespace ArrowDbCore.Serializers; @@ -8,8 +9,23 @@ namespace ArrowDbCore.Serializers; /// and single-owner writable semantics for the underlying database file. /// public abstract class BaseFileSerializer : IDbSerializer, IDisposable { + private static readonly FileStreamOptions ReadStreamOptions = new() { + Access = FileAccess.Read, + Mode = FileMode.Open, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan, + Share = FileShare.Read, + }; + + private static readonly FileStreamOptions WriteStreamOptions = new() { + Access = FileAccess.Write, + Mode = FileMode.Create, + Options = FileOptions.Asynchronous, + Share = FileShare.None, + }; + private readonly string _dbFilePath; private readonly SafeFileHandle? _ownershipHandle; + private string? _lastTempFilePath; private bool _disposed; /// @@ -41,31 +57,35 @@ protected BaseFileSerializer(string path) { } /// - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { - if (!File.Exists(_dbFilePath) || new FileInfo(_dbFilePath).Length == 0) { - return ValueTask.FromResult(new ConcurrentDictionary()); - } + public async ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + + try { + await using FileStream fileStream = new(_dbFilePath, ReadStreamOptions); + if (fileStream.Length == 0) { + return new ConcurrentDictionary(); + } - using var fileStream = File.OpenRead(_dbFilePath); - return DeserializeData(fileStream); + return await DeserializeDataAsync(fileStream, cancellationToken); + } catch (FileNotFoundException) { + return new ConcurrentDictionary(); + } } /// - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { - string tempFilePath = $"{_dbFilePath}.{Guid.NewGuid():N}.tmp"; + public async ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + string tempFilePath = GenerateTempFilePath(); try { - using (var fileStream = File.Create(tempFilePath)) { - SerializeData(fileStream, data); + await using (FileStream fileStream = new(tempFilePath, WriteStreamOptions)) { + await SerializeDataAsync(fileStream, data, cancellationToken); + await fileStream.FlushAsync(cancellationToken); } File.Move(tempFilePath, _dbFilePath, true); } finally { - if (File.Exists(tempFilePath)) { - File.Delete(tempFilePath); - } + TryDeleteFile(tempFilePath); } - - return ValueTask.CompletedTask; } /// @@ -73,14 +93,16 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance /// /// The stream to write the data to. /// The data to serialize. - protected abstract void SerializeData(Stream stream, ConcurrentDictionary data); + /// A cancellation token. + protected abstract ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken); /// /// When overridden in a derived class, deserializes the data from the provided stream. /// /// The stream to read the data from. + /// A cancellation token. /// The deserialized dictionary. - protected abstract ValueTask> DeserializeData(Stream stream); + protected abstract ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken); /// public void Dispose() { @@ -99,4 +121,24 @@ private static SafeFileHandle AcquireOwnershipHandle(string dbFilePath) { throw new ArrowDbOwnershipException($"The database file '{dbFilePath}' is already owned by another process.", ex); } } + + private string GenerateTempFilePath() { + string? lastTempFilePath = _lastTempFilePath; + string tempFilePath; + do { + tempFilePath = $"{_dbFilePath}.{RandomNumberGenerator.GetHexString(4)}.tmp"; + } while (string.Equals(tempFilePath, lastTempFilePath, StringComparison.Ordinal)); + _lastTempFilePath = tempFilePath; + return tempFilePath; + } + + private static void TryDeleteFile(string path) { + try { + if (File.Exists(path)) { + File.Delete(path); + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } + } } diff --git a/src/ArrowDbCore/Serializers/FileSerializer.cs b/src/ArrowDbCore/Serializers/FileSerializer.cs index e72db82..f073244 100644 --- a/src/ArrowDbCore/Serializers/FileSerializer.cs +++ b/src/ArrowDbCore/Serializers/FileSerializer.cs @@ -21,13 +21,13 @@ public FileSerializer(string path, JsonTypeInfo - protected override void SerializeData(Stream stream, ConcurrentDictionary data) { - JsonSerializer.Serialize(stream, data, _jsonTypeInfo); + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { + await JsonSerializer.SerializeAsync(stream, data, _jsonTypeInfo, cancellationToken); } /// - protected override ValueTask> DeserializeData(Stream stream) { - var result = JsonSerializer.Deserialize(stream, _jsonTypeInfo) ?? new(); - return ValueTask.FromResult(result); + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { + ConcurrentDictionary? result = await JsonSerializer.DeserializeAsync(stream, _jsonTypeInfo, cancellationToken); + return result ?? new ConcurrentDictionary(); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs index 0dcf5a6..84ebafb 100644 --- a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs +++ b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs @@ -21,6 +21,33 @@ public async Task CreateCustom_WhenCanceled_ThrowsOperationCanceledException() { await Assert.ThrowsAsync(() => ArrowDb.CreateCustom(new CancellationSerializer(), cancellationTokenSource.Token).AsTask()); } + [Fact] + public async Task CreateFromFile_WhenCanceled_ThrowsOperationCanceledException() { + string path = Path.GetTempFileName(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + try { + await Assert.ThrowsAsync(() => ArrowDb.CreateFromFile(path, cancellationTokenSource.Token).AsTask()); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task CreateFromFileWithAes_WhenCanceled_ThrowsOperationCanceledException() { + string path = Path.GetTempFileName(); + using var aes = System.Security.Cryptography.Aes.Create(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + try { + await Assert.ThrowsAsync(() => ArrowDb.CreateFromFileWithAes(path, aes, cancellationTokenSource.Token).AsTask()); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + [Fact] public async Task SerializeAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndDoesNotStartSecondSerialize() { var serializer = new CancellationSerializer(); diff --git a/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs new file mode 100644 index 0000000..330104f --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Text; + +using ArrowDbCore.Serializers; + +namespace ArrowDbCore.Tests.Unit; + +public sealed class FileSerializerAsync { + [Fact] + public async Task BaseFileSerializer_SerializeAsync_WhenCanceledBeforeCommit_LeavesOriginalFileAndDeletesTemp() { + string path = Path.GetTempFileName(); + AsyncTrackingFileSerializer? serializer = null; + + try { + await File.WriteAllTextAsync(path, "original", TestContext.Current.CancellationToken); + + serializer = new AsyncTrackingFileSerializer(path) { + BlockSerialize = true, + }; + + using var cancellationTokenSource = new CancellationTokenSource(); + Task serializeTask = serializer.SerializeAsync(new ConcurrentDictionary(), cancellationTokenSource.Token).AsTask(); + + await serializer.SerializeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + string? tempFilePath = serializer.SerializeStreamPaths.SingleOrDefault(); + + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => serializeTask); + Assert.Equal("original", await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + Assert.NotNull(tempFilePath); + Assert.False(File.Exists(tempFilePath)); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task BaseFileSerializer_DeserializeAsync_WhenCanceled_ThrowsAndLeavesFileUnchanged() { + string path = Path.GetTempFileName(); + AsyncTrackingFileSerializer? serializer = null; + + try { + await File.WriteAllTextAsync(path, "existing", TestContext.Current.CancellationToken); + + serializer = new AsyncTrackingFileSerializer(path) { + BlockDeserialize = true, + }; + + using var cancellationTokenSource = new CancellationTokenSource(); + Task deserializeTask = serializer.DeserializeAsync(cancellationTokenSource.Token).AsTask(); + + await serializer.DeserializeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => deserializeTask); + Assert.Equal("existing", await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task BaseFileSerializer_SerializeAsync_UsesUniqueTempFilePerWrite() { + string path = Path.GetTempFileName(); + AsyncTrackingFileSerializer? serializer = null; + + try { + serializer = new AsyncTrackingFileSerializer(path); + + await serializer.SerializeAsync(new ConcurrentDictionary()); + await serializer.SerializeAsync(new ConcurrentDictionary()); + + Assert.Equal(2, serializer.SerializeStreamPaths.Count); + Assert.NotEqual(serializer.SerializeStreamPaths[0], serializer.SerializeStreamPaths[1]); + Assert.All(serializer.SerializeStreamPaths, tempFilePath => { + Assert.StartsWith($"{path}.", tempFilePath, StringComparison.Ordinal); + Assert.EndsWith(".tmp", tempFilePath, StringComparison.Ordinal); + Assert.NotEqual(path, tempFilePath); + }); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + private sealed class AsyncTrackingFileSerializer : BaseFileSerializer { + public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + public readonly TaskCompletionSource DeserializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + public readonly List SerializeStreamPaths = []; + public bool BlockSerialize; + public bool BlockDeserialize; + + public AsyncTrackingFileSerializer(string path) + : base(path) { + } + + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { + if (stream is FileStream fileStream) { + SerializeStreamPaths.Add(fileStream.Name); + } + + SerializeStarted.TrySetResult(); + if (BlockSerialize) { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + await stream.WriteAsync(bytes, cancellationToken); + } + + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { + DeserializeStarted.TrySetResult(); + if (BlockDeserialize) { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, cancellationToken); + return new ConcurrentDictionary(); + } + } +} From 97b54e4dfe2fa9d036d4a16d25525d363d3bf668 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 11 Apr 2026 16:35:43 +0300 Subject: [PATCH 4/7] feat: implement DI package and better async abstractions --- .github/workflows/unit-tests-matrix.yaml | 9 +- .github/workflows/unit-tests-ubuntu.yaml | 9 +- ArrowDbCore.slnx | 2 + CHANGELOG.md | 5 +- README.md | 64 +++++- .../ArrowDbCore.DependencyInjection.csproj | 41 ++++ .../ArrowDbInitializationHostedService.cs | 22 ++ .../ArrowDbProvider.cs | 66 ++++++ .../ArrowDbServiceCollectionExtensions.cs | 18 ++ .../IArrowDbProvider.cs | 12 + .../Readme.Nuget.md | 66 ++++++ src/ArrowDbCore/ArrowDb.Factory.cs | 34 ++- src/ArrowDbCore/ArrowDb.Serialization.cs | 4 + src/ArrowDbCore/ArrowDb.cs | 20 +- src/ArrowDbCore/ArrowDbCore.csproj | 5 +- src/ArrowDbCore/IDbSerializer.cs | 7 +- src/ArrowDbCore/Readme.Nuget.md | 8 + .../Serializers/BaseFileSerializer.cs | 43 +++- .../Serializers/InMemorySerializer.cs | 26 ++- ...rowDbCore.DependencyInjection.Tests.csproj | 34 +++ .../DependencyInjection.cs | 216 ++++++++++++++++++ .../FileBackedTestHelpers.cs | 23 ++ .../xunit.runner.json | 7 + tests/ArrowDbCore.Tests.Unit/Cancellation.cs | 13 ++ tests/ArrowDbCore.Tests.Unit/Disposal.cs | 125 ++++++++++ tests/ArrowDbCore.Tests.Unit/RollbackRace.cs | 16 +- .../SerializationPendingChanges.cs | 13 ++ 27 files changed, 867 insertions(+), 41 deletions(-) create mode 100644 src/ArrowDbCore.DependencyInjection/ArrowDbCore.DependencyInjection.csproj create mode 100644 src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs create mode 100644 src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs create mode 100644 src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs create mode 100644 src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs create mode 100644 src/ArrowDbCore.DependencyInjection/Readme.Nuget.md create mode 100644 tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj create mode 100644 tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs create mode 100644 tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs create mode 100644 tests/ArrowDbCore.DependencyInjection.Tests/xunit.runner.json create mode 100644 tests/ArrowDbCore.Tests.Unit/Disposal.cs diff --git a/.github/workflows/unit-tests-matrix.yaml b/.github/workflows/unit-tests-matrix.yaml index 5d8b2df..d5a45cd 100644 --- a/.github/workflows/unit-tests-matrix.yaml +++ b/.github/workflows/unit-tests-matrix.yaml @@ -10,7 +10,12 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest, macos-latest] - project: [tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj, tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj] + project: + [ + tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj, + tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj, + tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj + ] uses: dusrdev/actions/.github/workflows/reusable-dotnet-test-mtp.yaml@main with: platform: ${{ matrix.platform }} @@ -43,4 +48,4 @@ jobs: run: dotnet restore ${{ env.PROJECT }} - name: Build As Release - run: dotnet build ${{ env.PROJECT }} --configuration Release \ No newline at end of file + run: dotnet build ${{ env.PROJECT }} --configuration Release diff --git a/.github/workflows/unit-tests-ubuntu.yaml b/.github/workflows/unit-tests-ubuntu.yaml index 48b1113..6249744 100644 --- a/.github/workflows/unit-tests-ubuntu.yaml +++ b/.github/workflows/unit-tests-ubuntu.yaml @@ -8,9 +8,14 @@ jobs: strategy: fail-fast: false matrix: - project: [tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj, tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj] + project: + [ + tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj, + tests/ArrowDbCore.Tests.Unit.Isolated/ArrowDbCore.Tests.Unit.Isolated.csproj, + tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj + ] uses: dusrdev/actions/.github/workflows/reusable-dotnet-test-mtp.yaml@main with: platform: ubuntu-latest dotnet-version: 10.0.x - test-project-path: ${{ matrix.project }} \ No newline at end of file + test-project-path: ${{ matrix.project }} diff --git a/ArrowDbCore.slnx b/ArrowDbCore.slnx index 8e9110e..3cf58f2 100644 --- a/ArrowDbCore.slnx +++ b/ArrowDbCore.slnx @@ -6,10 +6,12 @@ + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ed85f..e83a33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,16 @@ - Added optional `CancellationToken` parameters to ArrowDb async APIs, including factory initialization, `SerializeAsync`, `RollbackAsync`, `GetOrAddAsync`, and `BeginTransaction`. - Updated `GetOrAddAsync` factory delegates to receive the active `CancellationToken`. -- Updated the public `IDbSerializer` contract to receive an optional `CancellationToken` for serialization and deserialization. +- Added `ArrowDb.DependencyInjection` with `IArrowDbProvider`, the public generic `ArrowDbProvider`, and an optional hosted-service primer for eager startup initialization. +- `ArrowDb.CreateCustom(...)` now has an overload that accepts `disposeSerializer` so serializer ownership can be explicitly assigned; `ArrowDbProvider` defaults to external serializer ownership and can opt into owning disposal. +- Updated the public `IDbSerializer` contract to receive an optional `CancellationToken` for serialization and deserialization, track `IsDisposed`, and implement both `IDisposable` and `IAsyncDisposable`. - Transaction scopes can now carry a cancellation token into the outermost implicit serialize during disposal. - This is a breaking release for callers implementing `IDbSerializer` or calling `GetOrAddAsync` with the old delegate shapes. - Built-in file-backed serializers now use single-owner writable semantics and fail fast with `ArrowDbOwnershipException` if another process already owns the same database path. - Removed the previous cross-process writable safety claim from the built-in file serializer path; the persisted file remains a snapshot of the owning process state. - Built-in file-backed serializers now perform true async file and JSON I/O internally instead of synchronous work behind async signatures. - This is also a breaking release for custom types inheriting `BaseFileSerializer`, which must implement the new async protected override surface. +- Removed the previous sync-over-async dependency injection guidance from the docs; hosted DI is now documented through `ArrowDb.DependencyInjection` using explicit serializer registration plus `ArrowDbProvider`. ## 1.6.0.0 diff --git a/README.md b/README.md index 1e5af32..4125f43 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,13 @@ This policy does not affect value types (`structs`); their `default` values (e.g ## Getting Started -Installation is done via NuGet: `dotnet add package ArrowDbCore` +Installation is done via NuGet: `dotnet add package ArrowDb` Initializing the db is done via the factory methods, they return the instance as `ValueTask` and may or may not be asynchronous depending on the selected serializer implementation. The default serializer is `FileSerializer`, which serializes the db to a file on disk. These async APIs accept an optional `CancellationToken`. The following example demonstrates the basic usage, and more details on serializers will be discussed later. ```csharp // manual instance creation var db = await ArrowDb.CreateFromFile("path.db"); -// or with dependency injection -builder.Services.AddSingleton(_ => ArrowDb.CreateFromFile("path.db").GetAwaiter().GetResult()); -// the default DI container doesn't support async, so we hack it with GetAwaiter().GetResult() -// this will block during startup while the serializer performs file I/O -// in cases of different serializers, you can use Lazy or other workarounds ``` This will either create a new ArrowDb instance, or load an existing one from the specified path, if exists. @@ -86,6 +81,39 @@ await db.SerializeAsync(); await db.SerializeAsync(cancellationToken); ``` +## Hosted Dependency Injection + +For applications that use the default .NET host / dependency injection stack, use the companion package: + +```bash +dotnet add package ArrowDb.DependencyInjection +``` + +This package exposes `IArrowDbProvider` plus the public generic `ArrowDbProvider`. Register the serializer you want to use, then register the provider over that serializer type. + +```csharp +builder.Services.AddSingleton(new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); +builder.Services.AddSingleton>(); +builder.Services.AddArrowDbInitialization(); + +public sealed class MyService { + private readonly IArrowDbProvider _provider; + + public MyService(IArrowDbProvider provider) { + _provider = provider; + } + + public async Task CountAsync() { + ArrowDb db = await _provider.GetAsync(); + return db.Count; + } +} +``` + +`AddArrowDbInitialization()` is optional. Add it when you want eager host-startup priming for a singleton provider. Otherwise the provider stays lazy and initializes on first `GetAsync(...)`. + +`ArrowDbProvider` does not dispose the serializer by default. That is the right default when the serializer is registered separately in DI and the container owns it. If you want the provider to own the serializer lifetime instead, register it with a factory and pass `disposeSerializer: true`. + ## APIs For tracking some ArrowDb internals the following properties are exposed: @@ -234,9 +262,13 @@ var people = keys.Where(k => k.StartsWith(prefix)); ```csharp var db = await ArrowDb.CreateInMemory(); -// or with dependency injection -builder.Services.AddSingleton(() => ArrowDb.CreateInMemory().GetAwaiter().GetResult()); -// Since this isn’t persisted, you may also use it as a Transient or Scoped service (whatever fits your needs). +``` + +For hosted DI usage, register the in-memory variant through `ArrowDb.DependencyInjection`: + +```csharp +builder.Services.AddSingleton(new InMemorySerializer()); +builder.Services.AddSingleton>(); ``` A common code pattern for caching usually consists of some `GetOrAdd` method, that will check if a value exists by the key, and return it, otherwise it will accept a method used to generate the value, which will be used to add the value to the cache, then return it. @@ -266,9 +298,14 @@ As seen earlier, the default recommended serializer is `FileSerializer`, which s string path = "store.db"; using var aes = Aes.Create(); var db = await ArrowDb.CreateFromFileWithAes(path, aes); -// or with dependency injection +``` + +For hosted DI usage: + +```csharp builder.Services.AddSingleton(_ => Aes.Create()); -builder.Services.AddSingleton(services => ArrowDb.CreateFromFileWithAes(path, services.GetRequiredService()).GetAwaiter().GetResult()); +builder.Services.AddSingleton(services => new AesFileSerializer(path, services.GetRequiredService(), ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); +builder.Services.AddSingleton>(); ``` ## Serialization @@ -285,12 +322,15 @@ The `IDbSerializer` is exposed and can be used to implement custom serializers: ```csharp public interface IDbSerializer { + bool IsDisposed { get; } ValueTask> DeserializeAsync(CancellationToken cancellationToken = default); ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default); + void Dispose(); + ValueTask DisposeAsync(); } ``` -The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic writes, single-owner writable file semantics, and async file I/O out of the box. +The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. The disposal contract allows hosted integrations to release serializer-owned resources deterministically. For custom file-based serializers, it is recommended to inherit from `BaseFileSerializer` to get atomic writes, single-owner writable file semantics, and async file I/O out of the box. Being that they return a `ValueTask`, the implementations can be async. This means that you can even implement serializers to persist the db to a remote server, or cloud, or whatever else you want. diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbCore.DependencyInjection.csproj b/src/ArrowDbCore.DependencyInjection/ArrowDbCore.DependencyInjection.csproj new file mode 100644 index 0000000..b841645 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbCore.DependencyInjection.csproj @@ -0,0 +1,41 @@ + + + + net9.0;net10.0 + enable + enable + 2.0.0 + true + true + latest-recommended + True + ArrowDb.DependencyInjection + ArrowDb.DependencyInjection + Hosted dependency injection support for ArrowDb + ArrowDbCore.DependencyInjection + Readme.Nuget.md + LICENSE.txt + https://github.com/dusrdev/ArrowDb + https://github.com/dusrdev/ArrowDb + git + Database;DependencyInjection;Hosting;NoSql;KeyValuePair + David Shnayder + David Shnayder + true + + + + + + + + + + + + + + + + + diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs new file mode 100644 index 0000000..9e64d62 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ArrowDbCore.DependencyInjection; + +/// +/// Primes the registered during host startup. +/// +internal sealed class ArrowDbInitializationHostedService : IHostedService { + private readonly IServiceProvider _serviceProvider; + + public ArrowDbInitializationHostedService(IServiceProvider serviceProvider) { + _serviceProvider = serviceProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) { + IArrowDbProvider provider = _serviceProvider.GetRequiredService(); + await provider.GetAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs new file mode 100644 index 0000000..0eced2a --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs @@ -0,0 +1,66 @@ +namespace ArrowDbCore.DependencyInjection; + +/// +/// Provides lazy asynchronous access to an instance backed by a specific serializer. +/// +/// The serializer type used by the provider. +public sealed class ArrowDbProvider : IArrowDbProvider, IDisposable, IAsyncDisposable + where TSerializer : IDbSerializer { + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly TSerializer _serializer; + private readonly bool _disposeSerializer; + private ArrowDb? _arrowDb; + + /// + /// Initializes a new instance of the class. + /// + /// The serializer instance used by this provider. + /// Whether this provider owns the serializer lifetime. + public ArrowDbProvider(TSerializer serializer, bool disposeSerializer = false) { + _serializer = serializer; + _disposeSerializer = disposeSerializer; + } + + /// + public ValueTask GetAsync(CancellationToken cancellationToken = default) { + ArrowDb? arrowDb = _arrowDb; + if (arrowDb is not null) { + return ValueTask.FromResult(arrowDb); + } + + return GetAsyncCore(cancellationToken); + } + + /// + public void Dispose() { + if (_disposeSerializer) { + _serializer.Dispose(); + } + + _semaphore.Dispose(); + } + + /// + public async ValueTask DisposeAsync() { + if (_disposeSerializer) { + await _serializer.DisposeAsync(); + } + + _semaphore.Dispose(); + } + + private async ValueTask GetAsyncCore(CancellationToken cancellationToken) { + await _semaphore.WaitAsync(cancellationToken); + try { + ArrowDb? arrowDb = _arrowDb; + if (arrowDb is not null) { + return arrowDb; + } + + _arrowDb = await ArrowDb.CreateCustom(_serializer, _disposeSerializer, cancellationToken); + return _arrowDb; + } finally { + _semaphore.Release(); + } + } +} diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs new file mode 100644 index 0000000..c1f223b --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ArrowDbCore.DependencyInjection; + +/// +/// Provides optional service registration helpers for ArrowDb dependency injection integration. +/// +public static class ArrowDbServiceCollectionExtensions { + /// + /// Adds a hosted service that primes the registered during host startup. + /// + public static IServiceCollection AddArrowDbInitialization(this IServiceCollection services) { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); + return services; + } +} diff --git a/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs b/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs new file mode 100644 index 0000000..113cd94 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs @@ -0,0 +1,12 @@ +namespace ArrowDbCore.DependencyInjection; + +/// +/// Provides asynchronous access to a DI-managed instance. +/// +public interface IArrowDbProvider { + /// + /// Gets the initialized instance. + /// + /// A cancellation token that cancels only the wait operation. + ValueTask GetAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ArrowDbCore.DependencyInjection/Readme.Nuget.md b/src/ArrowDbCore.DependencyInjection/Readme.Nuget.md new file mode 100644 index 0000000..afa6b78 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/Readme.Nuget.md @@ -0,0 +1,66 @@ +# ArrowDb.DependencyInjection + +Dependency injection support for ArrowDb. + +This package provides `IArrowDbProvider`, the public generic `ArrowDbProvider`, and an optional hosted-service primer for eager startup initialization. + +## Install + +```bash +dotnet add package ArrowDb.DependencyInjection +``` + +## Register + +```csharp +builder.Services.AddSingleton(new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); +builder.Services.AddSingleton>(); +``` + +For AES-backed storage: + +```csharp +builder.Services.AddSingleton(_ => Aes.Create()); +builder.Services.AddSingleton(serviceProvider => + new AesFileSerializer( + path, + serviceProvider.GetRequiredService(), + ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); +builder.Services.AddSingleton>(); +``` + +If you want eager host-startup initialization for a singleton provider, also add: + +```csharp +builder.Services.AddArrowDbInitialization(); +``` + +## Consume + +```csharp +public sealed class MyService { + private readonly IArrowDbProvider _provider; + + public MyService(IArrowDbProvider provider) { + _provider = provider; + } + + public async Task CountAsync() { + ArrowDb db = await _provider.GetAsync(); + return db.Count; + } +} +``` + +`ArrowDbProvider` does not dispose the serializer by default. That fits the common DI case where the serializer is registered separately and the container owns it. + +If you want the provider to own the serializer lifetime instead, register it with a factory and pass `disposeSerializer: true`: + +```csharp +builder.Services.AddSingleton(_ => + new ArrowDbProvider( + new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray), + disposeSerializer: true)); +``` + +If you register the provider as a singleton and add `AddArrowDbInitialization()`, the host will prime it during startup. diff --git a/src/ArrowDbCore/ArrowDb.Factory.cs b/src/ArrowDbCore/ArrowDb.Factory.cs index ddb87e0..78de42c 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -17,8 +17,7 @@ public partial class ArrowDb { public static async ValueTask CreateFromFile(string path, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); - var data = await serializer.DeserializeAsync(cancellationToken); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// @@ -34,8 +33,7 @@ public static async ValueTask CreateFromFile(string path, CancellationT public static async ValueTask CreateFromFileWithAes(string path, Aes aes, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); - var data = await serializer.DeserializeAsync(cancellationToken); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// @@ -46,8 +44,7 @@ public static async ValueTask CreateFromFileWithAes(string path, Aes ae public static async ValueTask CreateInMemory(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var serializer = new InMemorySerializer(); - var data = await serializer.DeserializeAsync(cancellationToken); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// @@ -58,8 +55,19 @@ public static async ValueTask CreateInMemory(CancellationToken cancella /// A database instance public static async ValueTask CreateCustom(IDbSerializer serializer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var data = await serializer.DeserializeAsync(cancellationToken); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); + } + + /// + /// Initializes a database with a custom implementation + /// + /// A custom implementation + /// Whether the returned instance owns the serializer lifetime. + /// A cancellation token. + /// A database instance + public static async ValueTask CreateCustom(IDbSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + return await CreateFromSerializer(serializer, disposeSerializer, cancellationToken); } /// @@ -88,4 +96,14 @@ private static class TypeNameCache { /// public static readonly string TypeName = typeof(T).Name; } + + private static async ValueTask CreateFromSerializer(IDbSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken) { + try { + var data = await serializer.DeserializeAsync(cancellationToken); + return new ArrowDb(data, serializer, disposeSerializer); + } catch { + await serializer.DisposeAsync(); + throw; + } + } } diff --git a/src/ArrowDbCore/ArrowDb.Serialization.cs b/src/ArrowDbCore/ArrowDb.Serialization.cs index 44f1dc5..107c4ac 100644 --- a/src/ArrowDbCore/ArrowDb.Serialization.cs +++ b/src/ArrowDbCore/ArrowDb.Serialization.cs @@ -11,6 +11,8 @@ public partial class ArrowDb { /// /// A cancellation token. public async Task SerializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + if (Interlocked.Read(ref _pendingChanges) == 0) { return; } @@ -41,6 +43,8 @@ private void WaitIfSerializing() { /// /// A cancellation token. public async Task RollbackAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + await Semaphore.WaitAsync(cancellationToken); try { Interlocked.Increment(ref StateEpoch); diff --git a/src/ArrowDbCore/ArrowDb.cs b/src/ArrowDbCore/ArrowDb.cs index 0a26f6f..2a575cc 100644 --- a/src/ArrowDbCore/ArrowDb.cs +++ b/src/ArrowDbCore/ArrowDb.cs @@ -37,6 +37,11 @@ public sealed partial class ArrowDb { /// internal readonly IDbSerializer Serializer; + /// + /// Indicates whether this instance owns the serializer lifetime. + /// + internal readonly bool DisposeSerializer; + /// /// An event that is raised when any operation was performed that changes the database state, i.e, adding, updating, or removing a key, or clearing the database /// @@ -75,10 +80,12 @@ private void OnChangeInternal(ArrowDbChangeEventArgs args) { /// /// A pre-existing or empty dictionary /// A serializer implementation - private ArrowDb(ConcurrentDictionary source, IDbSerializer serializer) { + /// Whether this instance owns the serializer lifetime. + private ArrowDb(ConcurrentDictionary source, IDbSerializer serializer, bool disposeSerializer) { Source = source; Lookup = Source.GetAlternateLookup>(); Serializer = serializer; + DisposeSerializer = disposeSerializer; Interlocked.Increment(ref s_runningInstances); Semaphore = new SemaphoreSlim(1, 1); } @@ -87,8 +94,12 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// Finalizer (called when the instance is garbage collected) /// ~ArrowDb() { - Interlocked.Decrement(ref s_runningInstances); + if (DisposeSerializer) + Serializer.Dispose(); + Semaphore.Dispose(); + + Interlocked.Decrement(ref s_runningInstances); } /// @@ -99,5 +110,8 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// A cancellation token for the outermost implicit serialize operation. /// A new instance. - public ArrowDbTransactionScope BeginTransaction(CancellationToken cancellationToken = default) => new(this, cancellationToken); + public ArrowDbTransactionScope BeginTransaction(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + return new(this, cancellationToken); + } } diff --git a/src/ArrowDbCore/ArrowDbCore.csproj b/src/ArrowDbCore/ArrowDbCore.csproj index de566b4..5dfb7cc 100644 --- a/src/ArrowDbCore/ArrowDbCore.csproj +++ b/src/ArrowDbCore/ArrowDbCore.csproj @@ -43,7 +43,7 @@ - + @@ -61,6 +61,9 @@ <_Parameter1>ArrowDbCore.Tests.Integrity + + <_Parameter1>ArrowDbCore.DependencyInjection + diff --git a/src/ArrowDbCore/IDbSerializer.cs b/src/ArrowDbCore/IDbSerializer.cs index 2be2a44..b26bf59 100644 --- a/src/ArrowDbCore/IDbSerializer.cs +++ b/src/ArrowDbCore/IDbSerializer.cs @@ -5,7 +5,12 @@ namespace ArrowDbCore; /// /// The interface that defines a serializer for ArrowDb /// -public interface IDbSerializer { +public interface IDbSerializer : IDisposable, IAsyncDisposable { + /// + /// Indicates whether the serializer was disposed. + /// + bool IsDisposed { get; } + /// /// Deserializes the database from the underlying storage /// diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index 694fb7f..65e5219 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -27,6 +27,14 @@ Information on usage can be found in the [README](https://github.com/dusrdev/Arr ArrowDb 2.0 adds optional `CancellationToken` parameters to its async APIs, including database initialization, `SerializeAsync`, `RollbackAsync`, `GetOrAddAsync`, and the public `IDbSerializer` contract. Custom serializer implementations should update their method signatures accordingly. +## Hosted dependency injection + +Hosted DI integration is provided by the companion package `ArrowDb.DependencyInjection`. That package exposes `IArrowDbProvider`, the public generic `ArrowDbProvider`, and an optional hosted-service primer for eager startup initialization. + +## Serializer disposal + +`IDbSerializer` now tracks `IsDisposed` and implements both `IDisposable` and `IAsyncDisposable`. `ArrowDb.CreateCustom(...)` also has an overload that accepts `disposeSerializer` so serializer ownership can stay with either the database instance or the surrounding host/integration. + ## File-backed ownership The built-in file-backed serializers are single-owner writable. If another process already opened the same database path through ArrowDb's built-in file serializer path, the next writable open fails fast with `ArrowDbOwnershipException`. diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs index 71a4f35..d5978d8 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Security.Cryptography; + using Microsoft.Win32.SafeHandles; namespace ArrowDbCore.Serializers; @@ -8,7 +9,7 @@ namespace ArrowDbCore.Serializers; /// Provides a base implementation for file-based serializers that ensures atomic writes /// and single-owner writable semantics for the underlying database file. /// -public abstract class BaseFileSerializer : IDbSerializer, IDisposable { +public abstract class BaseFileSerializer : IDbSerializer { private static readonly FileStreamOptions ReadStreamOptions = new() { Access = FileAccess.Read, Mode = FileMode.Open, @@ -49,15 +50,12 @@ protected BaseFileSerializer(string path) { /// Finalizer to ensure the ownership handle is released when the serializer is garbage collected. /// ~BaseFileSerializer() { - try { - _ownershipHandle?.Dispose(); - } catch { - // Finalizers must never throw. - } + Dispose(disposing: false); } /// public async ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); try { @@ -74,6 +72,7 @@ public async ValueTask> DeserializeAsync(Ca /// public async ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); string tempFilePath = GenerateTempFilePath(); try { @@ -104,13 +103,41 @@ public async ValueTask SerializeAsync(ConcurrentDictionary data, /// The deserialized dictionary. protected abstract ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken); + /// + public bool IsDisposed => _disposed; + /// public void Dispose() { - if (_disposed) return; + if (_disposed) { + return; + } + + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + public ValueTask DisposeAsync() { + if (_disposed) { + return ValueTask.CompletedTask; + } + + Dispose(disposing: true); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + /// + /// Releases serializer resources. + /// + /// Indicates whether disposal was triggered explicitly. + protected virtual void Dispose(bool disposing) { + if (_disposed) { + return; + } _ownershipHandle?.Dispose(); _disposed = true; - GC.SuppressFinalize(this); } private static SafeFileHandle AcquireOwnershipHandle(string dbFilePath) { diff --git a/src/ArrowDbCore/Serializers/InMemorySerializer.cs b/src/ArrowDbCore/Serializers/InMemorySerializer.cs index 5382be1..dac3c27 100644 --- a/src/ArrowDbCore/Serializers/InMemorySerializer.cs +++ b/src/ArrowDbCore/Serializers/InMemorySerializer.cs @@ -7,13 +7,35 @@ namespace ArrowDbCore.Serializers; /// An in-memory serializer (does nothing) /// public sealed class InMemorySerializer : IDbSerializer { + private bool _disposed; + + /// + public bool IsDisposed => _disposed; + /// /// Returns an empty dictionary /// - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(new ConcurrentDictionary()); + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); + return ValueTask.FromResult(new ConcurrentDictionary()); + } /// /// Does nothing /// - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); + return ValueTask.CompletedTask; + } + + /// + public void Dispose() { + _disposed = true; + } + + /// + public ValueTask DisposeAsync() { + Dispose(); + return ValueTask.CompletedTask; + } } diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj b/tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj new file mode 100644 index 0000000..1b12068 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/ArrowDbCore.DependencyInjection.Tests.csproj @@ -0,0 +1,34 @@ + + + + enable + enable + Exe + ArrowDbCore.DependencyInjection.Tests + net10.0 + true + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs b/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs new file mode 100644 index 0000000..06ab451 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs @@ -0,0 +1,216 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +using ArrowDbCore.DependencyInjection; +using ArrowDbCore.Serializers; +using ArrowDbCore.Tests.Common; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ArrowDbCore.DependencyInjection.Tests; + +public sealed class DependencyInjection { + [Fact] + public async Task InitializationHostedService_PrimesRegisteredProvider_AndReturnsSameInstance() { + var serializer = new TrackingSerializer(); + using IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(serializer); + services.AddSingleton>(); + services.AddArrowDbInitialization(); + }) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, serializer.DeserializeCalls); + + IArrowDbProvider provider = host.Services.GetRequiredService(); + ArrowDb first = await provider.GetAsync(TestContext.Current.CancellationToken); + ArrowDb second = await provider.GetAsync(TestContext.Current.CancellationToken); + + Assert.Same(first, second); + } + + [Fact] + public async Task InitializationHostedService_WhenFileInitializationFails_HostStartupFails() { + string path = Path.GetTempFileName(); + + try { + await File.WriteAllTextAsync(path, "not json", TestContext.Current.CancellationToken); + + using IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); + services.AddSingleton>(); + services.AddArrowDbInitialization(); + }) + .Build(); + + await Assert.ThrowsAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task GenericProvider_WithInMemorySerializer_InitializesAndSupportsReads() { + using IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(new InMemorySerializer()); + services.AddSingleton>(); + services.AddArrowDbInitialization(); + }) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + + IArrowDbProvider provider = host.Services.GetRequiredService(); + ArrowDb db = await provider.GetAsync(TestContext.Current.CancellationToken); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + Assert.True(db.TryGetValue("seed", JContext.Default.Int32, out int value)); + Assert.Equal(1, value); + } + + [Fact] + public async Task GenericProvider_WithAesFileSerializer_InitializesSuccessfully() { + string path = Path.GetTempFileName(); + + try { + using IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(_ => Aes.Create()); + services.AddSingleton(serviceProvider => + new AesFileSerializer( + path, + serviceProvider.GetRequiredService(), + ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); + services.AddSingleton>(); + services.AddArrowDbInitialization(); + }) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + + IArrowDbProvider provider = host.Services.GetRequiredService(); + ArrowDb db = await provider.GetAsync(TestContext.Current.CancellationToken); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task HostShutdown_DisposesOwnedSerializer_AndRetainedArrowDbBlocksPersistence() { + string path = Path.GetTempFileName(); + ArrowDb? db = null; + FileSerializer? serializer = null; + + try { + IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + services.AddSingleton(_ => new ArrowDbProvider(serializer, disposeSerializer: true)); + services.AddArrowDbInitialization(); + }) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + + IArrowDbProvider provider = host.Services.GetRequiredService(); + db = await provider.GetAsync(TestContext.Current.CancellationToken); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + + await host.StopAsync(TestContext.Current.CancellationToken); + host.Dispose(); + + await Assert.ThrowsAsync(() => db.SerializeAsync(TestContext.Current.CancellationToken)); + Assert.True(serializer!.IsDisposed); + + using FileStream lockStream = new($"{path}.lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + Assert.NotNull(lockStream); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task GenericProvider_IsLazyWithoutInitializationHostedService() { + var serializer = new TrackingSerializer(); + using IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(serializer); + services.AddSingleton>(); + }) + .Build(); + + Assert.Equal(0, serializer.DeserializeCalls); + + IArrowDbProvider provider = host.Services.GetRequiredService(); + ArrowDb db = await provider.GetAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(db); + Assert.Equal(1, serializer.DeserializeCalls); + } + + [Fact] + public async Task ProviderOwnedSerializer_IsDisposedWhenHostStops() { + var serializer = new TrackingSerializer(); + IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => { + services.AddSingleton(_ => new ArrowDbProvider(serializer, disposeSerializer: true)); + services.AddArrowDbInitialization(); + }) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + await host.StopAsync(TestContext.Current.CancellationToken); + host.Dispose(); + + Assert.True(serializer.IsDisposed); + } + + [Fact] + public async Task Provider_DoesNotDisposeExternalSerializerByDefault() { + var serializer = new TrackingSerializer(); + var provider = new ArrowDbProvider(serializer); + + ArrowDb db = await provider.GetAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(db); + + await provider.DisposeAsync(); + + Assert.False(serializer.IsDisposed); + } + + private sealed class TrackingSerializer : IDbSerializer { + public int DeserializeCalls; + public bool IsDisposed { get; private set; } + + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + if (IsDisposed) { + throw new ObjectDisposedException(GetType().FullName); + } + + Interlocked.Increment(ref DeserializeCalls); + return ValueTask.FromResult(new ConcurrentDictionary()); + } + + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + if (IsDisposed) { + throw new ObjectDisposedException(GetType().FullName); + } + + return ValueTask.CompletedTask; + } + + public void Dispose() => IsDisposed = true; + + public ValueTask DisposeAsync() { + IsDisposed = true; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs b/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs new file mode 100644 index 0000000..9510691 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs @@ -0,0 +1,23 @@ +namespace ArrowDbCore.DependencyInjection.Tests; + +internal static class FileBackedTestHelpers { + public static void DeleteArtifacts(string path) { + string? directory = Path.GetDirectoryName(path); + string fileName = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + File.Delete(tempFilePath); + } + } + + DeleteIfExists(path); + DeleteIfExists($"{path}.lock"); + } + + private static void DeleteIfExists(string path) { + if (File.Exists(path)) { + File.Delete(path); + } + } +} diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/xunit.runner.json b/tests/ArrowDbCore.DependencyInjection.Tests/xunit.runner.json new file mode 100644 index 0000000..7d6ce78 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/xunit.runner.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true, + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "showLiveOutput": true +} diff --git a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs index 84ebafb..cb6880f 100644 --- a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs +++ b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs @@ -111,19 +111,32 @@ public async Task TransactionScope_WhenOuterTokenCanceled_ThrowsAndLeavesPending } internal sealed class CancellationSerializer : IDbSerializer { + private bool _disposed; + public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource AllowSerializeToFinish = new(TaskCreationOptions.RunContinuationsAsynchronously); public int DeserializeCalls; public int SerializeCalls; + public bool IsDisposed => _disposed; + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); Interlocked.Increment(ref DeserializeCalls); return ValueTask.FromResult(new ConcurrentDictionary()); } public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); Interlocked.Increment(ref SerializeCalls); SerializeStarted.TrySetResult(); return new ValueTask(AllowSerializeToFinish.Task); } + + public void Dispose() => _disposed = true; + + public ValueTask DisposeAsync() { + _disposed = true; + return ValueTask.CompletedTask; + } } diff --git a/tests/ArrowDbCore.Tests.Unit/Disposal.cs b/tests/ArrowDbCore.Tests.Unit/Disposal.cs new file mode 100644 index 0000000..32166f5 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Disposal.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text.Json; + +using ArrowDbCore.Serializers; +using ArrowDbCore.Tests.Common; + +namespace ArrowDbCore.Tests.Unit; + +public sealed class Disposal { + [Fact] + public async Task InMemorySerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() { + var serializer = new InMemorySerializer(); + + await serializer.DisposeAsync(); + + Assert.True(serializer.IsDisposed); + await Assert.ThrowsAsync(() => serializer.DeserializeAsync().AsTask()); + await Assert.ThrowsAsync(() => serializer.SerializeAsync(new ConcurrentDictionary()).AsTask()); + } + + [Fact] + public async Task FileSerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() { + string path = Path.GetTempFileName(); + FileSerializer? serializer = null; + + try { + serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + + await serializer.DisposeAsync(); + + Assert.True(serializer.IsDisposed); + await Assert.ThrowsAsync(() => serializer.DeserializeAsync().AsTask()); + await Assert.ThrowsAsync(() => serializer.SerializeAsync(new ConcurrentDictionary()).AsTask()); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public void AesFileSerializer_Dispose_DoesNotDisposeSuppliedAes() { + string path = Path.GetTempFileName(); + using Aes aes = Aes.Create(); + AesFileSerializer? serializer = null; + + try { + serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + + serializer.Dispose(); + + using ICryptoTransform encryptor = aes.CreateEncryptor(); + Assert.NotNull(encryptor); + } finally { + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + [Fact] + public async Task ArrowDb_WhenSerializerDisposed_PersistenceApisThrowAndInMemoryOperationsStillWork() { + ArrowDb db = await ArrowDb.CreateInMemory(); + Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); + + db.Serializer.Dispose(); + + Assert.Throws(() => db.BeginTransaction()); + await Assert.ThrowsAsync(() => db.SerializeAsync()); + await Assert.ThrowsAsync(() => db.RollbackAsync()); + + Assert.True(db.TryGetValue("seed", JContext.Default.Int32, out int value)); + Assert.Equal(1, value); + Assert.True(db.Upsert("seed", 2, JContext.Default.Int32)); + Assert.True(db.TryGetValue("seed", JContext.Default.Int32, out value)); + Assert.Equal(2, value); + } + + [Fact] + public async Task CreateCustom_WhenDeserializeFails_DisposesSerializer() { + var serializer = new FailingSerializer(); + + await Assert.ThrowsAsync(() => ArrowDb.CreateCustom(serializer).AsTask()); + + Assert.True(serializer.IsDisposed); + } + + [Fact] + public async Task CreateFromFile_WhenDeserializeFails_DisposesSerializerAndReleasesOwnership() { + string path = Path.GetTempFileName(); + FileSerializer? serializer = null; + + try { + await File.WriteAllTextAsync(path, "not json", TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => ArrowDb.CreateFromFile(path).AsTask()); + + serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + Assert.False(serializer.IsDisposed); + } finally { + serializer?.Dispose(); + FileBackedTestHelpers.DeleteArtifacts(path); + } + } + + private sealed class FailingSerializer : IDbSerializer { + public bool IsDisposed { get; private set; } + + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + throw new InvalidOperationException("boom"); + } + + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + if (IsDisposed) { + throw new ObjectDisposedException(GetType().FullName); + } + + return ValueTask.CompletedTask; + } + + public void Dispose() => IsDisposed = true; + + public ValueTask DisposeAsync() { + IsDisposed = true; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs index c833fb8..932c6d5 100644 --- a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs +++ b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs @@ -57,13 +57,17 @@ public async Task Upsert_WhenRacingWithRollback_EitherPersistsOrSignalsFailure() internal sealed class RollbackRaceBlockingSerializer : IDbSerializer { private int _blockNextDeserialize; + private bool _disposed; public readonly TaskCompletionSource RollbackDeserializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource AllowRollbackDeserializeToReturn = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool IsDisposed => _disposed; + public void BlockNextDeserialize() => Interlocked.Exchange(ref _blockNextDeserialize, 1); public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) { return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -77,7 +81,17 @@ private async Task> WaitAndReturnEmptyAsync return new ConcurrentDictionary(); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); + return ValueTask.CompletedTask; + } + + public void Dispose() => _disposed = true; + + public ValueTask DisposeAsync() { + _disposed = true; + return ValueTask.CompletedTask; + } } internal sealed class RollbackRaceHooks { diff --git a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs index 7b183b4..bc51ee6 100644 --- a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs +++ b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs @@ -50,17 +50,30 @@ public async Task SerializeAsync_WhenChangeHappensDuringSerialization_DoesNotCle } private sealed class BlockingSerializer : IDbSerializer { + private bool _disposed; + public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource AllowSerializeToFinish = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool IsDisposed => _disposed; + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.FromResult(new ConcurrentDictionary()); } public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); SerializeStarted.TrySetResult(); return new ValueTask(AllowSerializeToFinish.Task); } + + public void Dispose() => _disposed = true; + + public ValueTask DisposeAsync() { + _disposed = true; + return ValueTask.CompletedTask; + } } } From 03637d60211915f8015dc8a19a7086f31ac6ebf9 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 11 Apr 2026 18:12:01 +0300 Subject: [PATCH 5/7] chore: fix readme and make custom factory generic --- README.md | 8 ++++---- src/ArrowDbCore/ArrowDb.Factory.cs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4125f43..3c767bf 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,8 @@ And removal: ```csharp bool db.TryRemove(ReadOnlySpan key); // removes the entry with the specified key -bool db.TryClear(); // clears all entries; returns false if a concurrent RollbackAsync occurred -void db.Clear(); // obsolete: use TryClear() +bool db.TryClear(); // clears all entries; returns false if a concurrent RollbackAsync occurred +void db.Clear(); // obsolete: use TryClear() ``` ## Optimistic Concurrency Control @@ -286,7 +286,7 @@ If the value exists, the asynchronous factory method is not called, and the valu `GetOrAddAsync` is intentionally **not atomic**. Under concurrency, `valueFactory` may be invoked multiple times for the same key, and the final stored value is last-writer-wins (because the value is persisted via `Upsert`). If you need single-invocation semantics for the factory (e.g. side-effects/expensive work), guard the call site with a keyed lock. -Since `ArrowDb` was not made specifically to cache, it doesn't store time metadata for values, because of this, there will not be a method that accepts "cache expiration" or similar options in the foreseen future. Such scenarios will need to implemented client-side, best done with a pattern that splits read and write, by called `TryGetValue` which will also check the inner time reference, if false and out of date, will generate the value and use `Upsert`. +Since `ArrowDb` was not made specifically to cache, it doesn't store time metadata for values, because of this, there will not be a method that accepts "cache expiration" or similar options in the foreseen future. Such scenarios will need to implemented client-side, best done with a pattern that splits read and write, by calling `TryGetValue` which will also check the inner time reference, if false and out of date, will generate the value and use `Upsert`. Similarly to `Upsert` - `GetOrAddAsync` also has an overload that accepts `TArg` and and enables closure free execution for optimal performance. @@ -296,7 +296,7 @@ As seen earlier, the default recommended serializer is `FileSerializer`, which s ```csharp string path = "store.db"; -using var aes = Aes.Create(); +var aes = Aes.Create(); // aes lifetime should match the db instance as the serializer relies on it var db = await ArrowDb.CreateFromFileWithAes(path, aes); ``` diff --git a/src/ArrowDbCore/ArrowDb.Factory.cs b/src/ArrowDbCore/ArrowDb.Factory.cs index 78de42c..6ce7b29 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -53,7 +53,7 @@ public static async ValueTask CreateInMemory(CancellationToken cancella /// A custom implementation /// A cancellation token. /// A database instance - public static async ValueTask CreateCustom(IDbSerializer serializer, CancellationToken cancellationToken = default) { + public static async ValueTask CreateCustom(TSerializer serializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer { cancellationToken.ThrowIfCancellationRequested(); return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } @@ -65,7 +65,7 @@ public static async ValueTask CreateCustom(IDbSerializer serializer, Ca /// Whether the returned instance owns the serializer lifetime. /// A cancellation token. /// A database instance - public static async ValueTask CreateCustom(IDbSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) { + public static async ValueTask CreateCustom(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer { cancellationToken.ThrowIfCancellationRequested(); return await CreateFromSerializer(serializer, disposeSerializer, cancellationToken); } @@ -97,7 +97,7 @@ private static class TypeNameCache { public static readonly string TypeName = typeof(T).Name; } - private static async ValueTask CreateFromSerializer(IDbSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken) { + private static async ValueTask CreateFromSerializer(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken) where TSerializer : IDbSerializer { try { var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer, disposeSerializer); From e287b59875acb592119b65f73f26c3c4d3b3923b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 11 Apr 2026 18:13:17 +0300 Subject: [PATCH 6/7] style: formatting fix --- .editorconfig | 34 +++---- .../ArrowDbCore.Benchmarks.Common/JContext.cs | 2 +- .../ArrowDbCore.Benchmarks.Common/Person.cs | 14 ++- .../Program.cs | 2 +- .../RandomOperationsBenchmark.cs | 20 ++-- .../SerializationToFileBenchmark.cs | 23 +++-- .../VersionComparisonConfig.cs | 11 ++- benchmarks/ArrowDbCore.Benchmarks/Program.cs | 2 +- .../RandomOperationsBenchmark.cs | 20 ++-- .../SerializationToFileBenchmark.cs | 23 +++-- .../ArrowDbInitializationHostedService.cs | 9 +- .../ArrowDbProvider.cs | 37 +++++--- .../ArrowDbServiceCollectionExtensions.cs | 6 +- .../IArrowDbProvider.cs | 3 +- src/ArrowDbCore/ArrowDb.Factory.cs | 34 ++++--- src/ArrowDbCore/ArrowDb.GetOrAdd.cs | 15 ++- .../ArrowDb.IDictionaryAccessor.cs | 20 ++-- src/ArrowDbCore/ArrowDb.Read.cs | 11 ++- src/ArrowDbCore/ArrowDb.Remove.cs | 20 ++-- src/ArrowDbCore/ArrowDb.Serialization.cs | 32 +++++-- src/ArrowDbCore/ArrowDb.Upsert.cs | 41 +++++--- src/ArrowDbCore/ArrowDb.cs | 15 ++- src/ArrowDbCore/ArrowDbJsonContext.cs | 2 +- src/ArrowDbCore/ArrowDbOwnershipException.cs | 3 +- src/ArrowDbCore/ArrowDbTransactionScope.cs | 21 ++-- src/ArrowDbCore/ChangeEventArgs.cs | 11 ++- src/ArrowDbCore/Extensions.cs | 8 +- src/ArrowDbCore/IDbSerializer.cs | 3 +- .../Serializers/AesFileSerializer.cs | 12 ++- .../Serializers/BaseFileSerializer.cs | 95 +++++++++++++------ src/ArrowDbCore/Serializers/FileSerializer.cs | 12 ++- .../Serializers/InMemorySerializer.cs | 15 ++- .../DependencyInjection.cs | 87 +++++++++++------ .../FileBackedTestHelpers.cs | 18 ++-- tests/ArrowDbCore.Tests.Analyzers/Program.cs | 2 +- tests/ArrowDbCore.Tests.Common/JContext.cs | 2 +- tests/ArrowDbCore.Tests.Common/Person.cs | 5 +- .../FileBackedTestHelpers.cs | 24 +++-- .../ArrowDbCore.Tests.Integrity/LargeFile.cs | 28 ++++-- .../OverwriteForceClear.cs | 25 +++-- .../ReadWriteCycles.cs | 28 ++++-- .../Program.cs | 9 +- .../StaticVariables.cs | 11 ++- tests/ArrowDbCore.Tests.Unit/Cancellation.cs | 50 ++++++---- tests/ArrowDbCore.Tests.Unit/Concurrency.cs | 28 ++++-- tests/ArrowDbCore.Tests.Unit/Disposal.cs | 57 +++++++---- .../FileBackedTestHelpers.cs | 24 +++-- tests/ArrowDbCore.Tests.Unit/FileOwnership.cs | 48 +++++++--- .../FileSerializerAsync.cs | 63 ++++++++---- tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs | 42 +++++--- tests/ArrowDbCore.Tests.Unit/KeyGeneration.cs | 17 ++-- tests/ArrowDbCore.Tests.Unit/OnChange.cs | 23 +++-- tests/ArrowDbCore.Tests.Unit/Reads.cs | 17 ++-- tests/ArrowDbCore.Tests.Unit/Removes.cs | 20 ++-- tests/ArrowDbCore.Tests.Unit/RollbackRace.cs | 80 +++++++++++----- tests/ArrowDbCore.Tests.Unit/Serialization.cs | 68 ++++++++----- .../SerializationPendingChanges.cs | 77 ++++++++++----- .../TrackingVariables.cs | 29 ++++-- tests/ArrowDbCore.Tests.Unit/Transactions.cs | 44 ++++++--- tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs | 32 ++++--- tests/ArrowDbCore.Tests.Unit/Upserts.cs | 35 ++++--- 61 files changed, 1044 insertions(+), 525 deletions(-) diff --git a/.editorconfig b/.editorconfig index 30024f0..5a1f647 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,8 +18,8 @@ indent_size = 4 tab_width = 4 # New line preferences -end_of_line = crlf -insert_final_newline = false +end_of_line = lf +insert_final_newline = true #### .NET Coding Conventions #### [*.{cs,vb}] @@ -40,10 +40,10 @@ dotnet_style_predefined_type_for_locals_parameters_members = true:silent dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:silent dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:silent # Modifier preferences dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent @@ -85,6 +85,8 @@ dotnet_diagnostic.IDE0301.severity = none # simplify collection initialization dotnet_diagnostic.IDE0053.severity = none # expression body lambda dotnet_diagnostic.IDE0046.severity = none # simplify if(s) - conditional operator dotnet_diagnostic.IDE0305.severity = none # [, ...] instead of .ToArray() +dotnet_diagnostic.IDE0130.severity = none # Match namespace name +dotnet_diagnostic.IDE0045.severity = none # Use conditional expression # namespace declaration @@ -140,12 +142,12 @@ csharp_using_directive_placement = outside_namespace:silent #### C# Formatting Rules #### # New line preferences -csharp_new_line_before_catch = false -csharp_new_line_before_else = false -csharp_new_line_before_finally = false -csharp_new_line_before_members_in_anonymous_types = false -csharp_new_line_before_members_in_object_initializers = false -csharp_new_line_before_open_brace = none +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all csharp_new_line_between_query_expression_clauses = true # Indentation preferences @@ -249,9 +251,9 @@ dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase +dotnet_naming_rule.private_static_readonly_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_s_camelcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_s_camelcase.style = s_camelcase dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums @@ -307,7 +309,7 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.non_field_members.required_modifiers = -dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter dotnet_naming_symbols.type_parameters.applicable_accessibilities = * dotnet_naming_symbols.type_parameters.required_modifiers = @@ -373,4 +375,4 @@ dotnet_naming_style.camelcase.capitalization = camel_case dotnet_naming_style.s_camelcase.required_prefix = s_ dotnet_naming_style.s_camelcase.required_suffix = dotnet_naming_style.s_camelcase.word_separator = -dotnet_naming_style.s_camelcase.capitalization = camel_case \ No newline at end of file +dotnet_naming_style.s_camelcase.capitalization = camel_case diff --git a/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs b/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs index c268ace..0278b7a 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs @@ -4,4 +4,4 @@ namespace ArrowDbCore.Benchmarks.Common; [JsonSourceGenerationOptions(WriteIndented = false, NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true)] [JsonSerializable(typeof(Person))] -public partial class JContext : JsonSerializerContext { } \ No newline at end of file +public partial class JContext : JsonSerializerContext { } diff --git a/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs b/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs index 8c51aac..7480b94 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs @@ -2,15 +2,19 @@ namespace ArrowDbCore.Benchmarks.Common; -public sealed class Person { +public sealed class Person +{ public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Surname { get; set; } = string.Empty; public int Age { get; set; } - public static IEnumerable GeneratePeople(int count, Faker faker) { - for (var i = 0; i < count; i++) { - yield return new Person { + public static IEnumerable GeneratePeople(int count, Faker faker) + { + for (var i = 0; i < count; i++) + { + yield return new Person + { Id = i, Name = faker.Name.FirstName(), Surname = faker.Name.LastName(), @@ -18,4 +22,4 @@ public static IEnumerable GeneratePeople(int count, Faker faker) { }; } } -} \ No newline at end of file +} diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs index 485961f..ff9853c 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs @@ -2,4 +2,4 @@ using BenchmarkDotNet.Running; -BenchmarkRunner.Run(); \ No newline at end of file +BenchmarkRunner.Run(); diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs index 2e3b76f..97d63e6 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs @@ -13,7 +13,8 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; [MemoryDiagnoser(false)] [RankColumn] [Config(typeof(VersionComparisonConfig))] -public class RandomOperationsBenchmarks { +public class RandomOperationsBenchmarks +{ private Person[] _items = []; private ArrowDb _db = default!; @@ -21,8 +22,10 @@ public class RandomOperationsBenchmarks { public int Count { get; set; } [IterationSetup] - public void Setup() { - var faker = new Faker { + public void Setup() + { + var faker = new Faker + { Random = new Randomizer(1337) }; @@ -34,8 +37,10 @@ public void Setup() { } [Benchmark] - public void RandomOperations() { - Parallel.For(0, Count, i => { + public void RandomOperations() + { + Parallel.For(0, Count, i => + { // Pick a random operation: 0 = add/update, 1 = remove int operationType = Random.Shared.Next(0, 2); @@ -44,7 +49,8 @@ public void RandomOperations() { var key = item.Name; var jsonTypeInfo = JContext.Default.Person; - switch (operationType) { + switch (operationType) + { case 0: // Add/Update _db.Upsert(key, item, jsonTypeInfo); break; @@ -54,4 +60,4 @@ public void RandomOperations() { } }); } -} \ No newline at end of file +} diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs index 36e0504..e193a9e 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs @@ -13,15 +13,18 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; [MemoryDiagnoser(false)] [RankColumn] [Config(typeof(VersionComparisonConfig))] -public class SerializationToFileBenchmarks { +public class SerializationToFileBenchmarks +{ private ArrowDb _db = default!; [Params(100, 10_000, 1_000_000)] public int Size { get; set; } [IterationSetup] - public void Setup() { - var faker = new Faker { + public void Setup() + { + var faker = new Faker + { Random = new Randomizer(1337) }; @@ -29,7 +32,8 @@ public void Setup() { Span buffer = stackalloc char[64]; - foreach (var person in Person.GeneratePeople(Size, faker)) { + foreach (var person in Person.GeneratePeople(Size, faker)) + { _ = person.Id.TryFormat(buffer, out var written); var id = buffer.Slice(0, written); _db.Upsert(id, person, JContext.Default.Person); @@ -39,14 +43,17 @@ public void Setup() { } [IterationCleanup] - public void Cleanup() { - if (File.Exists("test.db")) { + public void Cleanup() + { + if (File.Exists("test.db")) + { File.Delete("test.db"); } } [Benchmark] - public async Task SerializeAsync() { + public async Task SerializeAsync() + { await _db.SerializeAsync(); } -} \ No newline at end of file +} diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs index 5a3dd84..145a1fb 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs @@ -13,10 +13,12 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; -public class VersionComparisonConfig : ManualConfig { +public class VersionComparisonConfig : ManualConfig +{ public const string PackageId = "ArrowDb"; - public VersionComparisonConfig() { + public VersionComparisonConfig() + { var (stable, latest) = GetLatestVersionsAsync(PackageId) .GetAwaiter() .GetResult(); @@ -33,7 +35,8 @@ public VersionComparisonConfig() { .WithId($"Latest-{latest.ToNormalizedString()}")); } - private static async Task<(NuGetVersion stable, NuGetVersion latest)> GetLatestVersionsAsync(string packageId) { + private static async Task<(NuGetVersion stable, NuGetVersion latest)> GetLatestVersionsAsync(string packageId) + { // Point at the official NuGet v3 API var source = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); var metaResource = await source.GetResourceAsync(); @@ -63,4 +66,4 @@ public VersionComparisonConfig() { return (stable, latest); } -} \ No newline at end of file +} diff --git a/benchmarks/ArrowDbCore.Benchmarks/Program.cs b/benchmarks/ArrowDbCore.Benchmarks/Program.cs index 57476fc..26152a7 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/Program.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/Program.cs @@ -2,4 +2,4 @@ using BenchmarkDotNet.Running; -BenchmarkRunner.Run(); \ No newline at end of file +BenchmarkRunner.Run(); diff --git a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs index c3e0bd4..b1fc582 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs @@ -13,7 +13,8 @@ namespace ArrowDbCore.Benchmarks; [MemoryDiagnoser(false)] [RankColumn] [MediumRunJob] -public class RandomOperationsBenchmarks { +public class RandomOperationsBenchmarks +{ private Person[] _items = []; private ArrowDb _db = default!; @@ -21,8 +22,10 @@ public class RandomOperationsBenchmarks { public int Count { get; set; } [IterationSetup] - public void Setup() { - var faker = new Faker { + public void Setup() + { + var faker = new Faker + { Random = new Randomizer(1337) }; @@ -34,8 +37,10 @@ public void Setup() { } [Benchmark] - public void RandomOperations() { - Parallel.For(0, Count, i => { + public void RandomOperations() + { + Parallel.For(0, Count, i => + { // Pick a random operation: 0 = add/update, 1 = remove int operationType = Random.Shared.Next(0, 2); @@ -44,7 +49,8 @@ public void RandomOperations() { var key = item.Name; var jsonTypeInfo = JContext.Default.Person; - switch (operationType) { + switch (operationType) + { case 0: // Add/Update _db.Upsert(key, item, jsonTypeInfo); break; @@ -54,4 +60,4 @@ public void RandomOperations() { } }); } -} \ No newline at end of file +} diff --git a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs index 6c13422..e6d1690 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs @@ -13,15 +13,18 @@ namespace ArrowDbCore.Benchmarks; [MemoryDiagnoser(false)] [RankColumn] [MediumRunJob] -public class SerializationToFileBenchmarks { +public class SerializationToFileBenchmarks +{ private ArrowDb _db = default!; [Params(100, 10_000, 1_000_000)] public int Size { get; set; } [IterationSetup] - public void Setup() { - var faker = new Faker { + public void Setup() + { + var faker = new Faker + { Random = new Randomizer(1337) }; @@ -29,7 +32,8 @@ public void Setup() { Span buffer = stackalloc char[64]; - foreach (var person in Person.GeneratePeople(Size, faker)) { + foreach (var person in Person.GeneratePeople(Size, faker)) + { _ = person.Id.TryFormat(buffer, out var written); var id = buffer.Slice(0, written); _db.Upsert(id, person, JContext.Default.Person); @@ -39,14 +43,17 @@ public void Setup() { } [IterationCleanup] - public void Cleanup() { - if (File.Exists("test.db")) { + public void Cleanup() + { + if (File.Exists("test.db")) + { File.Delete("test.db"); } } [Benchmark] - public async Task SerializeAsync() { + public async Task SerializeAsync() + { await _db.SerializeAsync(); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs index 9e64d62..e26c554 100644 --- a/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs @@ -6,14 +6,17 @@ namespace ArrowDbCore.DependencyInjection; /// /// Primes the registered during host startup. /// -internal sealed class ArrowDbInitializationHostedService : IHostedService { +internal sealed class ArrowDbInitializationHostedService : IHostedService +{ private readonly IServiceProvider _serviceProvider; - public ArrowDbInitializationHostedService(IServiceProvider serviceProvider) { + public ArrowDbInitializationHostedService(IServiceProvider serviceProvider) + { _serviceProvider = serviceProvider; } - public async Task StartAsync(CancellationToken cancellationToken) { + public async Task StartAsync(CancellationToken cancellationToken) + { IArrowDbProvider provider = _serviceProvider.GetRequiredService(); await provider.GetAsync(cancellationToken); } diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs index 0eced2a..0614116 100644 --- a/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs @@ -5,7 +5,8 @@ namespace ArrowDbCore.DependencyInjection; /// /// The serializer type used by the provider. public sealed class ArrowDbProvider : IArrowDbProvider, IDisposable, IAsyncDisposable - where TSerializer : IDbSerializer { + where TSerializer : IDbSerializer +{ private readonly SemaphoreSlim _semaphore = new(1, 1); private readonly TSerializer _serializer; private readonly bool _disposeSerializer; @@ -16,15 +17,18 @@ public sealed class ArrowDbProvider : IArrowDbProvider, IDisposable /// /// The serializer instance used by this provider. /// Whether this provider owns the serializer lifetime. - public ArrowDbProvider(TSerializer serializer, bool disposeSerializer = false) { + public ArrowDbProvider(TSerializer serializer, bool disposeSerializer = false) + { _serializer = serializer; _disposeSerializer = disposeSerializer; } /// - public ValueTask GetAsync(CancellationToken cancellationToken = default) { + public ValueTask GetAsync(CancellationToken cancellationToken = default) + { ArrowDb? arrowDb = _arrowDb; - if (arrowDb is not null) { + if (arrowDb is not null) + { return ValueTask.FromResult(arrowDb); } @@ -32,8 +36,10 @@ public ValueTask GetAsync(CancellationToken cancellationToken = default } /// - public void Dispose() { - if (_disposeSerializer) { + public void Dispose() + { + if (_disposeSerializer) + { _serializer.Dispose(); } @@ -41,25 +47,32 @@ public void Dispose() { } /// - public async ValueTask DisposeAsync() { - if (_disposeSerializer) { + public async ValueTask DisposeAsync() + { + if (_disposeSerializer) + { await _serializer.DisposeAsync(); } _semaphore.Dispose(); } - private async ValueTask GetAsyncCore(CancellationToken cancellationToken) { + private async ValueTask GetAsyncCore(CancellationToken cancellationToken) + { await _semaphore.WaitAsync(cancellationToken); - try { + try + { ArrowDb? arrowDb = _arrowDb; - if (arrowDb is not null) { + if (arrowDb is not null) + { return arrowDb; } _arrowDb = await ArrowDb.CreateCustom(_serializer, _disposeSerializer, cancellationToken); return _arrowDb; - } finally { + } + finally + { _semaphore.Release(); } } diff --git a/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs b/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs index c1f223b..894ada7 100644 --- a/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs @@ -6,11 +6,13 @@ namespace ArrowDbCore.DependencyInjection; /// /// Provides optional service registration helpers for ArrowDb dependency injection integration. /// -public static class ArrowDbServiceCollectionExtensions { +public static class ArrowDbServiceCollectionExtensions +{ /// /// Adds a hosted service that primes the registered during host startup. /// - public static IServiceCollection AddArrowDbInitialization(this IServiceCollection services) { + public static IServiceCollection AddArrowDbInitialization(this IServiceCollection services) + { ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); return services; diff --git a/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs b/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs index 113cd94..4c9413f 100644 --- a/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs +++ b/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs @@ -3,7 +3,8 @@ namespace ArrowDbCore.DependencyInjection; /// /// Provides asynchronous access to a DI-managed instance. /// -public interface IArrowDbProvider { +public interface IArrowDbProvider +{ /// /// Gets the initialized instance. /// diff --git a/src/ArrowDbCore/ArrowDb.Factory.cs b/src/ArrowDbCore/ArrowDb.Factory.cs index 6ce7b29..9c14fdb 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -4,7 +4,8 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Initializes a file/disk backed database at the specified path /// @@ -14,7 +15,8 @@ public partial class ArrowDb { /// /// Thrown when another process already owns the same file-backed database path. /// - public static async ValueTask CreateFromFile(string path, CancellationToken cancellationToken = default) { + public static async ValueTask CreateFromFile(string path, CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); var serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); @@ -30,7 +32,8 @@ public static async ValueTask CreateFromFile(string path, CancellationT /// /// Thrown when another process already owns the same file-backed database path. /// - public static async ValueTask CreateFromFileWithAes(string path, Aes aes, CancellationToken cancellationToken = default) { + public static async ValueTask CreateFromFileWithAes(string path, Aes aes, CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); var serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); @@ -41,7 +44,8 @@ public static async ValueTask CreateFromFileWithAes(string path, Aes ae /// /// A cancellation token. /// A database instance - public static async ValueTask CreateInMemory(CancellationToken cancellationToken = default) { + public static async ValueTask CreateInMemory(CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); var serializer = new InMemorySerializer(); return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); @@ -53,7 +57,8 @@ public static async ValueTask CreateInMemory(CancellationToken cancella /// A custom implementation /// A cancellation token. /// A database instance - public static async ValueTask CreateCustom(TSerializer serializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer { + public static async ValueTask CreateCustom(TSerializer serializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer + { cancellationToken.ThrowIfCancellationRequested(); return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } @@ -65,7 +70,8 @@ public static async ValueTask CreateCustom(TSerializer ser /// Whether the returned instance owns the serializer lifetime. /// A cancellation token. /// A database instance - public static async ValueTask CreateCustom(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer { + public static async ValueTask CreateCustom(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer + { cancellationToken.ThrowIfCancellationRequested(); return await CreateFromSerializer(serializer, disposeSerializer, cancellationToken); } @@ -79,7 +85,8 @@ public static async ValueTask CreateCustom(TSerializer ser /// /// A key that is formatted as ":" /// - public static ReadOnlySpan GenerateTypedKey(ReadOnlySpan specificKey, Span buffer) { + public static ReadOnlySpan GenerateTypedKey(ReadOnlySpan specificKey, Span buffer) + { var typeName = TypeNameCache.TypeName; var length = typeName.Length + 1 + specificKey.Length; // type:specificKey ArgumentOutOfRangeException.ThrowIfGreaterThan(length, buffer.Length); @@ -90,18 +97,23 @@ public static ReadOnlySpan GenerateTypedKey(ReadOnlySpan specific } // A static class that caches type names during runtime - private static class TypeNameCache { + private static class TypeNameCache + { /// /// The name of the type of T /// public static readonly string TypeName = typeof(T).Name; } - private static async ValueTask CreateFromSerializer(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken) where TSerializer : IDbSerializer { - try { + private static async ValueTask CreateFromSerializer(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken) where TSerializer : IDbSerializer + { + try + { var data = await serializer.DeserializeAsync(cancellationToken); return new ArrowDb(data, serializer, disposeSerializer); - } catch { + } + catch + { await serializer.DisposeAsync(); throw; } diff --git a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs index c0918fd..7ef0cd7 100644 --- a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs +++ b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs @@ -3,7 +3,8 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Tries to retrieve a value stored in the database under , if it doesn't exist, it uses the factory to create and add it, then returns it. /// @@ -23,8 +24,10 @@ public partial class ArrowDb { /// If you need single-invocation semantics for (e.g. the factory has side-effects or is expensive), guard the call site with a keyed lock. /// /// - public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, CancellationToken cancellationToken = default) { - if (Lookup.TryGetValue(key, out var source)) { + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, CancellationToken cancellationToken = default) + { + if (Lookup.TryGetValue(key, out var source)) + { return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; } @@ -56,8 +59,10 @@ public async ValueTask GetOrAddAsync(string key, JsonTypeInfo (e.g. the factory has side-effects or is expensive), guard the call site with a keyed lock. /// /// - public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument, CancellationToken cancellationToken = default) { - if (Lookup.TryGetValue(key, out var source)) { + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory, TArg factoryArgument, CancellationToken cancellationToken = default) + { + if (Lookup.TryGetValue(key, out var source)) + { return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; } diff --git a/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs b/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs index 9a9e776..574405c 100644 --- a/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs +++ b/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs @@ -1,11 +1,13 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Provides an interface that unifies methods of upserting values to ArrowDb /// /// - private interface IDictionaryAccessor where TKey : allows ref struct { + private interface IDictionaryAccessor where TKey : allows ref struct + { /// /// Assigns the to the in /// @@ -18,9 +20,11 @@ private interface IDictionaryAccessor where TKey : allows ref struct { /// /// Implements by using the source dictionary directly /// - private readonly ref struct StringAccessor : IDictionaryAccessor { + private readonly ref struct StringAccessor : IDictionaryAccessor + { /// - public void Upsert(ArrowDb instance, string key, byte[] value) { + public void Upsert(ArrowDb instance, string key, byte[] value) + { instance.Source[key] = value; } } @@ -28,10 +32,12 @@ public void Upsert(ArrowDb instance, string key, byte[] value) { /// /// Implements by using the lookup /// - private readonly ref struct ReadOnlySpanAccessor : IDictionaryAccessor> { + private readonly ref struct ReadOnlySpanAccessor : IDictionaryAccessor> + { /// - public void Upsert(ArrowDb instance, ReadOnlySpan key, byte[] value) { + public void Upsert(ArrowDb instance, ReadOnlySpan key, byte[] value) + { instance.Lookup[key] = value; } } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.Read.cs b/src/ArrowDbCore/ArrowDb.Read.cs index 61b0d5b..94401a2 100644 --- a/src/ArrowDbCore/ArrowDb.Read.cs +++ b/src/ArrowDbCore/ArrowDb.Read.cs @@ -3,7 +3,8 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Returns the number of entries in the database /// @@ -24,8 +25,10 @@ public partial class ArrowDb { /// The json type info for the value type /// The resulting value /// True if the value exists and was parsed successfully, false otherwise - public bool TryGetValue(ReadOnlySpan key, JsonTypeInfo jsonTypeInfo, out TValue value) { - if (!Lookup.TryGetValue(key, out byte[]? existingReference)) { + public bool TryGetValue(ReadOnlySpan key, JsonTypeInfo jsonTypeInfo, out TValue value) + { + if (!Lookup.TryGetValue(key, out byte[]? existingReference)) + { value = default!; return false; } @@ -37,4 +40,4 @@ public bool TryGetValue(ReadOnlySpan key, JsonTypeInfo jso /// Returns a read-only collection of the database keys /// public ICollection Keys => Source.Keys; -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.Remove.cs b/src/ArrowDbCore/ArrowDb.Remove.cs index 6b2cc62..23edc2a 100644 --- a/src/ArrowDbCore/ArrowDb.Remove.cs +++ b/src/ArrowDbCore/ArrowDb.Remove.cs @@ -1,16 +1,19 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Tries to remove the specified key from the database /// /// The key to remove /// True if the key was removed, false otherwise - public bool TryRemove(ReadOnlySpan key) { + public bool TryRemove(ReadOnlySpan key) + { var observedEpoch = Volatile.Read(ref StateEpoch); WaitIfSerializing(); // block if the database is currently serializing var removed = Lookup.TryRemove(key, out byte[]? _); - if (removed) { + if (removed) + { OnChangeInternal(ArrowDbChangeEventArgs.Remove); // trigger change event } return removed && Volatile.Read(ref StateEpoch) == observedEpoch; @@ -20,8 +23,10 @@ public bool TryRemove(ReadOnlySpan key) { /// Tries to clear the database /// /// True if the clear was completed without a concurrent rollback, false otherwise - public bool TryClear() { - if (Source.IsEmpty) { + public bool TryClear() + { + if (Source.IsEmpty) + { return true; } var observedEpoch = Volatile.Read(ref StateEpoch); @@ -35,7 +40,8 @@ public bool TryClear() { /// Clears the database /// [Obsolete("Use TryClear() instead. Clear() ignores rollback races and cannot signal if a concurrent RollbackAsync occurred.")] - public void Clear() { + public void Clear() + { _ = TryClear(); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.Serialization.cs b/src/ArrowDbCore/ArrowDb.Serialization.cs index 107c4ac..41134f6 100644 --- a/src/ArrowDbCore/ArrowDb.Serialization.cs +++ b/src/ArrowDbCore/ArrowDb.Serialization.cs @@ -2,7 +2,8 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Serializes the database /// @@ -10,19 +11,24 @@ public partial class ArrowDb { /// If there are no pending updates, this method does nothing, otherwise it serializes the database and resets the pending updates counter /// /// A cancellation token. - public async Task SerializeAsync(CancellationToken cancellationToken = default) { + public async Task SerializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); - if (Interlocked.Read(ref _pendingChanges) == 0) { + if (Interlocked.Read(ref _pendingChanges) == 0) + { return; } await Semaphore.WaitAsync(cancellationToken); - try { + try + { var observedPendingChanges = Interlocked.Read(ref _pendingChanges); await Serializer.SerializeAsync(Source, cancellationToken); Interlocked.CompareExchange(ref _pendingChanges, 0, observedPendingChanges); // reset pending changes only if unchanged - } finally { + } + finally + { Semaphore.Release(); } } @@ -31,8 +37,10 @@ public async Task SerializeAsync(CancellationToken cancellationToken = default) /// Waits for the semaphore if the database is currently serializing /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void WaitIfSerializing() { - if (Semaphore.CurrentCount == 0) { + private void WaitIfSerializing() + { + if (Semaphore.CurrentCount == 0) + { Semaphore.Wait(); Semaphore.Release(); } @@ -42,18 +50,22 @@ private void WaitIfSerializing() { /// Rolls the entire database to the last persisted state /// /// A cancellation token. - public async Task RollbackAsync(CancellationToken cancellationToken = default) { + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); await Semaphore.WaitAsync(cancellationToken); - try { + try + { Interlocked.Increment(ref StateEpoch); var prevState = await Serializer.DeserializeAsync(cancellationToken); Source.Clear(); Interlocked.Exchange(ref Source, prevState); Lookup = Source.GetAlternateLookup>(); Interlocked.Exchange(ref _pendingChanges, 0); - } finally { + } + finally + { Semaphore.Release(); } } diff --git a/src/ArrowDbCore/ArrowDb.Upsert.cs b/src/ArrowDbCore/ArrowDb.Upsert.cs index 83cbf78..3ac9f4c 100644 --- a/src/ArrowDbCore/ArrowDb.Upsert.cs +++ b/src/ArrowDbCore/ArrowDb.Upsert.cs @@ -4,7 +4,8 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Upsert the specified key with the specified value into the database. /// @@ -13,7 +14,8 @@ public partial class ArrowDb { /// The value to upsert. This cannot be null. /// The json type info for the value type /// True if the value was upserted, false if the provided value was null. - public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo) { + public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo) + { return UpsertCore(key, value, jsonTypeInfo, default); } @@ -28,15 +30,18 @@ public bool Upsert(string key, TValue value, JsonTypeInfo jsonTy /// /// This method overload which uses ReadOnlySpan{char} will not allocate a new string for the key if it already exists, instead it will directly replace the value. /// - public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo) { + public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo) + { return UpsertCore, TValue, ReadOnlySpanAccessor>(key, value, jsonTypeInfo, default); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool UpsertCore(TKey key, TValue value, JsonTypeInfo jsonTypeInfo, TAccessor accessor) where TKey : allows ref struct - where TAccessor : IDictionaryAccessor, allows ref struct { - if (value is null) { + where TAccessor : IDictionaryAccessor, allows ref struct + { + if (value is null) + { return false; } var observedEpoch = Volatile.Read(ref StateEpoch); @@ -66,9 +71,11 @@ private bool UpsertCore(TKey key, TValue value, JsonTyp /// 1. A value for the specified key exists and successfully deserialized to /// 2. on the reference value returns false /// - public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition) { + public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition) + { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && - !updateCondition(existingReference)) { + !updateCondition(existingReference)) + { return false; } return Upsert(key, value, jsonTypeInfo); @@ -95,9 +102,11 @@ public bool Upsert(string key, TValue value, JsonTypeInfo jsonTy /// 1. A value for the specified key exists and successfully deserialized to /// 2. on the reference value returns false /// - public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition, TArg updateConditionArgument) { + public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition, TArg updateConditionArgument) + { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && - !updateCondition(existingReference, updateConditionArgument)) { + !updateCondition(existingReference, updateConditionArgument)) + { return false; } return Upsert(key, value, jsonTypeInfo); @@ -125,9 +134,11 @@ public bool Upsert(string key, TValue value, JsonTypeInfo /// This method overload which uses ReadOnlySpan{char} will not allocate a new string for the key if it already exists, instead it will directly replace the value /// /// - public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition) { + public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition) + { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && - !updateCondition(existingReference)) { + !updateCondition(existingReference)) + { return false; } return Upsert(key, value, jsonTypeInfo); @@ -157,11 +168,13 @@ public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo /// - public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition, TArg updateConditionArgument) { + public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition, TArg updateConditionArgument) + { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && - !updateCondition(existingReference, updateConditionArgument)) { + !updateCondition(existingReference, updateConditionArgument)) + { return false; } return Upsert(key, value, jsonTypeInfo); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/ArrowDb.cs b/src/ArrowDbCore/ArrowDb.cs index 2a575cc..9a34ff5 100644 --- a/src/ArrowDbCore/ArrowDb.cs +++ b/src/ArrowDbCore/ArrowDb.cs @@ -6,7 +6,8 @@ namespace ArrowDbCore; /// ArrowDb /// /// Initialize via the factory methods -public sealed partial class ArrowDb { +public sealed partial class ArrowDb +{ /// /// Returns the number of active instances /// @@ -50,7 +51,8 @@ public sealed partial class ArrowDb { /// /// Raises the event /// - private void OnChangeInternal(ArrowDbChangeEventArgs args) { + private void OnChangeInternal(ArrowDbChangeEventArgs args) + { Interlocked.Increment(ref _pendingChanges); OnChange?.Invoke(this, args); } @@ -81,7 +83,8 @@ private void OnChangeInternal(ArrowDbChangeEventArgs args) { /// A pre-existing or empty dictionary /// A serializer implementation /// Whether this instance owns the serializer lifetime. - private ArrowDb(ConcurrentDictionary source, IDbSerializer serializer, bool disposeSerializer) { + private ArrowDb(ConcurrentDictionary source, IDbSerializer serializer, bool disposeSerializer) + { Source = source; Lookup = Source.GetAlternateLookup>(); Serializer = serializer; @@ -93,7 +96,8 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// Finalizer (called when the instance is garbage collected) /// - ~ArrowDb() { + ~ArrowDb() + { if (DisposeSerializer) Serializer.Dispose(); @@ -110,7 +114,8 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// A cancellation token for the outermost implicit serialize operation. /// A new instance. - public ArrowDbTransactionScope BeginTransaction(CancellationToken cancellationToken = default) { + public ArrowDbTransactionScope BeginTransaction(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); return new(this, cancellationToken); } diff --git a/src/ArrowDbCore/ArrowDbJsonContext.cs b/src/ArrowDbCore/ArrowDbJsonContext.cs index 4c01e29..f993317 100644 --- a/src/ArrowDbCore/ArrowDbJsonContext.cs +++ b/src/ArrowDbCore/ArrowDbJsonContext.cs @@ -8,4 +8,4 @@ namespace ArrowDbCore; /// [JsonSourceGenerationOptions(WriteIndented = false, AllowTrailingCommas = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true)] [JsonSerializable(typeof(ConcurrentDictionary))] -public partial class ArrowDbJsonContext : JsonSerializerContext; \ No newline at end of file +public partial class ArrowDbJsonContext : JsonSerializerContext; diff --git a/src/ArrowDbCore/ArrowDbOwnershipException.cs b/src/ArrowDbCore/ArrowDbOwnershipException.cs index b2e4492..6d8dcab 100644 --- a/src/ArrowDbCore/ArrowDbOwnershipException.cs +++ b/src/ArrowDbCore/ArrowDbOwnershipException.cs @@ -4,7 +4,8 @@ namespace ArrowDbCore; /// Thrown when a file-backed cannot acquire exclusive ownership /// of the underlying database file. /// -public sealed class ArrowDbOwnershipException : IOException { +public sealed class ArrowDbOwnershipException : IOException +{ /// /// Initializes a new instance of the class. /// diff --git a/src/ArrowDbCore/ArrowDbTransactionScope.cs b/src/ArrowDbCore/ArrowDbTransactionScope.cs index b387cc1..8ff344d 100644 --- a/src/ArrowDbCore/ArrowDbTransactionScope.cs +++ b/src/ArrowDbCore/ArrowDbTransactionScope.cs @@ -4,7 +4,8 @@ namespace ArrowDbCore; /// /// Provides a scope that can be used to defer serialization until the scope is disposed /// -public sealed class ArrowDbTransactionScope : IAsyncDisposable, IDisposable { +public sealed class ArrowDbTransactionScope : IAsyncDisposable, IDisposable +{ private readonly ArrowDb _database; private readonly CancellationToken _cancellationToken; private bool _disposed; @@ -14,7 +15,8 @@ public sealed class ArrowDbTransactionScope : IAsyncDisposable, IDisposable { /// /// The database instance /// A cancellation token for the outermost implicit serialize operation. - internal ArrowDbTransactionScope(ArrowDb database, CancellationToken cancellationToken) { + internal ArrowDbTransactionScope(ArrowDb database, CancellationToken cancellationToken) + { _database = database; _cancellationToken = cancellationToken; Interlocked.Increment(ref _database.TransactionDepth); @@ -23,13 +25,16 @@ internal ArrowDbTransactionScope(ArrowDb database, CancellationToken cancellatio /// /// Disposes the scope and calls /// - public async ValueTask DisposeAsync() { - if (_disposed) { + public async ValueTask DisposeAsync() + { + if (_disposed) + { return; } _disposed = true; - if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) { + if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) + { await _database.SerializeAsync(_cancellationToken).ConfigureAwait(false); } } @@ -37,11 +42,13 @@ public async ValueTask DisposeAsync() { /// /// Disposes the scope and calls in a blocking operation /// - public void Dispose() { + public void Dispose() + { #pragma warning disable CA2012 var task = DisposeAsync(); #pragma warning restore CA2012 - if (task.IsCompletedSuccessfully) { + if (task.IsCompletedSuccessfully) + { return; } task.GetAwaiter().GetResult(); diff --git a/src/ArrowDbCore/ChangeEventArgs.cs b/src/ArrowDbCore/ChangeEventArgs.cs index a0a577f..618ddf2 100644 --- a/src/ArrowDbCore/ChangeEventArgs.cs +++ b/src/ArrowDbCore/ChangeEventArgs.cs @@ -3,7 +3,8 @@ namespace ArrowDbCore; /// /// An argument that is passed to the event /// -public sealed class ArrowDbChangeEventArgs : EventArgs { +public sealed class ArrowDbChangeEventArgs : EventArgs +{ /// /// A change event that represents an upsert /// @@ -22,7 +23,8 @@ public sealed class ArrowDbChangeEventArgs : EventArgs { /// public ArrowDbChangeType ChangeType { get; init; } - private ArrowDbChangeEventArgs(ArrowDbChangeType changeType) { + private ArrowDbChangeEventArgs(ArrowDbChangeType changeType) + { ChangeType = changeType; } } @@ -30,7 +32,8 @@ private ArrowDbChangeEventArgs(ArrowDbChangeType changeType) { /// /// The type of change that occurred in an instance /// -public enum ArrowDbChangeType { +public enum ArrowDbChangeType +{ /// /// An upsert occurred /// @@ -43,4 +46,4 @@ public enum ArrowDbChangeType { /// The db instance was cleared (all entries were removed) /// Clear -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/Extensions.cs b/src/ArrowDbCore/Extensions.cs index 47d5bc8..f1f54c1 100644 --- a/src/ArrowDbCore/Extensions.cs +++ b/src/ArrowDbCore/Extensions.cs @@ -7,12 +7,14 @@ namespace ArrowDbCore; /// /// Extension methods /// -internal static class Extensions { +internal static class Extensions +{ /// /// Converts an input into a Hex Hash in an efficient manner /// /// - internal static string ToSHA256Hash(string input) { + internal static string ToSHA256Hash(string input) + { var inputLength = Encoding.UTF8.GetMaxByteCount(input.Length); using var memOwner = MemoryPool.Shared.Rent(inputLength); Span span = memOwner.Memory.Span; @@ -21,4 +23,4 @@ internal static string ToSHA256Hash(string input) { SHA256.HashData(span.Slice(0, written), hashBuffer); return Convert.ToHexString(hashBuffer); } -} \ No newline at end of file +} diff --git a/src/ArrowDbCore/IDbSerializer.cs b/src/ArrowDbCore/IDbSerializer.cs index b26bf59..8d59178 100644 --- a/src/ArrowDbCore/IDbSerializer.cs +++ b/src/ArrowDbCore/IDbSerializer.cs @@ -5,7 +5,8 @@ namespace ArrowDbCore; /// /// The interface that defines a serializer for ArrowDb /// -public interface IDbSerializer : IDisposable, IAsyncDisposable { +public interface IDbSerializer : IDisposable, IAsyncDisposable +{ /// /// Indicates whether the serializer was disposed. /// diff --git a/src/ArrowDbCore/Serializers/AesFileSerializer.cs b/src/ArrowDbCore/Serializers/AesFileSerializer.cs index 2e54df8..1161e6b 100644 --- a/src/ArrowDbCore/Serializers/AesFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/AesFileSerializer.cs @@ -8,7 +8,8 @@ namespace ArrowDbCore.Serializers; /// /// An managed file/disk backed serializer. /// -public sealed class AesFileSerializer : BaseFileSerializer { +public sealed class AesFileSerializer : BaseFileSerializer +{ private readonly Aes _aes; private readonly JsonTypeInfo> _jsonTypeInfo; @@ -19,20 +20,23 @@ public sealed class AesFileSerializer : BaseFileSerializer { /// The instance to use. /// The json type info for the dictionary. public AesFileSerializer(string path, Aes aes, JsonTypeInfo> jsonTypeInfo) - : base(path) { + : base(path) + { _aes = aes; _jsonTypeInfo = jsonTypeInfo; } /// - protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) + { using var encryptor = _aes.CreateEncryptor(); await using var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write, leaveOpen: true); await JsonSerializer.SerializeAsync(cryptoStream, data, _jsonTypeInfo, cancellationToken); } /// - protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) + { using var decryptor = _aes.CreateDecryptor(); await using var cryptoStream = new CryptoStream(stream, decryptor, CryptoStreamMode.Read, leaveOpen: true); ConcurrentDictionary? result = await JsonSerializer.DeserializeAsync(cryptoStream, _jsonTypeInfo, cancellationToken); diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs index d5978d8..ffe0b7d 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -9,15 +9,18 @@ namespace ArrowDbCore.Serializers; /// Provides a base implementation for file-based serializers that ensures atomic writes /// and single-owner writable semantics for the underlying database file. /// -public abstract class BaseFileSerializer : IDbSerializer { - private static readonly FileStreamOptions ReadStreamOptions = new() { +public abstract class BaseFileSerializer : IDbSerializer +{ + private static readonly FileStreamOptions ReadStreamOptions = new() + { Access = FileAccess.Read, Mode = FileMode.Open, Options = FileOptions.Asynchronous | FileOptions.SequentialScan, Share = FileShare.Read, }; - private static readonly FileStreamOptions WriteStreamOptions = new() { + private static readonly FileStreamOptions WriteStreamOptions = new() + { Access = FileAccess.Write, Mode = FileMode.Create, Options = FileOptions.Asynchronous, @@ -36,10 +39,12 @@ public abstract class BaseFileSerializer : IDbSerializer { /// /// Thrown when another ArrowDb process already owns the same file-backed database path. /// - protected BaseFileSerializer(string path) { + protected BaseFileSerializer(string path) + { _dbFilePath = Path.GetFullPath(path); string? directory = Path.GetDirectoryName(_dbFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { throw new DirectoryNotFoundException($"The directory '{directory}' does not exist."); } @@ -49,40 +54,51 @@ protected BaseFileSerializer(string path) { /// /// Finalizer to ensure the ownership handle is released when the serializer is garbage collected. /// - ~BaseFileSerializer() { + ~BaseFileSerializer() + { Dispose(disposing: false); } /// - public async ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public async ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); - try { + try + { await using FileStream fileStream = new(_dbFilePath, ReadStreamOptions); - if (fileStream.Length == 0) { + if (fileStream.Length == 0) + { return new ConcurrentDictionary(); } return await DeserializeDataAsync(fileStream, cancellationToken); - } catch (FileNotFoundException) { + } + catch (FileNotFoundException) + { return new ConcurrentDictionary(); } } /// - public async ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + public async ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); string tempFilePath = GenerateTempFilePath(); - try { - await using (FileStream fileStream = new(tempFilePath, WriteStreamOptions)) { + try + { + await using (FileStream fileStream = new(tempFilePath, WriteStreamOptions)) + { await SerializeDataAsync(fileStream, data, cancellationToken); await fileStream.FlushAsync(cancellationToken); } File.Move(tempFilePath, _dbFilePath, true); - } finally { + } + finally + { TryDeleteFile(tempFilePath); } } @@ -107,8 +123,10 @@ public async ValueTask SerializeAsync(ConcurrentDictionary data, public bool IsDisposed => _disposed; /// - public void Dispose() { - if (_disposed) { + public void Dispose() + { + if (_disposed) + { return; } @@ -117,8 +135,10 @@ public void Dispose() { } /// - public ValueTask DisposeAsync() { - if (_disposed) { + public ValueTask DisposeAsync() + { + if (_disposed) + { return ValueTask.CompletedTask; } @@ -131,8 +151,10 @@ public ValueTask DisposeAsync() { /// Releases serializer resources. /// /// Indicates whether disposal was triggered explicitly. - protected virtual void Dispose(bool disposing) { - if (_disposed) { + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { return; } @@ -140,32 +162,45 @@ protected virtual void Dispose(bool disposing) { _disposed = true; } - private static SafeFileHandle AcquireOwnershipHandle(string dbFilePath) { + private static SafeFileHandle AcquireOwnershipHandle(string dbFilePath) + { string lockFilePath = $"{dbFilePath}.lock"; - try { + try + { return File.OpenHandle(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - } catch (IOException ex) { + } + catch (IOException ex) + { throw new ArrowDbOwnershipException($"The database file '{dbFilePath}' is already owned by another process.", ex); } } - private string GenerateTempFilePath() { + private string GenerateTempFilePath() + { string? lastTempFilePath = _lastTempFilePath; string tempFilePath; - do { + do + { tempFilePath = $"{_dbFilePath}.{RandomNumberGenerator.GetHexString(4)}.tmp"; } while (string.Equals(tempFilePath, lastTempFilePath, StringComparison.Ordinal)); _lastTempFilePath = tempFilePath; return tempFilePath; } - private static void TryDeleteFile(string path) { - try { - if (File.Exists(path)) { + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { File.Delete(path); } - } catch (IOException) { - } catch (UnauthorizedAccessException) { + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { } } } diff --git a/src/ArrowDbCore/Serializers/FileSerializer.cs b/src/ArrowDbCore/Serializers/FileSerializer.cs index f073244..cfb1abc 100644 --- a/src/ArrowDbCore/Serializers/FileSerializer.cs +++ b/src/ArrowDbCore/Serializers/FileSerializer.cs @@ -7,7 +7,8 @@ namespace ArrowDbCore.Serializers; /// /// A file/disk backed serializer using JSON. /// -public class FileSerializer : BaseFileSerializer { +public class FileSerializer : BaseFileSerializer +{ private readonly JsonTypeInfo> _jsonTypeInfo; /// @@ -16,17 +17,20 @@ public class FileSerializer : BaseFileSerializer { /// The path to the file. /// The json type info for the dictionary. public FileSerializer(string path, JsonTypeInfo> jsonTypeInfo) - : base(path) { + : base(path) + { _jsonTypeInfo = jsonTypeInfo; } /// - protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) + { await JsonSerializer.SerializeAsync(stream, data, _jsonTypeInfo, cancellationToken); } /// - protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) + { ConcurrentDictionary? result = await JsonSerializer.DeserializeAsync(stream, _jsonTypeInfo, cancellationToken); return result ?? new ConcurrentDictionary(); } diff --git a/src/ArrowDbCore/Serializers/InMemorySerializer.cs b/src/ArrowDbCore/Serializers/InMemorySerializer.cs index dac3c27..6c9f948 100644 --- a/src/ArrowDbCore/Serializers/InMemorySerializer.cs +++ b/src/ArrowDbCore/Serializers/InMemorySerializer.cs @@ -6,7 +6,8 @@ namespace ArrowDbCore.Serializers; /// /// An in-memory serializer (does nothing) /// -public sealed class InMemorySerializer : IDbSerializer { +public sealed class InMemorySerializer : IDbSerializer +{ private bool _disposed; /// @@ -15,7 +16,8 @@ public sealed class InMemorySerializer : IDbSerializer { /// /// Returns an empty dictionary /// - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -23,18 +25,21 @@ public ValueTask> DeserializeAsync(Cancella /// /// Does nothing /// - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.CompletedTask; } /// - public void Dispose() { + public void Dispose() + { _disposed = true; } /// - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { Dispose(); return ValueTask.CompletedTask; } diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs b/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs index 06ab451..0094355 100644 --- a/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs +++ b/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs @@ -10,12 +10,15 @@ namespace ArrowDbCore.DependencyInjection.Tests; -public sealed class DependencyInjection { +public sealed class DependencyInjection +{ [Fact] - public async Task InitializationHostedService_PrimesRegisteredProvider_AndReturnsSameInstance() { + public async Task InitializationHostedService_PrimesRegisteredProvider_AndReturnsSameInstance() + { var serializer = new TrackingSerializer(); using IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(serializer); services.AddSingleton>(); services.AddArrowDbInitialization(); @@ -34,14 +37,17 @@ public async Task InitializationHostedService_PrimesRegisteredProvider_AndReturn } [Fact] - public async Task InitializationHostedService_WhenFileInitializationFails_HostStartupFails() { + public async Task InitializationHostedService_WhenFileInitializationFails_HostStartupFails() + { string path = Path.GetTempFileName(); - try { + try + { await File.WriteAllTextAsync(path, "not json", TestContext.Current.CancellationToken); using IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); services.AddSingleton>(); services.AddArrowDbInitialization(); @@ -49,15 +55,19 @@ public async Task InitializationHostedService_WhenFileInitializationFails_HostSt .Build(); await Assert.ThrowsAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task GenericProvider_WithInMemorySerializer_InitializesAndSupportsReads() { + public async Task GenericProvider_WithInMemorySerializer_InitializesAndSupportsReads() + { using IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(new InMemorySerializer()); services.AddSingleton>(); services.AddArrowDbInitialization(); @@ -74,12 +84,15 @@ public async Task GenericProvider_WithInMemorySerializer_InitializesAndSupportsR } [Fact] - public async Task GenericProvider_WithAesFileSerializer_InitializesSuccessfully() { + public async Task GenericProvider_WithAesFileSerializer_InitializesSuccessfully() + { string path = Path.GetTempFileName(); - try { + try + { using IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(_ => Aes.Create()); services.AddSingleton(serviceProvider => new AesFileSerializer( @@ -96,20 +109,25 @@ public async Task GenericProvider_WithAesFileSerializer_InitializesSuccessfully( IArrowDbProvider provider = host.Services.GetRequiredService(); ArrowDb db = await provider.GetAsync(TestContext.Current.CancellationToken); Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task HostShutdown_DisposesOwnedSerializer_AndRetainedArrowDbBlocksPersistence() { + public async Task HostShutdown_DisposesOwnedSerializer_AndRetainedArrowDbBlocksPersistence() + { string path = Path.GetTempFileName(); ArrowDb? db = null; FileSerializer? serializer = null; - try { + try + { IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); services.AddSingleton(_ => new ArrowDbProvider(serializer, disposeSerializer: true)); services.AddArrowDbInitialization(); @@ -130,16 +148,20 @@ public async Task HostShutdown_DisposesOwnedSerializer_AndRetainedArrowDbBlocksP using FileStream lockStream = new($"{path}.lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); Assert.NotNull(lockStream); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task GenericProvider_IsLazyWithoutInitializationHostedService() { + public async Task GenericProvider_IsLazyWithoutInitializationHostedService() + { var serializer = new TrackingSerializer(); using IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(serializer); services.AddSingleton>(); }) @@ -155,10 +177,12 @@ public async Task GenericProvider_IsLazyWithoutInitializationHostedService() { } [Fact] - public async Task ProviderOwnedSerializer_IsDisposedWhenHostStops() { + public async Task ProviderOwnedSerializer_IsDisposedWhenHostStops() + { var serializer = new TrackingSerializer(); IHost host = Host.CreateDefaultBuilder() - .ConfigureServices(services => { + .ConfigureServices(services => + { services.AddSingleton(_ => new ArrowDbProvider(serializer, disposeSerializer: true)); services.AddArrowDbInitialization(); }) @@ -172,7 +196,8 @@ public async Task ProviderOwnedSerializer_IsDisposedWhenHostStops() { } [Fact] - public async Task Provider_DoesNotDisposeExternalSerializerByDefault() { + public async Task Provider_DoesNotDisposeExternalSerializerByDefault() + { var serializer = new TrackingSerializer(); var provider = new ArrowDbProvider(serializer); @@ -185,12 +210,15 @@ public async Task Provider_DoesNotDisposeExternalSerializerByDefault() { Assert.False(serializer.IsDisposed); } - private sealed class TrackingSerializer : IDbSerializer { + private sealed class TrackingSerializer : IDbSerializer + { public int DeserializeCalls; public bool IsDisposed { get; private set; } - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { - if (IsDisposed) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { + if (IsDisposed) + { throw new ObjectDisposedException(GetType().FullName); } @@ -198,8 +226,10 @@ public ValueTask> DeserializeAsync(Cancella return ValueTask.FromResult(new ConcurrentDictionary()); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { - if (IsDisposed) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { + if (IsDisposed) + { throw new ObjectDisposedException(GetType().FullName); } @@ -208,7 +238,8 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance public void Dispose() => IsDisposed = true; - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { IsDisposed = true; return ValueTask.CompletedTask; } diff --git a/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs b/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs index 9510691..3ef38c1 100644 --- a/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs +++ b/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs @@ -1,12 +1,16 @@ namespace ArrowDbCore.DependencyInjection.Tests; -internal static class FileBackedTestHelpers { - public static void DeleteArtifacts(string path) { +internal static class FileBackedTestHelpers +{ + public static void DeleteArtifacts(string path) + { string? directory = Path.GetDirectoryName(path); string fileName = Path.GetFileName(path); - if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { - foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) + { File.Delete(tempFilePath); } } @@ -15,8 +19,10 @@ public static void DeleteArtifacts(string path) { DeleteIfExists($"{path}.lock"); } - private static void DeleteIfExists(string path) { - if (File.Exists(path)) { + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { File.Delete(path); } } diff --git a/tests/ArrowDbCore.Tests.Analyzers/Program.cs b/tests/ArrowDbCore.Tests.Analyzers/Program.cs index 837131c..1bc52a6 100644 --- a/tests/ArrowDbCore.Tests.Analyzers/Program.cs +++ b/tests/ArrowDbCore.Tests.Analyzers/Program.cs @@ -1 +1 @@ -Console.WriteLine("Hello, World!"); \ No newline at end of file +Console.WriteLine("Hello, World!"); diff --git a/tests/ArrowDbCore.Tests.Common/JContext.cs b/tests/ArrowDbCore.Tests.Common/JContext.cs index 98d5ffc..f796ce7 100644 --- a/tests/ArrowDbCore.Tests.Common/JContext.cs +++ b/tests/ArrowDbCore.Tests.Common/JContext.cs @@ -5,4 +5,4 @@ namespace ArrowDbCore.Tests.Common; [JsonSourceGenerationOptions(WriteIndented = false, NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true)] [JsonSerializable(typeof(Person))] [JsonSerializable(typeof(int))] -public partial class JContext : JsonSerializerContext { } \ No newline at end of file +public partial class JContext : JsonSerializerContext { } diff --git a/tests/ArrowDbCore.Tests.Common/Person.cs b/tests/ArrowDbCore.Tests.Common/Person.cs index 6b5eef6..75122cc 100644 --- a/tests/ArrowDbCore.Tests.Common/Person.cs +++ b/tests/ArrowDbCore.Tests.Common/Person.cs @@ -1,8 +1,9 @@ namespace ArrowDbCore.Tests.Common; -public class Person { +public class Person +{ public string Name { get; set; } = string.Empty; public int Age { get; set; } public DateTime BirthDate { get; set; } public bool IsMarried { get; set; } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs index a0364b2..c87d8e5 100644 --- a/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs +++ b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs @@ -1,18 +1,24 @@ namespace ArrowDbCore.Tests.Integrity; -internal static class FileBackedTestHelpers { - public static void ReleaseOwnership(ArrowDb db) { - if (db.Serializer is IDisposable disposable) { +internal static class FileBackedTestHelpers +{ + public static void ReleaseOwnership(ArrowDb db) + { + if (db.Serializer is IDisposable disposable) + { disposable.Dispose(); } } - public static void DeleteArtifacts(string path) { + public static void DeleteArtifacts(string path) + { string? directory = Path.GetDirectoryName(path); string fileName = Path.GetFileName(path); - if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { - foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) + { File.Delete(tempFilePath); } } @@ -21,8 +27,10 @@ public static void DeleteArtifacts(string path) { DeleteIfExists($"{path}.lock"); } - private static void DeleteIfExists(string path) { - if (File.Exists(path)) { + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { File.Delete(path); } } diff --git a/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs b/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs index 8b7b4e9..b231f59 100644 --- a/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs +++ b/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs @@ -8,8 +8,10 @@ namespace ArrowDbCore.Tests.Integrity; -public class LargeFile { - private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func> factory) { +public class LargeFile +{ + private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func> factory) + { const int itemCount = 500_000; ArrowDb? db = null; ArrowDb? db2 = null; @@ -22,13 +24,15 @@ private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func p.IsMarried, (f, _) => f.Random.Bool()); var buffer = new char[256]; - try { + try + { // load the db db = await factory(); // clear Assert.True(db.TryClear()); // add items - for (var j = 0; j < itemCount; j++) { + for (var j = 0; j < itemCount; j++) + { var person = faker.Generate(); var key = ArrowDb.GenerateTypedKey(person.Name, buffer); db.Upsert(key, person, JContext.Default.Person); @@ -40,12 +44,16 @@ private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func ArrowDb.CreateFromFile(path)); } [Fact] - public async Task LargeFile_Passes_OneReadWriteCycle_AesFileSerializer() { + public async Task LargeFile_Passes_OneReadWriteCycle_AesFileSerializer() + { var path = Sharpify.Utils.Env.PathInBaseDirectory("long-test-aes-file-serializer.db"); using var aes = Aes.Create(); aes.GenerateKey(); diff --git a/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs b/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs index 2242dad..ebedcfa 100644 --- a/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs +++ b/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs @@ -8,8 +8,10 @@ namespace ArrowDbCore.Tests.Integrity; -public class OverwriteForceClear { - private static async Task SerializeOverwritesExistingFile(string path, Func> factory) { +public class OverwriteForceClear +{ + private static async Task SerializeOverwritesExistingFile(string path, Func> factory) + { const int itemCount = 1_000; ArrowDb? db = null; @@ -21,13 +23,15 @@ private static async Task SerializeOverwritesExistingFile(string path, Func p.IsMarried, (f, _) => f.Random.Bool()); var buffer = new char[256]; - try { + try + { // load the db db = await factory(); // clear Assert.True(db.TryClear()); // add items - for (var j = 0; j < itemCount; j++) { + for (var j = 0; j < itemCount; j++) + { var person = faker.Generate(); var key = ArrowDb.GenerateTypedKey(person.Name, buffer); db.Upsert(key, person, JContext.Default.Person); @@ -43,8 +47,11 @@ private static async Task SerializeOverwritesExistingFile(string path, Func ArrowDb.CreateFromFile(path)); } [Fact] - public async Task SerializeOverwritesExistingFile_AesFileSerializer() { + public async Task SerializeOverwritesExistingFile_AesFileSerializer() + { var path = Sharpify.Utils.Env.PathInBaseDirectory("overwrite-test-aes-file-serializer.db"); using var aes = Aes.Create(); aes.GenerateKey(); diff --git a/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs b/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs index ec8a118..d43742b 100644 --- a/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs +++ b/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs @@ -8,8 +8,10 @@ namespace ArrowDbCore.Tests.Integrity; -public class ReadWriteCycles { - private static async Task FileIO_Passes_ReadWriteCycles(string path, Func> factory) { +public class ReadWriteCycles +{ + private static async Task FileIO_Passes_ReadWriteCycles(string path, Func> factory) + { const int iterations = 200; const int itemCount = 100; ArrowDb? db = null; @@ -22,14 +24,17 @@ private static async Task FileIO_Passes_ReadWriteCycles(string path, Func p.IsMarried, (f, _) => f.Random.Bool()); var buffer = new char[256]; - try { - for (var i = 0; i < iterations; i++) { + try + { + for (var i = 0; i < iterations; i++) + { // load the db db = await factory(); // clear Assert.True(db.TryClear()); // add items - for (var j = 0; j < itemCount; j++) { + for (var j = 0; j < itemCount; j++) + { var person = faker.Generate(); var key = ArrowDb.GenerateTypedKey(person.Name, buffer); db.Upsert(key, person, JContext.Default.Person); @@ -39,8 +44,11 @@ private static async Task FileIO_Passes_ReadWriteCycles(string path, Func ArrowDb.CreateFromFile(path)); } [Fact] - public async Task FileIO_Passes_ReadWriteCycles_AesFileSerializer() { + public async Task FileIO_Passes_ReadWriteCycles_AesFileSerializer() + { var path = Sharpify.Utils.Env.PathInBaseDirectory("rdc-test-aes-file-serializer.db"); using var aes = Aes.Create(); aes.GenerateKey(); diff --git a/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs b/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs index 0d409ea..6b93b46 100644 --- a/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs +++ b/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs @@ -1,8 +1,11 @@ namespace ArrowDbCore.Tests.Probes.FileOwnership; -internal static class Program { - private static async Task Main(string[] args) { - if (args.Length != 2 || !string.Equals(args[0], "hold", StringComparison.Ordinal)) { +internal static class Program +{ + private static async Task Main(string[] args) + { + if (args.Length != 2 || !string.Equals(args[0], "hold", StringComparison.Ordinal)) + { Console.Error.WriteLine("Usage: hold "); return 1; } diff --git a/tests/ArrowDbCore.Tests.Unit.Isolated/StaticVariables.cs b/tests/ArrowDbCore.Tests.Unit.Isolated/StaticVariables.cs index 222eaa6..55f44c5 100644 --- a/tests/ArrowDbCore.Tests.Unit.Isolated/StaticVariables.cs +++ b/tests/ArrowDbCore.Tests.Unit.Isolated/StaticVariables.cs @@ -1,16 +1,19 @@ namespace ArrowDbCore.Tests.Unit.Isolated; -public class StaticVariables { +public class StaticVariables +{ [Fact] - public async Task Instance_Ids_Match_Running() { + public async Task Instance_Ids_Match_Running() + { // At startup of process instances should be 0 Assert.Equal(0, ArrowDb.RunningInstances); // Create 10 instances and check counter const int count = 10; var dbs = new ArrowDb[count]; - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { dbs[i] = await ArrowDb.CreateInMemory(); } Assert.Equal(count, ArrowDb.RunningInstances); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs index cb6880f..b4b5a94 100644 --- a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs +++ b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs @@ -4,9 +4,11 @@ namespace ArrowDbCore.Tests.Unit; -public class Cancellation { +public class Cancellation +{ [Fact] - public async Task CreateInMemory_WhenCanceled_ThrowsOperationCanceledException() { + public async Task CreateInMemory_WhenCanceled_ThrowsOperationCanceledException() + { using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); @@ -14,7 +16,8 @@ public async Task CreateInMemory_WhenCanceled_ThrowsOperationCanceledException() } [Fact] - public async Task CreateCustom_WhenCanceled_ThrowsOperationCanceledException() { + public async Task CreateCustom_WhenCanceled_ThrowsOperationCanceledException() + { using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); @@ -22,34 +25,43 @@ public async Task CreateCustom_WhenCanceled_ThrowsOperationCanceledException() { } [Fact] - public async Task CreateFromFile_WhenCanceled_ThrowsOperationCanceledException() { + public async Task CreateFromFile_WhenCanceled_ThrowsOperationCanceledException() + { string path = Path.GetTempFileName(); using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); - try { + try + { await Assert.ThrowsAsync(() => ArrowDb.CreateFromFile(path, cancellationTokenSource.Token).AsTask()); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task CreateFromFileWithAes_WhenCanceled_ThrowsOperationCanceledException() { + public async Task CreateFromFileWithAes_WhenCanceled_ThrowsOperationCanceledException() + { string path = Path.GetTempFileName(); using var aes = System.Security.Cryptography.Aes.Create(); using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); - try { + try + { await Assert.ThrowsAsync(() => ArrowDb.CreateFromFileWithAes(path, aes, cancellationTokenSource.Token).AsTask()); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task SerializeAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndDoesNotStartSecondSerialize() { + public async Task SerializeAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndDoesNotStartSecondSerialize() + { var serializer = new CancellationSerializer(); var db = await ArrowDb.CreateCustom(serializer); Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); @@ -70,7 +82,8 @@ public async Task SerializeAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndD } [Fact] - public async Task RollbackAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndLeavesStateUnchanged() { + public async Task RollbackAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndLeavesStateUnchanged() + { var serializer = new CancellationSerializer(); var db = await ArrowDb.CreateCustom(serializer); int deserializeCallsBeforeRollback = Volatile.Read(ref serializer.DeserializeCalls); @@ -93,7 +106,8 @@ public async Task RollbackAsync_WhenCanceledWhileWaitingForSemaphore_ThrowsAndLe } [Fact] - public async Task TransactionScope_WhenOuterTokenCanceled_ThrowsAndLeavesPendingChanges() { + public async Task TransactionScope_WhenOuterTokenCanceled_ThrowsAndLeavesPendingChanges() + { var db = await ArrowDb.CreateInMemory(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -110,7 +124,8 @@ public async Task TransactionScope_WhenOuterTokenCanceled_ThrowsAndLeavesPending } } -internal sealed class CancellationSerializer : IDbSerializer { +internal sealed class CancellationSerializer : IDbSerializer +{ private bool _disposed; public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -120,13 +135,15 @@ internal sealed class CancellationSerializer : IDbSerializer { public bool IsDisposed => _disposed; - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); Interlocked.Increment(ref DeserializeCalls); return ValueTask.FromResult(new ConcurrentDictionary()); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); Interlocked.Increment(ref SerializeCalls); SerializeStarted.TrySetResult(); @@ -135,7 +152,8 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance public void Dispose() => _disposed = true; - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { _disposed = true; return ValueTask.CompletedTask; } diff --git a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs index 67dcd08..ccab93d 100644 --- a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs +++ b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs @@ -4,24 +4,28 @@ namespace ArrowDbCore.Tests.Unit; -public class Concurrency { +public class Concurrency +{ [Theory] [InlineData(true)] [InlineData(false)] - public async Task Concurrent_Writes_ShouldBe_ThreadSafe(bool useAes) { + public async Task Concurrent_Writes_ShouldBe_ThreadSafe(bool useAes) + { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); ArrowDb? db = null; ArrowDb? db2 = null; - try { + try + { db = await CreateDb(path, useAes, aes); var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; var taskCount = 100; var tasks = new Task[taskCount]; // Act - for (var i = 0; i < taskCount; i++) { + for (var i = 0; i < taskCount; i++) + { var key = $"key{i}"; tasks[i] = Task.Run(() => db.Upsert(key, person, JContext.Default.Person)); } @@ -33,12 +37,16 @@ public async Task Concurrent_Writes_ShouldBe_ThreadSafe(bool useAes) { // Assert db2 = await CreateDb(path, useAes, aes); Assert.Equal(taskCount, db2.Count); - } finally { - if (db2 is not null) { + } + finally + { + if (db2 is not null) + { FileBackedTestHelpers.ReleaseOwnership(db2); } - if (db is not null) { + if (db is not null) + { FileBackedTestHelpers.ReleaseOwnership(db); } @@ -46,8 +54,10 @@ public async Task Concurrent_Writes_ShouldBe_ThreadSafe(bool useAes) { } } - private async Task CreateDb(string path, bool useAes, Aes? aes = null) { - if (useAes) { + private async Task CreateDb(string path, bool useAes, Aes? aes = null) + { + if (useAes) + { return await ArrowDb.CreateFromFileWithAes(path, aes!); } diff --git a/tests/ArrowDbCore.Tests.Unit/Disposal.cs b/tests/ArrowDbCore.Tests.Unit/Disposal.cs index 32166f5..2e33ec7 100644 --- a/tests/ArrowDbCore.Tests.Unit/Disposal.cs +++ b/tests/ArrowDbCore.Tests.Unit/Disposal.cs @@ -7,9 +7,11 @@ namespace ArrowDbCore.Tests.Unit; -public sealed class Disposal { +public sealed class Disposal +{ [Fact] - public async Task InMemorySerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() { + public async Task InMemorySerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() + { var serializer = new InMemorySerializer(); await serializer.DisposeAsync(); @@ -20,11 +22,13 @@ public async Task InMemorySerializer_WhenDisposed_ReportsDisposedAndThrowsFromAs } [Fact] - public async Task FileSerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() { + public async Task FileSerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncMethods() + { string path = Path.GetTempFileName(); FileSerializer? serializer = null; - try { + try + { serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); await serializer.DisposeAsync(); @@ -32,31 +36,38 @@ public async Task FileSerializer_WhenDisposed_ReportsDisposedAndThrowsFromAsyncM Assert.True(serializer.IsDisposed); await Assert.ThrowsAsync(() => serializer.DeserializeAsync().AsTask()); await Assert.ThrowsAsync(() => serializer.SerializeAsync(new ConcurrentDictionary()).AsTask()); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public void AesFileSerializer_Dispose_DoesNotDisposeSuppliedAes() { + public void AesFileSerializer_Dispose_DoesNotDisposeSuppliedAes() + { string path = Path.GetTempFileName(); using Aes aes = Aes.Create(); AesFileSerializer? serializer = null; - try { + try + { serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); serializer.Dispose(); using ICryptoTransform encryptor = aes.CreateEncryptor(); Assert.NotNull(encryptor); - } finally { + } + finally + { FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task ArrowDb_WhenSerializerDisposed_PersistenceApisThrowAndInMemoryOperationsStillWork() { + public async Task ArrowDb_WhenSerializerDisposed_PersistenceApisThrowAndInMemoryOperationsStillWork() + { ArrowDb db = await ArrowDb.CreateInMemory(); Assert.True(db.Upsert("seed", 1, JContext.Default.Int32)); @@ -74,7 +85,8 @@ public async Task ArrowDb_WhenSerializerDisposed_PersistenceApisThrowAndInMemory } [Fact] - public async Task CreateCustom_WhenDeserializeFails_DisposesSerializer() { + public async Task CreateCustom_WhenDeserializeFails_DisposesSerializer() + { var serializer = new FailingSerializer(); await Assert.ThrowsAsync(() => ArrowDb.CreateCustom(serializer).AsTask()); @@ -83,32 +95,40 @@ public async Task CreateCustom_WhenDeserializeFails_DisposesSerializer() { } [Fact] - public async Task CreateFromFile_WhenDeserializeFails_DisposesSerializerAndReleasesOwnership() { + public async Task CreateFromFile_WhenDeserializeFails_DisposesSerializerAndReleasesOwnership() + { string path = Path.GetTempFileName(); FileSerializer? serializer = null; - try { + try + { await File.WriteAllTextAsync(path, "not json", TestContext.Current.CancellationToken); await Assert.ThrowsAsync(() => ArrowDb.CreateFromFile(path).AsTask()); serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); Assert.False(serializer.IsDisposed); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } - private sealed class FailingSerializer : IDbSerializer { + private sealed class FailingSerializer : IDbSerializer + { public bool IsDisposed { get; private set; } - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { throw new InvalidOperationException("boom"); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { - if (IsDisposed) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { + if (IsDisposed) + { throw new ObjectDisposedException(GetType().FullName); } @@ -117,7 +137,8 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance public void Dispose() => IsDisposed = true; - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { IsDisposed = true; return ValueTask.CompletedTask; } diff --git a/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs index a0ee949..9bca437 100644 --- a/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs +++ b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs @@ -1,18 +1,24 @@ namespace ArrowDbCore.Tests.Unit; -internal static class FileBackedTestHelpers { - public static void ReleaseOwnership(ArrowDb db) { - if (db.Serializer is IDisposable disposable) { +internal static class FileBackedTestHelpers +{ + public static void ReleaseOwnership(ArrowDb db) + { + if (db.Serializer is IDisposable disposable) + { disposable.Dispose(); } } - public static void DeleteArtifacts(string path) { + public static void DeleteArtifacts(string path) + { string? directory = Path.GetDirectoryName(path); string fileName = Path.GetFileName(path); - if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) { - foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) { + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + foreach (string tempFilePath in Directory.EnumerateFiles(directory, $"{fileName}.*.tmp")) + { File.Delete(tempFilePath); } } @@ -21,8 +27,10 @@ public static void DeleteArtifacts(string path) { DeleteIfExists($"{path}.lock"); } - private static void DeleteIfExists(string path) { - if (File.Exists(path)) { + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { File.Delete(path); } } diff --git a/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs b/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs index f760a2f..7440d0c 100644 --- a/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs +++ b/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs @@ -6,50 +6,61 @@ namespace ArrowDbCore.Tests.Unit; -public sealed class FileOwnership { +public sealed class FileOwnership +{ [Fact] - public void FileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() { + public void FileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() + { string path = Path.GetTempFileName(); FileSerializer? serializer = null; - try { + try + { serializer = new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); ArrowDbOwnershipException exception = Assert.Throws(() => new FileSerializer(path, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); Assert.Contains(path, exception.Message, StringComparison.Ordinal); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public void AesFileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() { + public void AesFileSerializer_WhenPathAlreadyOwned_ThrowsInConstructor() + { string path = Path.GetTempFileName(); using Aes aes = Aes.Create(); AesFileSerializer? serializer = null; - try { + try + { serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); ArrowDbOwnershipException exception = Assert.Throws(() => new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray)); Assert.Contains(path, exception.Message, StringComparison.Ordinal); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task CreateFromFile_WhenOwnedByAnotherProcess_ThrowsUntilOwnerExits() { + public async Task CreateFromFile_WhenOwnedByAnotherProcess_ThrowsUntilOwnerExits() + { string path = Path.GetTempFileName(); Process? process = null; - try { + try + { process = StartOwnershipProbe(path); await WaitForReady(process); @@ -60,8 +71,11 @@ public async Task CreateFromFile_WhenOwnedByAnotherProcess_ThrowsUntilOwnerExits ArrowDb db = await ArrowDb.CreateFromFile(path); FileBackedTestHelpers.ReleaseOwnership(db); - } finally { - if (process is not null) { + } + finally + { + if (process is not null) + { process.Dispose(); } @@ -69,9 +83,11 @@ public async Task CreateFromFile_WhenOwnedByAnotherProcess_ThrowsUntilOwnerExits } } - private static Process StartOwnershipProbe(string path) { + private static Process StartOwnershipProbe(string path) + { string probeAssemblyPath = typeof(OwnershipProbeMarker).Assembly.Location; - var startInfo = new ProcessStartInfo("dotnet") { + var startInfo = new ProcessStartInfo("dotnet") + { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, @@ -82,9 +98,11 @@ private static Process StartOwnershipProbe(string path) { return Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ownership probe process."); } - private static async Task WaitForReady(Process process) { + private static async Task WaitForReady(Process process) + { string? line = await process.StandardOutput.ReadLineAsync(TestContext.Current.CancellationToken); - if (string.Equals(line, "READY", StringComparison.Ordinal)) { + if (string.Equals(line, "READY", StringComparison.Ordinal)) + { return; } diff --git a/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs index 330104f..88a0ccd 100644 --- a/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs +++ b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs @@ -5,16 +5,20 @@ namespace ArrowDbCore.Tests.Unit; -public sealed class FileSerializerAsync { +public sealed class FileSerializerAsync +{ [Fact] - public async Task BaseFileSerializer_SerializeAsync_WhenCanceledBeforeCommit_LeavesOriginalFileAndDeletesTemp() { + public async Task BaseFileSerializer_SerializeAsync_WhenCanceledBeforeCommit_LeavesOriginalFileAndDeletesTemp() + { string path = Path.GetTempFileName(); AsyncTrackingFileSerializer? serializer = null; - try { + try + { await File.WriteAllTextAsync(path, "original", TestContext.Current.CancellationToken); - serializer = new AsyncTrackingFileSerializer(path) { + serializer = new AsyncTrackingFileSerializer(path) + { BlockSerialize = true, }; @@ -30,21 +34,26 @@ public async Task BaseFileSerializer_SerializeAsync_WhenCanceledBeforeCommit_Lea Assert.Equal("original", await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); Assert.NotNull(tempFilePath); Assert.False(File.Exists(tempFilePath)); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task BaseFileSerializer_DeserializeAsync_WhenCanceled_ThrowsAndLeavesFileUnchanged() { + public async Task BaseFileSerializer_DeserializeAsync_WhenCanceled_ThrowsAndLeavesFileUnchanged() + { string path = Path.GetTempFileName(); AsyncTrackingFileSerializer? serializer = null; - try { + try + { await File.WriteAllTextAsync(path, "existing", TestContext.Current.CancellationToken); - serializer = new AsyncTrackingFileSerializer(path) { + serializer = new AsyncTrackingFileSerializer(path) + { BlockDeserialize = true, }; @@ -56,18 +65,22 @@ public async Task BaseFileSerializer_DeserializeAsync_WhenCanceled_ThrowsAndLeav await Assert.ThrowsAnyAsync(() => deserializeTask); Assert.Equal("existing", await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } [Fact] - public async Task BaseFileSerializer_SerializeAsync_UsesUniqueTempFilePerWrite() { + public async Task BaseFileSerializer_SerializeAsync_UsesUniqueTempFilePerWrite() + { string path = Path.GetTempFileName(); AsyncTrackingFileSerializer? serializer = null; - try { + try + { serializer = new AsyncTrackingFileSerializer(path); await serializer.SerializeAsync(new ConcurrentDictionary()); @@ -75,18 +88,22 @@ public async Task BaseFileSerializer_SerializeAsync_UsesUniqueTempFilePerWrite() Assert.Equal(2, serializer.SerializeStreamPaths.Count); Assert.NotEqual(serializer.SerializeStreamPaths[0], serializer.SerializeStreamPaths[1]); - Assert.All(serializer.SerializeStreamPaths, tempFilePath => { + Assert.All(serializer.SerializeStreamPaths, tempFilePath => + { Assert.StartsWith($"{path}.", tempFilePath, StringComparison.Ordinal); Assert.EndsWith(".tmp", tempFilePath, StringComparison.Ordinal); Assert.NotEqual(path, tempFilePath); }); - } finally { + } + finally + { serializer?.Dispose(); FileBackedTestHelpers.DeleteArtifacts(path); } } - private sealed class AsyncTrackingFileSerializer : BaseFileSerializer { + private sealed class AsyncTrackingFileSerializer : BaseFileSerializer + { public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource DeserializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly List SerializeStreamPaths = []; @@ -94,16 +111,20 @@ private sealed class AsyncTrackingFileSerializer : BaseFileSerializer { public bool BlockDeserialize; public AsyncTrackingFileSerializer(string path) - : base(path) { + : base(path) + { } - protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) { - if (stream is FileStream fileStream) { + protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentDictionary data, CancellationToken cancellationToken) + { + if (stream is FileStream fileStream) + { SerializeStreamPaths.Add(fileStream.Name); } SerializeStarted.TrySetResult(); - if (BlockSerialize) { + if (BlockSerialize) + { await Task.Delay(Timeout.Infinite, cancellationToken); } @@ -111,9 +132,11 @@ protected override async ValueTask SerializeDataAsync(Stream stream, ConcurrentD await stream.WriteAsync(bytes, cancellationToken); } - protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) { + protected override async ValueTask> DeserializeDataAsync(Stream stream, CancellationToken cancellationToken) + { DeserializeStarted.TrySetResult(); - if (BlockDeserialize) { + if (BlockDeserialize) + { await Task.Delay(Timeout.Infinite, cancellationToken); } diff --git a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs index 927397c..d1629a4 100644 --- a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs +++ b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs @@ -2,15 +2,18 @@ namespace ArrowDbCore.Tests.Unit; -public class GetOrAddAsync { +public class GetOrAddAsync +{ #pragma warning disable xUnit1031 // Do not use blocking task operations in test method // this is required here for testing purposes [Fact] - public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() { + public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); // add before - var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => + { await Task.Delay(1000); return 1; }); @@ -21,12 +24,14 @@ public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() { } [Fact] - public async Task GetOrAddAsync_WithTArg_ReturnsSynchronously_WhenExists() { + public async Task GetOrAddAsync_WithTArg_ReturnsSynchronously_WhenExists() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); // add before // using a static delegate ensures that closure cannot be allocated - var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => + { await Task.Delay(1000); return value; }, 1); @@ -38,11 +43,13 @@ public async Task GetOrAddAsync_WithTArg_ReturnsSynchronously_WhenExists() { #pragma warning restore xUnit1031 // Do not use blocking task operations in test method [Fact] - public async Task GetOrAddAsync_ReturnsAsynchronously_WhenNotExists() { + public async Task GetOrAddAsync_ReturnsAsynchronously_WhenNotExists() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); // doesn't exist - var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async (_, _) => + { await Task.Delay(1000); return 1; }); @@ -51,12 +58,14 @@ public async Task GetOrAddAsync_ReturnsAsynchronously_WhenNotExists() { } [Fact] - public async Task GetOrAddAsync_WithTArg_ReturnsAsynchronously_WhenNotExists() { + public async Task GetOrAddAsync_WithTArg_ReturnsAsynchronously_WhenNotExists() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); // doesn't exist // using a static delegate ensures that closure cannot be allocated - var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, static async (_, value, _) => + { await Task.Delay(1000); return value; }, 1); @@ -65,7 +74,8 @@ public async Task GetOrAddAsync_WithTArg_ReturnsAsynchronously_WhenNotExists() { } [Fact] - public async Task GetOrAddAsync_FailingFactory_DoesNotAddItem() { + public async Task GetOrAddAsync_FailingFactory_DoesNotAddItem() + { // Arrange var db = await ArrowDb.CreateInMemory(); @@ -81,14 +91,16 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists_EvenIfCanceled() { + public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists_EvenIfCanceled() + { var db = await ArrowDb.CreateInMemory(); db.Upsert("1", 1, JContext.Default.Int32); using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); bool factoryCalled = false; - var task = db.GetOrAddAsync("1", JContext.Default.Int32, (_, _) => { + var task = db.GetOrAddAsync("1", JContext.Default.Int32, (_, _) => + { factoryCalled = true; return ValueTask.FromResult(2); }, cancellationTokenSource.Token); @@ -99,12 +111,14 @@ public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists_EvenIfCanceled() } [Fact] - public async Task GetOrAddAsync_WhenCanceledAfterFactory_ReturnsCanceledAndDoesNotAddItem() { + public async Task GetOrAddAsync_WhenCanceledAfterFactory_ReturnsCanceledAndDoesNotAddItem() + { var db = await ArrowDb.CreateInMemory(); using var cancellationTokenSource = new CancellationTokenSource(); await Assert.ThrowsAsync(() => - db.GetOrAddAsync("key", JContext.Default.Int32, (_, cancellationToken) => { + db.GetOrAddAsync("key", JContext.Default.Int32, (_, cancellationToken) => + { cancellationTokenSource.Cancel(); return ValueTask.FromResult(1); }, cancellationTokenSource.Token).AsTask() diff --git a/tests/ArrowDbCore.Tests.Unit/KeyGeneration.cs b/tests/ArrowDbCore.Tests.Unit/KeyGeneration.cs index a4d22ca..65b7fde 100644 --- a/tests/ArrowDbCore.Tests.Unit/KeyGeneration.cs +++ b/tests/ArrowDbCore.Tests.Unit/KeyGeneration.cs @@ -2,30 +2,35 @@ namespace ArrowDbCore.Tests.Unit; -public class KeyGeneration { +public class KeyGeneration +{ [InlineArray(128)] - private struct Buffer { + private struct Buffer + { private char _first; } [Fact] - public void GenerateTypedKey_Primitive() { + public void GenerateTypedKey_Primitive() + { var buffer = new Buffer(); var key = ArrowDb.GenerateTypedKey("1", buffer); Assert.Equal("Int32:1", key); } [Fact] - public void GenerateTypedKey_String() { + public void GenerateTypedKey_String() + { var buffer = new Buffer(); var key = ArrowDb.GenerateTypedKey("1", buffer); Assert.Equal("String:1", key); } [Fact] - public void GenerateTypedKey_Person() { + public void GenerateTypedKey_Person() + { var buffer = new Buffer(); var key = ArrowDb.GenerateTypedKey("1", buffer); Assert.Equal("Buffer:1", key); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/OnChange.cs b/tests/ArrowDbCore.Tests.Unit/OnChange.cs index d5cac95..c3a6971 100644 --- a/tests/ArrowDbCore.Tests.Unit/OnChange.cs +++ b/tests/ArrowDbCore.Tests.Unit/OnChange.cs @@ -2,37 +2,44 @@ namespace ArrowDbCore.Tests.Unit; -public class OnChange { +public class OnChange +{ [Fact] - public async Task OnChange_Upserts_Shows_Expected_Change() { + public async Task OnChange_Upserts_Shows_Expected_Change() + { var db = await ArrowDb.CreateInMemory(); var change = (ArrowDbChangeType)(-1); // invalid state to ensure the event is triggered - db.OnChange += (_, args) => { + db.OnChange += (_, args) => + { change = args.ChangeType; }; db.Upsert("1", 1, JContext.Default.Int32); Assert.Equal(ArrowDbChangeType.Upsert, change); } [Fact] - public async Task OnChange_Remove_Shows_Expected_Change() { + public async Task OnChange_Remove_Shows_Expected_Change() + { var db = await ArrowDb.CreateInMemory(); db.Upsert("1", 1, JContext.Default.Int32); var change = (ArrowDbChangeType)(-1); // invalid state to ensure the event is triggered - db.OnChange += (_, args) => { + db.OnChange += (_, args) => + { change = args.ChangeType; }; db.TryRemove("1"); Assert.Equal(ArrowDbChangeType.Remove, change); } [Fact] - public async Task OnChange_Clear_Shows_Expected_Change() { + public async Task OnChange_Clear_Shows_Expected_Change() + { var db = await ArrowDb.CreateInMemory(); db.Upsert("1", 1, JContext.Default.Int32); var change = (ArrowDbChangeType)(-1); // invalid state to ensure the event is triggered - db.OnChange += (_, args) => { + db.OnChange += (_, args) => + { change = args.ChangeType; }; Assert.True(db.TryClear()); Assert.Equal(ArrowDbChangeType.Clear, change); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Reads.cs b/tests/ArrowDbCore.Tests.Unit/Reads.cs index c23794a..60207cb 100644 --- a/tests/ArrowDbCore.Tests.Unit/Reads.cs +++ b/tests/ArrowDbCore.Tests.Unit/Reads.cs @@ -4,9 +4,11 @@ namespace ArrowDbCore.Tests.Unit; -public class Reads { +public class Reads +{ [Fact] - public async Task TryGetValue_CurrentType() { + public async Task TryGetValue_CurrentType() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -16,9 +18,11 @@ public async Task TryGetValue_CurrentType() { } [Fact] - public async Task TryGetValue_WrongType_ThrowsJsonException() { + public async Task TryGetValue_WrongType_ThrowsJsonException() + { var db = await ArrowDb.CreateInMemory(); - Person ron = new() { + Person ron = new() + { Name = "Ron", Age = 50, BirthDate = TimeProvider.System.GetUtcNow().AddYears(-50).DateTime, @@ -33,7 +37,8 @@ public async Task TryGetValue_WrongType_ThrowsJsonException() { } [Fact] - public async Task TryGetValue_Struct_ReturnsTrueForDefault() { + public async Task TryGetValue_Struct_ReturnsTrueForDefault() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("int", 0, JContext.Default.Int32); @@ -41,4 +46,4 @@ public async Task TryGetValue_Struct_ReturnsTrueForDefault() { Assert.True(db.TryGetValue("int", JContext.Default.Int32, out int val)); Assert.Equal(default, val); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Removes.cs b/tests/ArrowDbCore.Tests.Unit/Removes.cs index 1ffedc1..a81a4eb 100644 --- a/tests/ArrowDbCore.Tests.Unit/Removes.cs +++ b/tests/ArrowDbCore.Tests.Unit/Removes.cs @@ -2,16 +2,19 @@ namespace ArrowDbCore.Tests.Unit; -public class Removes { +public class Removes +{ [Fact] - public async Task TryRemove_When_Not_Found_Returns_False() { + public async Task TryRemove_When_Not_Found_Returns_False() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); Assert.False(db.TryRemove("1")); } [Fact] - public async Task TryRemove_NotFound_DoesNotIncrementPendingChanges() { + public async Task TryRemove_NotFound_DoesNotIncrementPendingChanges() + { // Arrange var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.PendingChanges); @@ -25,7 +28,8 @@ public async Task TryRemove_NotFound_DoesNotIncrementPendingChanges() { } [Fact] - public async Task TryRemove_When_Found_Returns_True() { + public async Task TryRemove_When_Found_Returns_True() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -33,7 +37,8 @@ public async Task TryRemove_When_Found_Returns_True() { } [Fact] - public async Task TryRemove_When_Found_Removes() { + public async Task TryRemove_When_Found_Removes() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -46,7 +51,8 @@ public async Task TryRemove_When_Found_Removes() { } [Fact] - public async Task Clear_Removes_All_From_Db() { + public async Task Clear_Removes_All_From_Db() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -59,4 +65,4 @@ public async Task Clear_Removes_All_From_Db() { Assert.False(db.TryGetValue("2", JContext.Default.Int32, out _)); Assert.Equal(0, db.Count); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs index 932c6d5..7c9845d 100644 --- a/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs +++ b/tests/ArrowDbCore.Tests.Unit/RollbackRace.cs @@ -4,15 +4,19 @@ namespace ArrowDbCore.Tests.Unit; -public class RollbackRace { +public class RollbackRace +{ [Fact] - public async Task Upsert_WhenRacingWithRollback_EitherPersistsOrSignalsFailure() { + public async Task Upsert_WhenRacingWithRollback_EitherPersistsOrSignalsFailure() + { var serializer = new RollbackRaceBlockingSerializer(); var db = await ArrowDb.CreateCustom(serializer); var upsertCommitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - db.OnChange += (_, args) => { - if (args.ChangeType == ArrowDbChangeType.Upsert) { + db.OnChange += (_, args) => + { + if (args.ChangeType == ArrowDbChangeType.Upsert) + { upsertCommitted.TrySetResult(); } }; @@ -20,7 +24,8 @@ public async Task Upsert_WhenRacingWithRollback_EitherPersistsOrSignalsFailure() var hooks = new RollbackRaceHooks(); RollbackRaceValueConverter.Hooks.Value = hooks; - try { + try + { Task upsertTask = Task.Run(() => db.Upsert( "k", new RollbackRaceValue { X = 1 }, @@ -49,13 +54,16 @@ public async Task Upsert_WhenRacingWithRollback_EitherPersistsOrSignalsFailure() // If the write is not reliable due to concurrent rollback, the operation should report failure. // Today, this can be violated (Upsert returns true but the key is dropped by rollback). Assert.True(db.ContainsKey("k") || !upserted); - } finally { + } + finally + { RollbackRaceValueConverter.Hooks.Value = null; } } } -internal sealed class RollbackRaceBlockingSerializer : IDbSerializer { +internal sealed class RollbackRaceBlockingSerializer : IDbSerializer +{ private int _blockNextDeserialize; private bool _disposed; @@ -66,9 +74,11 @@ internal sealed class RollbackRaceBlockingSerializer : IDbSerializer { public void BlockNextDeserialize() => Interlocked.Exchange(ref _blockNextDeserialize, 1); - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); - if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) { + if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) + { return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -76,67 +86,85 @@ public ValueTask> DeserializeAsync(Cancella return new ValueTask>(WaitAndReturnEmptyAsync()); } - private async Task> WaitAndReturnEmptyAsync() { + private async Task> WaitAndReturnEmptyAsync() + { await AllowRollbackDeserializeToReturn.Task.ConfigureAwait(false); return new ConcurrentDictionary(); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.CompletedTask; } public void Dispose() => _disposed = true; - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { _disposed = true; return ValueTask.CompletedTask; } } -internal sealed class RollbackRaceHooks { +internal sealed class RollbackRaceHooks +{ public readonly TaskCompletionSource UpsertReachedValueSerialization = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly ManualResetEventSlim AllowUpsertToProceed = new(false); } [JsonConverter(typeof(RollbackRaceValueConverter))] -internal sealed class RollbackRaceValue { +internal sealed class RollbackRaceValue +{ public int X { get; set; } } -internal sealed class RollbackRaceValueConverter : JsonConverter { +internal sealed class RollbackRaceValueConverter : JsonConverter +{ public static readonly AsyncLocal Hooks = new(); - public override RollbackRaceValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType != JsonTokenType.StartObject) { + public override RollbackRaceValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { throw new JsonException("Expected StartObject."); } int x = 0; - while (reader.Read()) { - if (reader.TokenType == JsonTokenType.EndObject) { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { return new RollbackRaceValue { X = x }; } - if (reader.TokenType != JsonTokenType.PropertyName) { + if (reader.TokenType != JsonTokenType.PropertyName) + { throw new JsonException("Expected PropertyName."); } string propertyName = reader.GetString() ?? string.Empty; - if (!reader.Read()) { + if (!reader.Read()) + { throw new JsonException("Unexpected end of JSON."); } - if (propertyName == "x") { + if (propertyName == "x") + { x = reader.GetInt32(); - } else { + } + else + { reader.Skip(); } } throw new JsonException("Unexpected end of JSON."); } - public override void Write(Utf8JsonWriter writer, RollbackRaceValue value, JsonSerializerOptions options) { + public override void Write(Utf8JsonWriter writer, RollbackRaceValue value, JsonSerializerOptions options) + { RollbackRaceHooks? hooks = Hooks.Value; - if (hooks is not null) { + if (hooks is not null) + { hooks.UpsertReachedValueSerialization.TrySetResult(); - if (!hooks.AllowUpsertToProceed.Wait(TimeSpan.FromSeconds(5))) { + if (!hooks.AllowUpsertToProceed.Wait(TimeSpan.FromSeconds(5))) + { throw new TimeoutException("Timed out waiting for test to allow value serialization to proceed."); } } diff --git a/tests/ArrowDbCore.Tests.Unit/Serialization.cs b/tests/ArrowDbCore.Tests.Unit/Serialization.cs index e9962b1..886ec99 100644 --- a/tests/ArrowDbCore.Tests.Unit/Serialization.cs +++ b/tests/ArrowDbCore.Tests.Unit/Serialization.cs @@ -4,9 +4,11 @@ namespace ArrowDbCore.Tests.Unit; -public class Serialization { +public class Serialization +{ [Fact] - public async Task Serialize_Resets_Changes() { + public async Task Serialize_Resets_Changes() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -20,7 +22,8 @@ public async Task Serialize_Resets_Changes() { } [Fact] - public async Task Rollback_Resets_Changes() { + public async Task Rollback_Resets_Changes() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -34,9 +37,11 @@ public async Task Rollback_Resets_Changes() { } [Fact] - public async Task Serialize_Using_Event_Resets_Changes() { + public async Task Serialize_Using_Event_Resets_Changes() + { var db = await ArrowDb.CreateInMemory(); - db.OnChange += async (sender, _) => { + db.OnChange += async (sender, _) => + { await ((ArrowDb)sender!).SerializeAsync(); }; db.Upsert("1", 1, JContext.Default.Int32); @@ -46,10 +51,12 @@ public async Task Serialize_Using_Event_Resets_Changes() { } [Fact] - public async Task DeferredSerializationScope_SerializeAsync_After_DisposeAsync() { + public async Task DeferredSerializationScope_SerializeAsync_After_DisposeAsync() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); - await using (_ = db.BeginTransaction()) { + await using (_ = db.BeginTransaction()) + { db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); Assert.Equal(1, db.Count); @@ -61,10 +68,12 @@ public async Task DeferredSerializationScope_SerializeAsync_After_DisposeAsync() } [Fact] - public async Task DeferredSerializationScope_Serialize_After_Dispose() { + public async Task DeferredSerializationScope_Serialize_After_Dispose() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); - using (_ = db.BeginTransaction()) { + using (_ = db.BeginTransaction()) + { db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); Assert.Equal(1, db.Count); @@ -75,10 +84,12 @@ public async Task DeferredSerializationScope_Serialize_After_Dispose() { Assert.Equal(0, db.PendingChanges); } - private static async Task File_Serializes_And_Deserializes_As_Expected(string path, Func> factory) { + private static async Task File_Serializes_And_Deserializes_As_Expected(string path, Func> factory) + { ArrowDb? db = null; ArrowDb? db2 = null; - try { + try + { db = await factory(); db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); @@ -88,12 +99,16 @@ private static async Task File_Serializes_And_Deserializes_As_Expected(string pa FileBackedTestHelpers.ReleaseOwnership(db); db2 = await factory(); Assert.Equal(db2.Source, db.Source); - } finally { - if (db2 is not null) { + } + finally + { + if (db2 is not null) + { FileBackedTestHelpers.ReleaseOwnership(db2); } - if (db is not null) { + if (db is not null) + { FileBackedTestHelpers.ReleaseOwnership(db); } @@ -102,13 +117,15 @@ private static async Task File_Serializes_And_Deserializes_As_Expected(string pa } [Fact] - public async Task FileSerializer_Serializes_And_Deserializes_As_Expected() { + public async Task FileSerializer_Serializes_And_Deserializes_As_Expected() + { var path = Path.GetTempFileName(); await File_Serializes_And_Deserializes_As_Expected(path, () => ArrowDb.CreateFromFile(path)); } [Fact] - public async Task AesFileSerializer_Serializes_And_Deserializes_As_Expected() { + public async Task AesFileSerializer_Serializes_And_Deserializes_As_Expected() + { var path = Path.GetTempFileName(); using var aes = Aes.Create(); aes.GenerateKey(); @@ -116,9 +133,11 @@ public async Task AesFileSerializer_Serializes_And_Deserializes_As_Expected() { await File_Serializes_And_Deserializes_As_Expected(path, () => ArrowDb.CreateFromFileWithAes(path, aes)); } - private static async Task File_Serializes_And_Rollback_As_Expected(string path, Func> factory) { + private static async Task File_Serializes_And_Rollback_As_Expected(string path, Func> factory) + { ArrowDb? db = null; - try { + try + { db = await factory(); db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); @@ -138,8 +157,11 @@ private static async Task File_Serializes_And_Rollback_As_Expected(string path, Assert.True(db.ContainsKey("1")); Assert.True(db.TryGetValue("1", JContext.Default.Int32, out var value)); Assert.Equal(1, value); - } finally { - if (db is not null) { + } + finally + { + if (db is not null) + { FileBackedTestHelpers.ReleaseOwnership(db); } @@ -148,13 +170,15 @@ private static async Task File_Serializes_And_Rollback_As_Expected(string path, } [Fact] - public async Task FileSerializer_Serializes_And_Rollback_As_Expected() { + public async Task FileSerializer_Serializes_And_Rollback_As_Expected() + { var path = Path.GetTempFileName(); await File_Serializes_And_Rollback_As_Expected(path, () => ArrowDb.CreateFromFile(path)); } [Fact] - public async Task AesFileSerializer_Serializes_And_Rollback_As_Expected() { + public async Task AesFileSerializer_Serializes_And_Rollback_As_Expected() + { var path = Path.GetTempFileName(); using var aes = Aes.Create(); aes.GenerateKey(); diff --git a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs index bc51ee6..c179081 100644 --- a/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs +++ b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs @@ -4,17 +4,22 @@ namespace ArrowDbCore.Tests.Unit; -public class SerializationPendingChanges { +public class SerializationPendingChanges +{ [Fact] - public async Task SerializeAsync_WhenChangeHappensDuringSerialization_DoesNotClearPendingChanges() { + public async Task SerializeAsync_WhenChangeHappensDuringSerialization_DoesNotClearPendingChanges() + { var serializer = new BlockingSerializer(); var db = await ArrowDb.CreateCustom(serializer); var secondUpsertCommitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int upsertEvents = 0; - db.OnChange += (_, args) => { - if (args.ChangeType == ArrowDbChangeType.Upsert) { - if (Interlocked.Increment(ref upsertEvents) == 2) { + db.OnChange += (_, args) => + { + if (args.ChangeType == ArrowDbChangeType.Upsert) + { + if (Interlocked.Increment(ref upsertEvents) == 2) + { secondUpsertCommitted.TrySetResult(); } } @@ -26,7 +31,8 @@ public async Task SerializeAsync_WhenChangeHappensDuringSerialization_DoesNotCle var hooks = new PendingChangesDuringSerializeHooks(); PendingChangesDuringSerializeValueConverter.Hooks.Value = hooks; - try { + try + { Task upsertTask = Task.Run(() => db.Upsert("k", new PendingChangesDuringSerializeValue { X = 1 }, PendingChangesDuringSerializeJsonContext.Default.PendingChangesDuringSerializeValue)); await hooks.UpsertReachedValueSerialization.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -44,12 +50,15 @@ public async Task SerializeAsync_WhenChangeHappensDuringSerialization_DoesNotCle Assert.True(upserted); Assert.True(db.PendingChanges > 0); - } finally { + } + finally + { PendingChangesDuringSerializeValueConverter.Hooks.Value = null; } } - private sealed class BlockingSerializer : IDbSerializer { + private sealed class BlockingSerializer : IDbSerializer + { private bool _disposed; public readonly TaskCompletionSource SerializeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -57,12 +66,14 @@ private sealed class BlockingSerializer : IDbSerializer { public bool IsDisposed => _disposed; - public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.FromResult(new ConcurrentDictionary()); } - public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) { + public ValueTask SerializeAsync(ConcurrentDictionary data, CancellationToken cancellationToken = default) + { ObjectDisposedException.ThrowIf(_disposed, this); SerializeStarted.TrySetResult(); return new ValueTask(AllowSerializeToFinish.Task); @@ -70,7 +81,8 @@ public ValueTask SerializeAsync(ConcurrentDictionary data, Cance public void Dispose() => _disposed = true; - public ValueTask DisposeAsync() { + public ValueTask DisposeAsync() + { _disposed = true; return ValueTask.CompletedTask; } @@ -80,49 +92,64 @@ public ValueTask DisposeAsync() { // These types are intentionally top-level so System.Text.Json source generation runs correctly. -internal sealed class PendingChangesDuringSerializeHooks { +internal sealed class PendingChangesDuringSerializeHooks +{ public readonly TaskCompletionSource UpsertReachedValueSerialization = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly ManualResetEventSlim AllowUpsertToProceed = new(false); } [JsonConverter(typeof(PendingChangesDuringSerializeValueConverter))] -internal sealed class PendingChangesDuringSerializeValue { +internal sealed class PendingChangesDuringSerializeValue +{ public int X { get; set; } } -internal sealed class PendingChangesDuringSerializeValueConverter : JsonConverter { +internal sealed class PendingChangesDuringSerializeValueConverter : JsonConverter +{ public static readonly AsyncLocal Hooks = new(); - public override PendingChangesDuringSerializeValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType != JsonTokenType.StartObject) { + public override PendingChangesDuringSerializeValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { throw new JsonException("Expected StartObject."); } int x = 0; - while (reader.Read()) { - if (reader.TokenType == JsonTokenType.EndObject) { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { return new PendingChangesDuringSerializeValue { X = x }; } - if (reader.TokenType != JsonTokenType.PropertyName) { + if (reader.TokenType != JsonTokenType.PropertyName) + { throw new JsonException("Expected PropertyName."); } string propertyName = reader.GetString() ?? string.Empty; - if (!reader.Read()) { + if (!reader.Read()) + { throw new JsonException("Unexpected end of JSON."); } - if (propertyName == "x") { + if (propertyName == "x") + { x = reader.GetInt32(); - } else { + } + else + { reader.Skip(); } } throw new JsonException("Unexpected end of JSON."); } - public override void Write(Utf8JsonWriter writer, PendingChangesDuringSerializeValue value, JsonSerializerOptions options) { + public override void Write(Utf8JsonWriter writer, PendingChangesDuringSerializeValue value, JsonSerializerOptions options) + { PendingChangesDuringSerializeHooks? hooks = Hooks.Value; - if (hooks is not null) { + if (hooks is not null) + { hooks.UpsertReachedValueSerialization.TrySetResult(); - if (!hooks.AllowUpsertToProceed.Wait(TimeSpan.FromSeconds(5))) { + if (!hooks.AllowUpsertToProceed.Wait(TimeSpan.FromSeconds(5))) + { throw new TimeoutException("Timed out waiting for test to allow value serialization to proceed."); } } diff --git a/tests/ArrowDbCore.Tests.Unit/TrackingVariables.cs b/tests/ArrowDbCore.Tests.Unit/TrackingVariables.cs index beeaca9..c465147 100644 --- a/tests/ArrowDbCore.Tests.Unit/TrackingVariables.cs +++ b/tests/ArrowDbCore.Tests.Unit/TrackingVariables.cs @@ -2,44 +2,53 @@ namespace ArrowDbCore.Tests.Unit; -public class TrackingVariables { +public class TrackingVariables +{ [Fact] - public async Task Changes_Match_Additions() { + public async Task Changes_Match_Additions() + { const int count = 10; var db = await ArrowDb.CreateInMemory(); - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { db.Upsert(i.ToString(), i, JContext.Default.Int32); } Assert.Equal(count, db.PendingChanges); } [Fact] - public async Task Changes_Match_Removals() { + public async Task Changes_Match_Removals() + { const int count = 10; var db = await ArrowDb.CreateInMemory(); - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { db.Upsert(i.ToString(), i, JContext.Default.Int32); } await db.SerializeAsync(); // resets pending changes Assert.Equal(0, db.PendingChanges); // verify reset - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { db.TryRemove(i.ToString()); } Assert.Equal(count, db.PendingChanges); } [Fact] - public async Task Changes_Match_Updates() { + public async Task Changes_Match_Updates() + { const int count = 10; var db = await ArrowDb.CreateInMemory(); - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { db.Upsert(i.ToString(), i, JContext.Default.Int32); } await db.SerializeAsync(); // resets pending changes Assert.Equal(0, db.PendingChanges); // verify reset - for (var i = 0; i < count; i++) { + for (var i = 0; i < count; i++) + { db.Upsert(i.ToString(), i + 1, JContext.Default.Int32); } Assert.Equal(count, db.PendingChanges); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Transactions.cs b/tests/ArrowDbCore.Tests.Unit/Transactions.cs index b74bf45..349e6ea 100644 --- a/tests/ArrowDbCore.Tests.Unit/Transactions.cs +++ b/tests/ArrowDbCore.Tests.Unit/Transactions.cs @@ -4,26 +4,31 @@ namespace ArrowDbCore.Tests.Unit; -public class Transactions { +public class Transactions +{ [Theory] [InlineData(true)] [InlineData(false)] - public async Task NestedTransactionScope_SerializesOnce(bool useAes) { + public async Task NestedTransactionScope_SerializesOnce(bool useAes) + { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); ArrowDb? db = null; ArrowDb? db3 = null; - try { + try + { db = await CreateDb(path, useAes, aes); var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; // Act - await using (var scope1 = db.BeginTransaction()) { + await using (var scope1 = db.BeginTransaction()) + { db.Upsert("key1", person, JContext.Default.Person); Assert.Equal(1, db.PendingChanges); - await using (var scope2 = db.BeginTransaction()) { + await using (var scope2 = db.BeginTransaction()) + { db.Upsert("key2", person, JContext.Default.Person); Assert.Equal(2, db.PendingChanges); @@ -38,12 +43,16 @@ public async Task NestedTransactionScope_SerializesOnce(bool useAes) { db3 = await CreateDb(path, useAes, aes); Assert.Equal(2, db3.Count); Assert.Equal(0, db3.PendingChanges); - } finally { - if (db3 is not null) { + } + finally + { + if (db3 is not null) + { FileBackedTestHelpers.ReleaseOwnership(db3); } - if (db is not null) { + if (db is not null) + { FileBackedTestHelpers.ReleaseOwnership(db); } @@ -54,12 +63,14 @@ public async Task NestedTransactionScope_SerializesOnce(bool useAes) { [Theory] [InlineData(true)] [InlineData(false)] - public async Task RollbackAsync_RevertsChanges(bool useAes) { + public async Task RollbackAsync_RevertsChanges(bool useAes) + { // Arrange var path = Path.GetTempFileName(); using var aes = Aes.Create(); ArrowDb? db = null; - try { + try + { db = await CreateDb(path, useAes, aes); var person = new Person { Name = "John", Age = 42, BirthDate = DateTime.UtcNow, IsMarried = false }; @@ -80,8 +91,11 @@ public async Task RollbackAsync_RevertsChanges(bool useAes) { Assert.Equal(0, db.PendingChanges); Assert.True(db.ContainsKey("key1")); Assert.False(db.ContainsKey("key2")); - } finally { - if (db is not null) { + } + finally + { + if (db is not null) + { FileBackedTestHelpers.ReleaseOwnership(db); } @@ -89,8 +103,10 @@ public async Task RollbackAsync_RevertsChanges(bool useAes) { } } - private async Task CreateDb(string path, bool useAes, Aes? aes = null) { - if (useAes) { + private async Task CreateDb(string path, bool useAes, Aes? aes = null) + { + if (useAes) + { return await ArrowDb.CreateFromFileWithAes(path, aes!); } diff --git a/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs b/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs index d87aed2..5d0590b 100644 --- a/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs +++ b/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs @@ -2,9 +2,11 @@ namespace ArrowDbCore.Tests.Unit; -public class Upserts_Spans { +public class Upserts_Spans +{ [Fact] - public async Task Upsert_Span_When_Not_Found_Inserts() { + public async Task Upsert_Span_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -14,7 +16,8 @@ public async Task Upsert_Span_When_Not_Found_Inserts() { } [Fact] - public async Task Upsert_Span_When_Found_Overwrites() { + public async Task Upsert_Span_When_Found_Overwrites() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -27,7 +30,8 @@ public async Task Upsert_Span_When_Found_Overwrites() { } [Fact] - public async Task Conditional_Update_When_Not_Found_Inserts() { + public async Task Conditional_Update_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -35,7 +39,8 @@ public async Task Conditional_Update_When_Not_Found_Inserts() { } [Fact] - public async Task Conditional_Update_When_Found_And_Valid_Updates() { + public async Task Conditional_Update_When_Found_And_Valid_Updates() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -46,7 +51,8 @@ public async Task Conditional_Update_When_Found_And_Valid_Updates() { } [Fact] - public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() { + public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -57,7 +63,8 @@ public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() { } [Fact] - public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvaluatesCondition() { + public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvaluatesCondition() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -68,7 +75,8 @@ public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvalua } [Fact] - public async Task Conditional_Update_TArg_When_Not_Found_Inserts() { + public async Task Conditional_Update_TArg_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -77,7 +85,8 @@ public async Task Conditional_Update_TArg_When_Not_Found_Inserts() { } [Fact] - public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() { + public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -89,7 +98,8 @@ public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() { } [Fact] - public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() { + public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); ReadOnlySpan key = "1"; @@ -99,4 +109,4 @@ public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() Assert.True(db.TryGetValue(key, JContext.Default.Int32, out var value)); Assert.Equal(1, value); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Upserts.cs b/tests/ArrowDbCore.Tests.Unit/Upserts.cs index 1cd87b1..756885e 100644 --- a/tests/ArrowDbCore.Tests.Unit/Upserts.cs +++ b/tests/ArrowDbCore.Tests.Unit/Upserts.cs @@ -2,9 +2,11 @@ namespace ArrowDbCore.Tests.Unit; -public class Upserts { +public class Upserts +{ [Fact] - public async Task Upsert_When_Not_Found_Inserts() { + public async Task Upsert_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -13,7 +15,8 @@ public async Task Upsert_When_Not_Found_Inserts() { } [Fact] - public async Task Upsert_When_Found_Overwrites() { + public async Task Upsert_When_Found_Overwrites() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -25,7 +28,8 @@ public async Task Upsert_When_Found_Overwrites() { } [Fact] - public async Task Upsert_NullValue_IsDisallowed() { + public async Task Upsert_NullValue_IsDisallowed() + { // Arrange var db = await ArrowDb.CreateInMemory(); @@ -39,14 +43,16 @@ public async Task Upsert_NullValue_IsDisallowed() { } [Fact] - public async Task Conditional_Update_When_Not_Found_Inserts() { + public async Task Conditional_Update_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); Assert.True(db.Upsert("1", 1, JContext.Default.Int32, reference => reference == 3)); } [Fact] - public async Task Conditional_Update_When_Found_And_Valid_Updates() { + public async Task Conditional_Update_When_Found_And_Valid_Updates() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -56,7 +62,8 @@ public async Task Conditional_Update_When_Found_And_Valid_Updates() { } [Fact] - public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() { + public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -66,7 +73,8 @@ public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() { } [Fact] - public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvaluatesCondition() { + public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvaluatesCondition() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 0, JContext.Default.Int32); @@ -76,7 +84,8 @@ public async Task Conditional_Update_When_Found_Default_ForValueType_StillEvalua } [Fact] - public async Task Conditional_Update_TArg_When_Not_Found_Inserts() { + public async Task Conditional_Update_TArg_When_Not_Found_Inserts() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); // using a static delegate ensures that closure cannot be allocated @@ -84,7 +93,8 @@ public async Task Conditional_Update_TArg_When_Not_Found_Inserts() { } [Fact] - public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() { + public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -95,7 +105,8 @@ public async Task Conditional_Update_TArg_When_Found_And_Valid_Updates() { } [Fact] - public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() { + public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() + { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); db.Upsert("1", 1, JContext.Default.Int32); @@ -104,4 +115,4 @@ public async Task Conditional_Update_TArg_When_Found_And_Invalid_Returns_False() Assert.True(db.TryGetValue("1", JContext.Default.Int32, out var value)); Assert.Equal(1, value); } -} \ No newline at end of file +} From eb2229cba0f0bc39b905c760f2813c42baded900 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 14 Apr 2026 17:00:23 +0300 Subject: [PATCH 7/7] chore: update benchmarks and readmes --- README.md | 2 +- ...DbCore.Benchmarks.VersionComparison.csproj | 15 +++- .../RandomOperationsBenchmark.cs | 2 +- .../SerializationToFileBenchmark.cs | 8 +- .../VersionComparisonConfig.cs | 82 ++++++++++++++----- ...e.Benchmarks.RandomOperationsBenchmarks.md | 14 ++-- ...enchmarks.SerializationToFileBenchmarks.md | 18 ++-- .../RandomOperationsBenchmark.cs | 2 +- .../SerializationToFileBenchmark.cs | 7 +- src/ArrowDbCore/Readme.Nuget.md | 2 +- 10 files changed, 104 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 3c767bf..49fd823 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ArrowDb is a fast, lightweight, and type-safe key-value database designed for .N * Super-Lightweight (dll size is ~19KB - approximately 9X smaller than [UltraLiteDb](https://github.com/rejemy/UltraLiteDB)) * Ultra-Fast (1,000,000 random operations / ~98ms on M2 MacBook Pro) -* Minimal-Allocation (constant ~520 bytes for serialization of any db size) +* Aggressively Optimized Low-Allocation Persistence * Thread-Safe and Concurrent * ACID compliant on transaction level * Type-Safe (no reflection - compile-time enforced via source-generated `JsonSerializerContext`) diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj index 091528b..58ecb52 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj @@ -5,21 +5,30 @@ net10.0 enable enable + false + 1.6.0 + - - - \ No newline at end of file + + + + + + + + + diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs index 97d63e6..cbfc3e4 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs @@ -33,7 +33,7 @@ public void Setup() Trace.Assert(_items.Length == Count); - _db = ArrowDb.CreateInMemory().GetAwaiter().GetResult(); + _db = ArrowDb.CreateInMemory().AsTask().GetAwaiter().GetResult(); } [Benchmark] diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs index e193a9e..9ee4b70 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs @@ -16,6 +16,7 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; public class SerializationToFileBenchmarks { private ArrowDb _db = default!; + private string _dbPath = default!; [Params(100, 10_000, 1_000_000)] public int Size { get; set; } @@ -28,7 +29,8 @@ public void Setup() Random = new Randomizer(1337) }; - _db = ArrowDb.CreateFromFile("test.db").GetAwaiter().GetResult(); + _dbPath = $"test-{Guid.NewGuid():N}.db"; + _db = ArrowDb.CreateFromFile(_dbPath).AsTask().GetAwaiter().GetResult(); Span buffer = stackalloc char[64]; @@ -45,9 +47,9 @@ public void Setup() [IterationCleanup] public void Cleanup() { - if (File.Exists("test.db")) + if (File.Exists(_dbPath)) { - File.Delete("test.db"); + File.Delete(_dbPath); } } diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs index 145a1fb..6b797f9 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs @@ -3,11 +3,16 @@ using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Reports; +using System.Xml.Linq; + +using GitRepository = LibGit2Sharp.Repository; + using NuGet.Common; using NuGet.Protocol; using NuGet.Protocol.Core.Types; +using NuGetRepository = NuGet.Protocol.Core.Types.Repository; using NuGet.Versioning; @@ -16,32 +21,75 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; public class VersionComparisonConfig : ManualConfig { public const string PackageId = "ArrowDb"; + private const string UseLocalArrowDbProperty = "/p:UseLocalArrowDb=true"; public VersionComparisonConfig() { - var (stable, latest) = GetLatestVersionsAsync(PackageId) + var localVersion = GetLocalPackageVersion(); + var stable = GetLatestStableVersionBelowAsync(PackageId, localVersion) .GetAwaiter() .GetResult(); SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend); + HideColumns("Arguments"); AddJob(Job.MediumRun .WithBaseline(true) - .WithNuGet(PackageId, stable.ToNormalizedString()) - .WithId($"Stable-{stable.ToNormalizedString()}")); + .WithMsBuildArguments(UseLocalArrowDbProperty) + .WithId("Local")); AddJob(Job.MediumRun - .WithNuGet(PackageId, latest.ToNormalizedString()) - .WithId($"Latest-{latest.ToNormalizedString()}")); + .WithMsBuildArguments($"/p:ArrowDbPackageVersion={stable.ToNormalizedString()}") + .WithId($"Stable-{stable.ToNormalizedString()}")); + } + + private static NuGetVersion GetLocalPackageVersion() + { + string projectFilePath = Path.Combine(GetRepositoryRoot(), "src", "ArrowDbCore", "ArrowDbCore.csproj"); + XDocument project = XDocument.Load(projectFilePath); + string? version = project.Root? + .Elements("PropertyGroup") + .Elements("Version") + .Select(element => element.Value?.Trim()) + .FirstOrDefault(value => !string.IsNullOrEmpty(value)); + + return version is null + ? throw new InvalidOperationException($"Could not determine the local package version from '{projectFilePath}'.") + : NuGetVersion.Parse(version); + } + + private static string GetRepositoryRoot() + { + string[] startPaths = + [ + Environment.CurrentDirectory, + AppContext.BaseDirectory, + ]; + + foreach (string startPath in startPaths) + { + string? repositoryPath = GitRepository.Discover(startPath); + if (repositoryPath is null) + { + continue; + } + + using GitRepository repository = new(repositoryPath); + string workingDirectory = repository.Info.WorkingDirectory; + if (!string.IsNullOrEmpty(workingDirectory)) + { + return workingDirectory; + } + } + + throw new InvalidOperationException($"Could not locate the git repository root starting from '{Environment.CurrentDirectory}' or '{AppContext.BaseDirectory}'."); } - private static async Task<(NuGetVersion stable, NuGetVersion latest)> GetLatestVersionsAsync(string packageId) + private static async Task GetLatestStableVersionBelowAsync(string packageId, NuGetVersion localVersion) { - // Point at the official NuGet v3 API - var source = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); + var source = NuGetRepository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); var metaResource = await source.GetResourceAsync(); - // Fetch all versions (incl. prerelease) and filter out unlisted packages var allMetadata = await metaResource.GetMetadataAsync( packageId, includePrerelease: true, @@ -50,20 +98,12 @@ public VersionComparisonConfig() log: NullLogger.Instance, token: CancellationToken.None); - // Extract distinct versions - var versions = allMetadata + var stable = allMetadata .Select(meta => meta.Identity.Version) .Distinct() - .OrderBy(v => v) // ascending - .ToList(); - - // Highest overall version (could be prerelease) - var latest = versions.Last(); - - // Highest *stable* (no prerelease); if none, fall back to latest - var stableVersions = versions.Where(v => !v.IsPrerelease).ToList(); - var stable = stableVersions.Any() ? stableVersions.Last() : latest; + .Where(version => !version.IsPrerelease && version < localVersion) + .Max(); - return (stable, latest); + return stable ?? throw new InvalidOperationException($"No stable {packageId} package lower than local version '{localVersion}' was found on NuGet."); } } diff --git a/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks.md b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks.md index 109ba76..5ef8820 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks.md +++ b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks.md @@ -1,10 +1,10 @@ ``` -BenchmarkDotNet v0.15.8, macOS Tahoe 26.1 (25B78) [Darwin 25.1.0] +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores -.NET SDK 10.0.101 - [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a - MediumRun : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + MediumRun : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a Job=MediumRun InvocationCount=1 IterationCount=15 LaunchCount=2 UnrollFactor=1 WarmupCount=10 @@ -12,6 +12,6 @@ LaunchCount=2 UnrollFactor=1 WarmupCount=10 ``` | Method | Count | Mean | Error | StdDev | Rank | Allocated | |----------------- |-------- |-------------:|-------------:|-------------:|-----:|------------:| -| **RandomOperations** | **100** | **41.73 μs** | **3.662 μs** | **5.252 μs** | **1** | **15.84 KB** | -| **RandomOperations** | **10000** | **1,349.40 μs** | **65.665 μs** | **89.883 μs** | **2** | **701.72 KB** | -| **RandomOperations** | **1000000** | **98,975.55 μs** | **1,918.205 μs** | **2,811.681 μs** | **3** | **53612.05 KB** | +| **RandomOperations** | **100** | **38.94 μs** | **3.074 μs** | **4.409 μs** | **1** | **15.45 KB** | +| **RandomOperations** | **10000** | **1,354.82 μs** | **89.848 μs** | **131.698 μs** | **2** | **691.92 KB** | +| **RandomOperations** | **1000000** | **99,480.14 μs** | **2,425.217 μs** | **3,629.951 μs** | **3** | **53652.41 KB** | diff --git a/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.SerializationToFileBenchmarks.md b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.SerializationToFileBenchmarks.md index 9f7cd2a..10b741c 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.SerializationToFileBenchmarks.md +++ b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/ArrowDbCore.Benchmarks.SerializationToFileBenchmarks.md @@ -1,17 +1,17 @@ ``` -BenchmarkDotNet v0.15.8, macOS Tahoe 26.1 (25B78) [Darwin 25.1.0] +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores -.NET SDK 10.0.101 - [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a - MediumRun : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + MediumRun : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a -Job=MediumRun InvocationCount=1 IterationCount=15 -LaunchCount=2 UnrollFactor=1 WarmupCount=10 +Job=MediumRun InvocationCount=1 IterationCount=15 +LaunchCount=2 UnrollFactor=1 WarmupCount=10 ``` | Method | Size | Mean | Error | StdDev | Rank | Allocated | |--------------- |-------- |-------------:|------------:|------------:|-----:|----------:| -| **SerializeAsync** | **100** | **207.4 μs** | **17.56 μs** | **25.73 μs** | **1** | **520 B** | -| **SerializeAsync** | **10000** | **2,409.7 μs** | **333.98 μs** | **499.89 μs** | **2** | **520 B** | -| **SerializeAsync** | **1000000** | **144,343.7 μs** | **1,514.16 μs** | **2,219.44 μs** | **3** | **520 B** | +| **SerializeAsync** | **100** | **214.0 μs** | **20.65 μs** | **30.90 μs** | **1** | **2.22 KB** | +| **SerializeAsync** | **10000** | **2,423.6 μs** | **141.85 μs** | **203.43 μs** | **2** | **9.48 KB** | +| **SerializeAsync** | **1000000** | **160,746.6 μs** | **2,265.35 μs** | **3,320.52 μs** | **3** | **760.73 KB** | diff --git a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs index b1fc582..9b639e2 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs @@ -33,7 +33,7 @@ public void Setup() Trace.Assert(_items.Length == Count); - _db = ArrowDb.CreateInMemory().GetAwaiter().GetResult(); + _db = ArrowDb.CreateInMemory().AsTask().GetAwaiter().GetResult(); } [Benchmark] diff --git a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs index e6d1690..b2f8ad2 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using ArrowDbCore.Benchmarks.Common; +using ArrowDbCore.Serializers; using BenchmarkDotNet.Attributes; @@ -15,6 +16,7 @@ namespace ArrowDbCore.Benchmarks; [MediumRunJob] public class SerializationToFileBenchmarks { + private FileSerializer? _fileSerializer; private ArrowDb _db = default!; [Params(100, 10_000, 1_000_000)] @@ -28,7 +30,8 @@ public void Setup() Random = new Randomizer(1337) }; - _db = ArrowDb.CreateFromFile("test.db").GetAwaiter().GetResult(); + _fileSerializer = new("test.db", ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray); + _db = ArrowDb.CreateCustom(_fileSerializer).AsTask().GetAwaiter().GetResult(); Span buffer = stackalloc char[64]; @@ -45,6 +48,8 @@ public void Setup() [IterationCleanup] public void Cleanup() { + _fileSerializer?.Dispose(); + if (File.Exists("test.db")) { File.Delete("test.db"); diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index 65e5219..3f7157c 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -4,7 +4,7 @@ A fast, lightweight, and type-safe key-value database designed for .NET. * Super-Lightweight (dll size is ~19KB - approximately 9X smaller than [UltraLiteDb](https://github.com/rejemy/UltraLiteDB)) * Ultra-Fast (1,000,000 random operations / ~98ms on M2 MacBook Pro) -* Minimal-Allocation (constant ~520 bytes for serialization of any db size) +* Aggressively Optimized Low-Allocation Persistence * Thread-Safe and Concurrent * ACID compliant on transaction level * Type-Safe (no reflection - compile-time enforced via source-generated `JsonSerializerContext`)