diff --git a/.editorconfig b/.editorconfig index 802c1da..30024f0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -83,6 +83,9 @@ dotnet_diagnostic.IDE0056.severity = none # simplify index operator dotnet_diagnostic.IDE0057.severity = none # use range operator 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() + # namespace declaration csharp_style_namespace_declarations = file_scoped:warning diff --git a/.github/workflows/unit-tests-matrix.yaml b/.github/workflows/unit-tests-matrix.yaml index 46b89ff..37b7cc6 100644 --- a/.github/workflows/unit-tests-matrix.yaml +++ b/.github/workflows/unit-tests-matrix.yaml @@ -22,4 +22,25 @@ jobs: with: platform: ubuntu-latest dotnet-version: 9.0.x - test-project-path: tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj \ No newline at end of file + test-project-path: tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj + + aot-trimming: + env: + PROJECT: tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 9.0.x + + - name: Install dependencies + run: dotnet restore ${{ env.PROJECT }} + + - name: Build As Release + run: dotnet build ${{ env.PROJECT }} --configuration Release \ No newline at end of file diff --git a/.gitignore b/.gitignore index e04ec1c..07a1f13 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ bld/ # Visual Studio 2017 auto generated files Generated\ Files/ +benchmarks/*/*.Artifacts.Results/* # MSTest test Results [Tt]est[Rr]esult*/ @@ -482,3 +483,4 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp +benchmarks/ArrowDbCore.Benchmarks.VersionComparison/BenchmarkDotNet.Artifacts/* diff --git a/ArrowDbCore.slnx b/ArrowDbCore.slnx index afa3d27..87413d9 100644 --- a/ArrowDbCore.slnx +++ b/ArrowDbCore.slnx @@ -1,5 +1,7 @@ + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a9713..65885cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,38 @@ # Changelog (Sorted by Date in Descending Order) +## 1.5.0.0 + +- File based serializers `FileSerializer` and `AesFileSerializer` now use a new base class implementation and have gained the ability to `journal` (maintain durability through crashes and other `IOException`, and ensure successful atomic write or complete rejection of changes), and cross-process isolation, preventing race condition that could be caused when multiple processes try to access the same `ArrowDb` file. + - If you had a class implementing `FileSerializer` this change may or may not break functionality and you should run tests to ensure everything still works as expected (With that said, my tests were not broken and did not require any adjusting). +- Thread-safe counters types were changed from `int` to `long`, this includes `PendingChanges` and `RunningInstances`. +- `ArrowDbTransactionScope` was updated to allow nested transactions, and prevent corruption that can be caused by multiple transactions running concurrently on the same `ArrowDb` instance. In addition, it was made `public` and also implements the regular `IDisposable` interface to allow usage in synchronous context. However, it is still your responsibility to ensure the contexts match. +- `Upsert` and all its overloads will now reject (return `false`) whenever the value to be upserted is a `null` reference type. This is to enforce a no `null` policy that will simplify development by eliminating `null` checks on retrieved values. + +### Perf Improvements + +- Random queued (Non serialized) operations are up to 20% faster due to improvement in cross-threaded state management. +- Serialization allocates up to 250% less memory 🔥 across all benchmarks. + ## 1.4.0.0 -* `GetOrAddAsync` and `Upsert` (which has the `updateCondition` argument) now both have overloads that accept a `TArg` parameter as well as a modified factory function that can use it, in order to avoid closures. +- `GetOrAddAsync` and `Upsert` (which has the `updateCondition` argument) now both have overloads that accept a `TArg` parameter as well as a modified factory function that can use it, in order to avoid closures. ## 1.3.0.0 -* Added overloads of `Upsert` that accept a `string` key, while the `ReadOnlySpan` overloads are amazing in specific cases where its use can prevent a string allocation for the lookup, in other places where the input was originally a `string` that was implicitly converted to a `ReadOnlySpan` for the parameter, this would've caused a copy to be allocated for the key when the key did not exist. The same scenario will now use the `string` overload and use it for the key directly, avoiding the intermediate copy. -* Added a `ValueTask` based `GetOrAddAsync` method, commonly used in caching scenarios. +- Added overloads of `Upsert` that accept a `string` key, while the `ReadOnlySpan` overloads are amazing in specific cases where its use can prevent a string allocation for the lookup, in other places where the input was originally a `string` that was implicitly converted to a `ReadOnlySpan` for the parameter, this would've caused a copy to be allocated for the key when the key did not exist. The same scenario will now use the `string` overload and use it for the key directly, avoiding the intermediate copy. +- Added a `ValueTask` based `GetOrAddAsync` method, commonly used in caching scenarios. ## 1.2.0.0 -* An overload to `Upsert` without `updateCondition` was added and would now act as default path in case `updateCondition` wasn't specified, this should further optimize such cases by removing condition checks and another reference from the stack during runtime. -* Internal methods which are rather small and frequently invoked will now be prioritized for inlining by JIT, this should slightly improve perf, especially in NativeAot. -* Added a new factory initializer `CreateFromFileWithAes` that received an `Aes` instance as parameter. It will then use it to encrypt and decrypt the output and input during serialization and deserialization respectively. +- An overload to `Upsert` without `updateCondition` was added and would now act as default path in case `updateCondition` wasn't specified, this should further optimize such cases by removing condition checks and another reference from the stack during runtime. +- Internal methods which are rather small and frequently invoked will now be prioritized for inlining by JIT, this should slightly improve perf, especially in NativeAot. +- Added a new factory initializer `CreateFromFileWithAes` that received an `Aes` instance as parameter. It will then use it to encrypt and decrypt the output and input during serialization and deserialization respectively. ## 1.1.0.0 -* Fixed issue with `FileSerializer` where serialization would write over existing file data which could create invalid tokens, causing deserialization to fail. -* Added static `ArrowDb.GenerateTypedKey` method that accepts the type of the value, specific key (identifier) and a buffer, it returns a `ReadOnlySpan` key that prefixes the type to the specific key. +- Fixed issue with `FileSerializer` where serialization would write over existing file data which could create invalid tokens, causing deserialization to fail. +- Added static `ArrowDb.GenerateTypedKey` method that accepts the type of the value, specific key (identifier) and a buffer, it returns a `ReadOnlySpan` key that prefixes the type to the specific key. ## 1.0.0.0 -* Initial Release +- Initial Release diff --git a/README.md b/README.md index 2bb2106..ca83e3d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ ArrowDb is a fast, lightweight, and type-safe key-value database designed for .N * Cross-Platform and Fully AOT-compatible * Super-Easy API near mirroring of `Dictionary` +### A Note on `null` Values + +ArrowDb enforces a "no nulls" policy by design. Attempting to `Upsert` a `null` value will be rejected and return `false`. This simplifies the developer experience by guaranteeing that if a key exists, its value is never `null`. This eliminates the need for null-checking after retrieval, leading to cleaner and more predictable application code. + +This policy does not affect value types (`structs`); their `default` values (e.g., `0` for an `int`) are considered valid. + ## Getting Started Installation is done via NuGet: `dotnet add package ArrowDbCore` @@ -83,10 +89,9 @@ await db.SerializeAsync(); For tracking some ArrowDb internals the following properties are exposed: ```csharp -int ArrowDb.RunningInstances; // Number of active ArrowDb instances (static) -int db.InstanceId; // The id of this ArrowDb instance +long ArrowDb.RunningInstances; // Number of active ArrowDb instances (static) +long db.PendingChanges; // The number of pending changes (number of changes that have not been serialized) int db.Count; // The number of entities in the ArrowDb -int db.PendingChanges; // The number of pending changes (number of changes that have not been serialized) ``` For reading the data we have the following methods: @@ -278,7 +283,7 @@ public interface IDbSerializer { } ``` -The `DeserializeAsync` method is invoked to load the db, and the `SerializeAsync` method is invoked to persist the db. +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. 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. @@ -323,7 +328,9 @@ void SomeMethod() { } // 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. +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. + +`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. ## Subscribing to Changes diff --git a/benchmarks/ArrowDbCore.Benchmarks.Common/ArrowDbCore.Benchmarks.Common.csproj b/benchmarks/ArrowDbCore.Benchmarks.Common/ArrowDbCore.Benchmarks.Common.csproj new file mode 100644 index 0000000..d9ff41a --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.Common/ArrowDbCore.Benchmarks.Common.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/benchmarks/ArrowDbCore.Benchmarks/JContext.cs b/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs similarity index 87% rename from benchmarks/ArrowDbCore.Benchmarks/JContext.cs rename to benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs index 697addd..34ac477 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/JContext.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.Common/JContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ArrowDbCore.Benchmarks; +namespace ArrowDbCore.Benchmarks.Common; [JsonSourceGenerationOptions(WriteIndented = false, NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true)] [JsonSerializable(typeof(Person))] diff --git a/benchmarks/ArrowDbCore.Benchmarks/Person.cs b/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs similarity index 92% rename from benchmarks/ArrowDbCore.Benchmarks/Person.cs rename to benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs index 09d9c68..2e4f963 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/Person.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.Common/Person.cs @@ -1,6 +1,6 @@ using Bogus; -namespace ArrowDbCore.Benchmarks; +namespace ArrowDbCore.Benchmarks.Common; public sealed class Person { public int Id { get; set; } diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj new file mode 100644 index 0000000..067aa97 --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/ArrowDbCore.Benchmarks.VersionComparison.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs new file mode 100644 index 0000000..485961f --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/Program.cs @@ -0,0 +1,5 @@ +using ArrowDbCore.Benchmarks.VersionComparison; + +using BenchmarkDotNet.Running; + +BenchmarkRunner.Run(); \ No newline at end of file diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs new file mode 100644 index 0000000..0b2a423 --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/RandomOperationsBenchmark.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using BenchmarkDotNet.Attributes; +using Bogus; +using ArrowDbCore.Benchmarks.Common; +using Person = ArrowDbCore.Benchmarks.Common.Person; + +namespace ArrowDbCore.Benchmarks.VersionComparison; + +[MemoryDiagnoser(false)] +[RankColumn] +[Config(typeof(VersionComparisonConfig))] +public class RandomOperationsBenchmarks { + private Person[] _items = []; + private ArrowDb _db = default!; + + [Params(100, 10_000, 1_000_000)] + public int Count { get; set; } + + [IterationSetup] + public void Setup() { + var faker = new Faker { + Random = new Randomizer(1337) + }; + + _items = Person.GeneratePeople(Count, faker).ToArray(); + + Trace.Assert(_items.Length == Count); + + _db = ArrowDb.CreateInMemory().GetAwaiter().GetResult(); + } + + [Benchmark] + public void RandomOperations() { + Parallel.For(0, Count, i => { + // Pick a random operation: 0 = add/update, 1 = remove + int operationType = Random.Shared.Next(0, 2); + + var item = _items[i]; + + var key = item.Name; + var jsonTypeInfo = JContext.Default.Person; + + switch (operationType) { + case 0: // Add/Update + _db.Upsert(key, item, jsonTypeInfo); + break; + case 1: // Remove + _db.TryRemove(key); + break; + } + }); + } +} diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs new file mode 100644 index 0000000..da9b149 --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using BenchmarkDotNet.Attributes; +using Bogus; +using ArrowDbCore.Benchmarks.Common; +using Person = ArrowDbCore.Benchmarks.Common.Person; + +namespace ArrowDbCore.Benchmarks.VersionComparison; + +[MemoryDiagnoser(false)] +[RankColumn] +[Config(typeof(VersionComparisonConfig))] +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 { + Random = new Randomizer(1337) + }; + + _db = ArrowDb.CreateFromFile("test.db").GetAwaiter().GetResult(); + + Span buffer = stackalloc char[64]; + + 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); + } + + Trace.Assert(_db.Count == Size); + } + + [IterationCleanup] + public void Cleanup() { + if (File.Exists("test.db")) { + File.Delete("test.db"); + } + } + + [Benchmark] + public async Task SerializeAsync() { + await _db.SerializeAsync(); + } +} diff --git a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs new file mode 100644 index 0000000..c13e532 --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs @@ -0,0 +1,67 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Reports; + +using NuGet.Common; + +using NuGet.Protocol; + +using NuGet.Protocol.Core.Types; + +using NuGet.Versioning; + +namespace ArrowDbCore.Benchmarks.VersionComparison; + +public class VersionComparisonConfig : ManualConfig { + public const string PackageId = "ArrowDb"; + + public VersionComparisonConfig() { + var (stable, latest) = GetLatestVersionsAsync(PackageId) + .GetAwaiter() + .GetResult(); + + SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend); + + AddJob(Job.MediumRun + .WithBaseline(true) + .WithNuGet(PackageId, stable.ToNormalizedString()) + .WithId($"Stable-{stable.ToNormalizedString()}")); + + AddJob(Job.MediumRun + .WithNuGet(PackageId, latest.ToNormalizedString()) + .WithId($"Latest-{latest.ToNormalizedString()}")); + } + + 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(); + + // Fetch all versions (incl. prerelease) and filter out unlisted packages + var allMetadata = await metaResource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + sourceCacheContext: new SourceCacheContext(), + log: NullLogger.Instance, + token: CancellationToken.None); + + // Extract distinct versions + var versions = 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; + + return (stable, latest); + } +} diff --git a/benchmarks/ArrowDbCore.Benchmarks/ArrowDbCore.Benchmarks.csproj b/benchmarks/ArrowDbCore.Benchmarks/ArrowDbCore.Benchmarks.csproj index 7f8f33a..f3944d8 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/ArrowDbCore.Benchmarks.csproj +++ b/benchmarks/ArrowDbCore.Benchmarks/ArrowDbCore.Benchmarks.csproj @@ -9,11 +9,12 @@ + - - + + diff --git a/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/results/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks-report-github.md b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/results/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks-report-github.md new file mode 100644 index 0000000..464b29e --- /dev/null +++ b/benchmarks/ArrowDbCore.Benchmarks/BenchmarkDotNet.Artifacts/results/ArrowDbCore.Benchmarks.RandomOperationsBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.15.2, macOS Sequoia 15.5 (24F74) [Darwin 24.5.0] +Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores +.NET SDK 9.0.303 + [Host] : .NET 9.0.7 (9.0.725.31616), Arm64 RyuJIT AdvSIMD + MediumRun : .NET 9.0.7 (9.0.725.31616), Arm64 RyuJIT AdvSIMD + +Job=MediumRun InvocationCount=1 IterationCount=15 +LaunchCount=2 UnrollFactor=1 WarmupCount=10 + +``` +| Method | Count | Mean | Error | StdDev | Rank | Allocated | +|----------------- |-------- |--------------:|-------------:|-------------:|-----:|------------:| +| **RandomOperations** | **100** | **41.75 μs** | **5.523 μs** | **8.096 μs** | **1** | **15.11 KB** | +| **RandomOperations** | **10000** | **1,779.51 μs** | **484.766 μs** | **679.575 μs** | **2** | **722.35 KB** | +| **RandomOperations** | **1000000** | **101,016.96 μs** | **1,921.740 μs** | **2,876.370 μs** | **3** | **53529.98 KB** | diff --git a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs index 0a01ffd..51837ec 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/RandomOperationsBenchmark.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; using Bogus; +using ArrowDbCore.Benchmarks.Common; +using Person = ArrowDbCore.Benchmarks.Common.Person; namespace ArrowDbCore.Benchmarks; diff --git a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs index 274b640..91872cf 100644 --- a/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks/SerializationToFileBenchmark.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; using Bogus; +using ArrowDbCore.Benchmarks.Common; +using Person = ArrowDbCore.Benchmarks.Common.Person; namespace ArrowDbCore.Benchmarks; diff --git a/benchmarks/ArrowDbCore.Benchmarks/TrentRatioConfig.cs b/benchmarks/ArrowDbCore.Benchmarks/TrentRatioConfig.cs deleted file mode 100644 index c445356..0000000 --- a/benchmarks/ArrowDbCore.Benchmarks/TrentRatioConfig.cs +++ /dev/null @@ -1,10 +0,0 @@ -using BenchmarkDotNet.Columns; -using BenchmarkDotNet.Configs; - -namespace ArrowDbCore.Benchmarks; - -public class TrentRatioConfig : ManualConfig { - public TrentRatioConfig() { - SummaryStyle = BenchmarkDotNet.Reports.SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend); - } -} \ No newline at end of file diff --git a/src/ArrowDbCore/ArrowDb.Serialization.cs b/src/ArrowDbCore/ArrowDb.Serialization.cs index 7ccb432..1eb4382 100644 --- a/src/ArrowDbCore/ArrowDb.Serialization.cs +++ b/src/ArrowDbCore/ArrowDb.Serialization.cs @@ -10,7 +10,7 @@ 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() { - if (_pendingChanges == 0) { + if (Interlocked.Read(ref _pendingChanges) == 0) { return; } try { diff --git a/src/ArrowDbCore/ArrowDb.Upsert.cs b/src/ArrowDbCore/ArrowDb.Upsert.cs index 80cbbbb..89a2e9c 100644 --- a/src/ArrowDbCore/ArrowDb.Upsert.cs +++ b/src/ArrowDbCore/ArrowDb.Upsert.cs @@ -6,27 +6,27 @@ namespace ArrowDbCore; public partial class ArrowDb { /// - /// Upsert the specified key with the specified value into the database + /// Upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type - /// True + /// True if the value was upserted, false if the provided value was null. public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo) { return UpsertCore(key, value, jsonTypeInfo, default); } /// - /// Upsert the specified key with the specified value into the database + /// Upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type - /// True + /// True if the value was upserted, false if the provided value was null. /// - /// 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 + /// 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) { return UpsertCore, TValue, ReadOnlySpanAccessor>(key, value, jsonTypeInfo, default); @@ -36,6 +36,9 @@ public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo(TKey key, TValue value, JsonTypeInfo jsonTypeInfo, TAccessor accessor) where TKey : allows ref struct where TAccessor : IDictionaryAccessor, allows ref struct { + if (value is null) { + return false; + } WaitIfSerializing(); // Block if serializing byte[] utf8Value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); accessor.Upsert(this, key, utf8Value); @@ -44,16 +47,19 @@ private bool UpsertCore(TKey key, TValue value, JsonTyp } /// - /// Tries to upsert the specified key with the specified value into the database + /// Tries to upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type /// A conditional check that determines whether this update should be performed - /// True if the value was upserted, false otherwise + /// True if the value was upserted, false otherwise. /// /// + /// The operation will be rejected if the provided is null. + /// + /// /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// /// 1. A value for the specified key exists and successfully deserialized to @@ -68,18 +74,21 @@ public bool Upsert(string key, TValue value, JsonTypeInfo jsonTy } /// - /// Tries to upsert the specified key with the specified value into the database + /// Tries to upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The type of the argument for the updateCondition function /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type /// A conditional check that determines whether this update should be performed /// An argument that could be provided to the updateCondition function to avoid a closure - /// True if the value was upserted, false otherwise + /// True if the value was upserted, false otherwise. /// /// + /// The operation will be rejected if the provided is null. + /// + /// /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// /// 1. A value for the specified key exists and successfully deserialized to @@ -94,16 +103,19 @@ public bool Upsert(string key, TValue value, JsonTypeInfo } /// - /// Tries to upsert the specified key with the specified value into the database + /// Tries to upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type /// A conditional check that determines whether this update should be performed - /// True if the value was upserted, false otherwise + /// True if the value was upserted, false otherwise. /// /// + /// The operation will be rejected if the provided is null. + /// + /// /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// /// 1. A value for the specified key exists and successfully deserialized to @@ -121,18 +133,21 @@ public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo - /// Tries to upsert the specified key with the specified value into the database + /// Tries to upsert the specified key with the specified value into the database. /// /// The type of the value to upsert /// The type of the argument for the updateCondition function /// The key at which to upsert the value - /// The value to upsert + /// The value to upsert. This cannot be null. /// The json type info for the value type /// A conditional check that determines whether this update should be performed /// An argument that could be provided to the updateCondition function to avoid a closure - /// True if the value was upserted, false otherwise + /// True if the value was upserted, false otherwise. /// /// + /// The operation will be rejected if the provided is null. + /// + /// /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// /// 1. A value for the specified key exists and successfully deserialized to diff --git a/src/ArrowDbCore/ArrowDb.cs b/src/ArrowDbCore/ArrowDb.cs index 6217234..9d09fb3 100644 --- a/src/ArrowDbCore/ArrowDb.cs +++ b/src/ArrowDbCore/ArrowDb.cs @@ -10,12 +10,12 @@ public sealed partial class ArrowDb { /// /// Returns the number of active instances /// - public static int RunningInstances => s_runningInstances; + public static long RunningInstances => Interlocked.Read(ref s_runningInstances); /// /// Tracks the number of running instances /// - private static volatile int s_runningInstances; + private static long s_runningInstances; /// /// The backing dictionary @@ -53,12 +53,17 @@ private void OnChangeInternal(ArrowDbChangeEventArgs args) { /// /// Returns the number of pending changes (number of changes that have not been serialized) /// - public int PendingChanges => _pendingChanges; + public long PendingChanges => Interlocked.Read(ref _pendingChanges); /// /// Thread-safe pending changes tracker /// - private volatile int _pendingChanges; + private long _pendingChanges; + + /// + /// Thread-safe transaction depth tracker + /// + internal long TransactionDepth = 0; /// /// Private Ctor @@ -84,6 +89,9 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// Returns a transaction scope that implicitly calls when disposed /// - /// IAsyncDisposable - public IAsyncDisposable BeginTransaction() => new ArrowDbTransactionScope(this); + /// + /// The implements both and , allowing it to be used in both synchronous and asynchronous contexts. + /// + /// A new instance. + public ArrowDbTransactionScope BeginTransaction() => new(this); } diff --git a/src/ArrowDbCore/ArrowDbCore.csproj b/src/ArrowDbCore/ArrowDbCore.csproj index 384fff5..b31e880 100644 --- a/src/ArrowDbCore/ArrowDbCore.csproj +++ b/src/ArrowDbCore/ArrowDbCore.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - 1.4.0.0 + 1.5.0 true true @@ -32,10 +32,6 @@ - - - - <_Parameter1>ArrowDbCore.Tests.Unit diff --git a/src/ArrowDbCore/ArrowDbTransactionScope.cs b/src/ArrowDbCore/ArrowDbTransactionScope.cs index b854eeb..d135440 100644 --- a/src/ArrowDbCore/ArrowDbTransactionScope.cs +++ b/src/ArrowDbCore/ArrowDbTransactionScope.cs @@ -4,21 +4,40 @@ namespace ArrowDbCore; /// /// Provides a scope that can be used to defer serialization until the scope is disposed /// -internal sealed class ArrowDbTransactionScope : IAsyncDisposable { +public sealed class ArrowDbTransactionScope : IAsyncDisposable, IDisposable { private readonly ArrowDb _database; + private bool _disposed; /// /// Initializes a new instance of the class. /// /// The database instance - public ArrowDbTransactionScope(ArrowDb database) { + internal ArrowDbTransactionScope(ArrowDb database) { _database = database; + Interlocked.Increment(ref _database.TransactionDepth); } /// /// Disposes the scope and calls /// - public async ValueTask DisposeAsync() { - await _database.SerializeAsync(); - } + public async ValueTask DisposeAsync() { + if (_disposed) { + return; + } + if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) { + await _database.SerializeAsync().ConfigureAwait(false); + } + _disposed = true; + } + + /// + /// Disposes the scope and calls in a blocking operation + /// + public void Dispose() { + var task = DisposeAsync(); + if (task.IsCompleted) { + return; + } + task.GetAwaiter().GetResult(); + } } diff --git a/src/ArrowDbCore/Extensions.cs b/src/ArrowDbCore/Extensions.cs new file mode 100644 index 0000000..c17339b --- /dev/null +++ b/src/ArrowDbCore/Extensions.cs @@ -0,0 +1,24 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; + +namespace ArrowDbCore; + +/// +/// Extension methods +/// +internal static class Extensions { + /// + /// Converts an input into a Hex Hash in an efficient manner + /// + /// + 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; + int written = Encoding.UTF8.GetBytes(input, span); + Span hashBuffer = stackalloc byte[32]; + SHA256.HashData(span.Slice(0, written), hashBuffer); + return Convert.ToHexString(hashBuffer); + } +} \ No newline at end of file diff --git a/src/ArrowDbCore/Readme.Nuget.md b/src/ArrowDbCore/Readme.Nuget.md index aba95d2..67b4a7d 100644 --- a/src/ArrowDbCore/Readme.Nuget.md +++ b/src/ArrowDbCore/Readme.Nuget.md @@ -11,4 +11,10 @@ A fast, lightweight, and type-safe key-value database designed for .NET. * Cross-Platform and Fully AOT-compatible * Super-Easy API near mirroring of `Dictionary` +## A Note on `null` Values + +`ArrowDb` enforces a "no `null`s" policy by design. Attempting to `Upsert` a `null` value will be rejected and return `false`. This simplifies the developer experience by guaranteeing that if a key exists, its value is never `null`. This eliminates the need for null-checking after retrieval, leading to cleaner and more predictable application code. + +This policy does not affect value types (`structs`); their `default` values (e.g., `0` for an `int`) are considered valid. + Information on usage can be found in the [README](https://github.com/dusrdev/ArrowDb/blob/stable/README.md). diff --git a/src/ArrowDbCore/Serializers/AesFileSerializer.cs b/src/ArrowDbCore/Serializers/AesFileSerializer.cs index 2d68024..f75b7fd 100644 --- a/src/ArrowDbCore/Serializers/AesFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/AesFileSerializer.cs @@ -6,54 +6,36 @@ namespace ArrowDbCore.Serializers; /// -/// An managed file/disk backed serializer +/// An managed file/disk backed serializer. /// -public sealed class AesFileSerializer : IDbSerializer { - /// - /// The path to the file - /// - private readonly string _path; - - /// - /// The Aes instance - /// +public sealed class AesFileSerializer : BaseFileSerializer { private readonly Aes _aes; - - /// - /// The json type info for the dictionary - /// private readonly JsonTypeInfo> _jsonTypeInfo; - /// + /// /// Initializes a new instance of the class. /// - /// The path to the file - /// The instance to use - /// The json type info for the dictionary - public AesFileSerializer(string path, Aes aes, JsonTypeInfo> jsonTypeInfo) { - _path = path; - _aes = aes; - _jsonTypeInfo = jsonTypeInfo; + /// The path to the file. + /// The instance to use. + /// The json type info for the dictionary. + public AesFileSerializer(string path, Aes aes, JsonTypeInfo> jsonTypeInfo) + : base(path) { + _aes = aes; + _jsonTypeInfo = jsonTypeInfo; } - /// - public ValueTask> DeserializeAsync() { - if (!File.Exists(_path) || new FileInfo(_path).Length == 0) { - return ValueTask.FromResult(new ConcurrentDictionary()); - } - using var fileStream = File.OpenRead(_path); - using var decryptor = _aes.CreateDecryptor(); - using var cryptoStream = new CryptoStream(fileStream, decryptor, CryptoStreamMode.Read); - var res = JsonSerializer.Deserialize(cryptoStream, _jsonTypeInfo); - return ValueTask.FromResult(res ?? new ConcurrentDictionary()); - } - - /// - public ValueTask SerializeAsync(ConcurrentDictionary data) { - using var fileStream = File.Create(_path); + /// + protected override void SerializeData(Stream stream, ConcurrentDictionary data) { using var encryptor = _aes.CreateEncryptor(); - using var cryptoStream = new CryptoStream(fileStream, encryptor, CryptoStreamMode.Write); + using var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write); JsonSerializer.Serialize(cryptoStream, data, _jsonTypeInfo); - return ValueTask.CompletedTask; } -} \ No newline at end of file + + /// + protected override ValueTask> DeserializeData(Stream stream) { + 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()); + } +} diff --git a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs new file mode 100644 index 0000000..cd30f52 --- /dev/null +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; + +namespace ArrowDbCore.Serializers; + +/// +/// Provides a base implementation for file-based serializers that ensures atomic and multi-process safe writes. +/// +public abstract class BaseFileSerializer : IDbSerializer { + private readonly string _dbFilePath; + private readonly string _tempFilePath; + private readonly Mutex _mutex; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the database file. + protected BaseFileSerializer(string path) { + _dbFilePath = Path.GetFullPath(path); + _tempFilePath = $"{_dbFilePath}.tmp"; + string mutexName = $"Global\\ArrowDb-{Extensions.ToSHA256Hash(_dbFilePath)}"; + _mutex = new Mutex(false, mutexName); + } + + /// + /// Finalizer to ensure the system-wide mutex is released when the serializer is garbage collected. + /// + ~BaseFileSerializer() { + _mutex.Dispose(); + } + + /// + public ValueTask> DeserializeAsync() { + if (!File.Exists(_dbFilePath) || new FileInfo(_dbFilePath).Length == 0) { + return ValueTask.FromResult(new ConcurrentDictionary()); + } + + _mutex.WaitOne(); + try { + using var fileStream = File.OpenRead(_dbFilePath); + return DeserializeData(fileStream); + } finally { + _mutex.ReleaseMutex(); + } + } + + /// + public ValueTask SerializeAsync(ConcurrentDictionary data) { + _mutex.WaitOne(); + try { + using (var fileStream = File.Create(_tempFilePath)) + { + SerializeData(fileStream, data); + } + File.Move(_tempFilePath, _dbFilePath, true); + } finally { + _mutex.ReleaseMutex(); + } + + return ValueTask.CompletedTask; + } + + /// + /// When overridden in a derived class, serializes the data to the provided stream. + /// + /// The stream to write the data to. + /// The data to serialize. + protected abstract void SerializeData(Stream stream, ConcurrentDictionary data); + + /// + /// When overridden in a derived class, deserializes the data from the provided stream. + /// + /// The stream to read the data from. + /// The deserialized dictionary. + protected abstract ValueTask> DeserializeData(Stream stream); +} \ No newline at end of file diff --git a/src/ArrowDbCore/Serializers/FileSerializer.cs b/src/ArrowDbCore/Serializers/FileSerializer.cs index 16cf141..390239a 100644 --- a/src/ArrowDbCore/Serializers/FileSerializer.cs +++ b/src/ArrowDbCore/Serializers/FileSerializer.cs @@ -2,47 +2,32 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; - namespace ArrowDbCore.Serializers; /// -/// A file/disk backed serializer +/// A file/disk backed serializer using JSON. /// -public class FileSerializer : IDbSerializer { - /// - /// The path to the file - /// - private readonly string _path; - - /// - /// The json type info for the dictionary - /// +public class FileSerializer : BaseFileSerializer { private readonly JsonTypeInfo> _jsonTypeInfo; /// /// Initializes a new instance of the class. /// - /// The path to the file - /// The json type info for the dictionary - public FileSerializer(string path, JsonTypeInfo> jsonTypeInfo) { - _path = path; + /// The path to the file. + /// The json type info for the dictionary. + public FileSerializer(string path, JsonTypeInfo> jsonTypeInfo) + : base(path) { _jsonTypeInfo = jsonTypeInfo; } /// - public ValueTask> DeserializeAsync() { - if (!File.Exists(_path) || new FileInfo(_path).Length == 0) { - return ValueTask.FromResult(new ConcurrentDictionary()); - } - using var file = File.OpenRead(_path); - var result = JsonSerializer.Deserialize(file, _jsonTypeInfo) ?? new(); - return ValueTask.FromResult(result); + protected override void SerializeData(Stream stream, ConcurrentDictionary data) { + JsonSerializer.Serialize(stream, data, _jsonTypeInfo); } /// - public ValueTask SerializeAsync(ConcurrentDictionary data) { - using var file = File.Create(_path); - JsonSerializer.Serialize(file, data, _jsonTypeInfo); - return ValueTask.CompletedTask; + protected override ValueTask> DeserializeData(Stream stream) { + var result = JsonSerializer.Deserialize(stream, _jsonTypeInfo) ?? new(); + return ValueTask.FromResult(result); } } diff --git a/tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj b/tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj index 965e2df..0b9b75f 100644 --- a/tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj +++ b/tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj @@ -7,6 +7,7 @@ enable true true + true diff --git a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs new file mode 100644 index 0000000..d8b8a35 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs @@ -0,0 +1,42 @@ +using System.Security.Cryptography; + +using ArrowDbCore.Tests.Common; + +namespace ArrowDbCore.Tests.Unit; + +public class Concurrency { + [Theory] + [InlineData(true)] + [InlineData(false)] + 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)); + } + + 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) { + if (useAes) { + return await ArrowDb.CreateFromFileWithAes(path, aes!); + } + + return await ArrowDb.CreateFromFile(path); + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs index ff6ddcf..55ce15e 100644 --- a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs +++ b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs @@ -4,7 +4,7 @@ namespace ArrowDbCore.Tests.Unit; public class GetOrAddAsync { #pragma warning disable xUnit1031 // Do not use blocking task operations in test method -// this is required here for testing purposes + // this is required here for testing purposes [Fact] public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() { var db = await ArrowDb.CreateInMemory(); @@ -63,4 +63,20 @@ public async Task GetOrAddAsync_WithTArg_ReturnsAsynchronously_WhenNotExists() { Assert.False(task.IsCompletedSuccessfully); Assert.Equal(1, await task); } + + [Fact] + public async Task GetOrAddAsync_FailingFactory_DoesNotAddItem() { + // Arrange + var db = await ArrowDb.CreateInMemory(); + + // Act & Assert + await Assert.ThrowsAsync(() => + db.GetOrAddAsync("key", JContext.Default.Int32, _ => + ValueTask.FromException(new InvalidOperationException("Factory failed")) + ).AsTask() + ); + + Assert.Equal(0, db.Count); + Assert.False(db.ContainsKey("key")); + } } \ No newline at end of file diff --git a/tests/ArrowDbCore.Tests.Unit/Removes.cs b/tests/ArrowDbCore.Tests.Unit/Removes.cs index be302b3..3bd1e29 100644 --- a/tests/ArrowDbCore.Tests.Unit/Removes.cs +++ b/tests/ArrowDbCore.Tests.Unit/Removes.cs @@ -10,6 +10,20 @@ public async Task TryRemove_When_Not_Found_Returns_False() { Assert.False(db.TryRemove("1")); } + [Fact] + public async Task TryRemove_NotFound_DoesNotIncrementPendingChanges() { + // Arrange + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.PendingChanges); + + // Act + var result = db.TryRemove("non_existent_key"); + + // Assert + Assert.False(result); + Assert.Equal(0, db.PendingChanges); + } + [Fact] public async Task TryRemove_When_Found_Returns_True() { var db = await ArrowDb.CreateInMemory(); diff --git a/tests/ArrowDbCore.Tests.Unit/Serialization.cs b/tests/ArrowDbCore.Tests.Unit/Serialization.cs index adaed9b..b5064bf 100644 --- a/tests/ArrowDbCore.Tests.Unit/Serialization.cs +++ b/tests/ArrowDbCore.Tests.Unit/Serialization.cs @@ -46,7 +46,7 @@ public async Task Serialize_Using_Event_Resets_Changes() { } [Fact] - public async Task DeferredSerializationScope_Serialize_After_Dispose() { + public async Task DeferredSerializationScope_SerializeAsync_After_DisposeAsync() { var db = await ArrowDb.CreateInMemory(); Assert.Equal(0, db.Count); await using (_ = db.BeginTransaction()) { @@ -60,6 +60,21 @@ public async Task DeferredSerializationScope_Serialize_After_Dispose() { Assert.Equal(0, db.PendingChanges); } + [Fact] + public async Task DeferredSerializationScope_Serialize_After_Dispose() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + using (_ = db.BeginTransaction()) { + db.Upsert("1", 1, JContext.Default.Int32); + Assert.True(db.ContainsKey("1")); + Assert.Equal(1, db.Count); + Assert.Equal(1, db.PendingChanges); + } + Assert.True(db.ContainsKey("1")); + Assert.Equal(1, db.Count); + Assert.Equal(0, db.PendingChanges); + } + private static async Task File_Serializes_And_Deserializes_As_Expected(string path, Func> factory) { try { var db = await factory(); diff --git a/tests/ArrowDbCore.Tests.Unit/Transactions.cs b/tests/ArrowDbCore.Tests.Unit/Transactions.cs new file mode 100644 index 0000000..e0bbd72 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Transactions.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; + +using ArrowDbCore.Tests.Common; + +namespace ArrowDbCore.Tests.Unit; + +public class Transactions { + [Theory] + [InlineData(true)] + [InlineData(false)] + 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 }; + + // 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); + Assert.Equal(2, db.PendingChanges); + + // Still shouldn't serialize + } + var db2 = await CreateDb(path, useAes, aes); + Assert.Equal(0, db2.Count); + + Assert.Equal(2, db.PendingChanges); + } + + // Assert + var db3 = await CreateDb(path, useAes, aes); + Assert.Equal(2, db3.Count); + Assert.Equal(0, db3.PendingChanges); + File.Delete(path); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + 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); + } + + private async Task CreateDb(string path, bool useAes, Aes? aes = null) { + if (useAes) { + return await ArrowDb.CreateFromFileWithAes(path, aes!); + } + + return await ArrowDb.CreateFromFile(path); + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/Upserts.cs b/tests/ArrowDbCore.Tests.Unit/Upserts.cs index bf00e89..86d1f9a 100644 --- a/tests/ArrowDbCore.Tests.Unit/Upserts.cs +++ b/tests/ArrowDbCore.Tests.Unit/Upserts.cs @@ -24,6 +24,21 @@ public async Task Upsert_When_Found_Overwrites() { Assert.Equal(2, value); } + [Fact] + public async Task Upsert_NullValue_IsDisallowed() + { + // Arrange + var db = await ArrowDb.CreateInMemory(); + + // Act + var result = db.Upsert("key", null, JContext.Default.Person); + + // Assert + Assert.False(result); + Assert.Equal(0, db.Count); + Assert.False(db.ContainsKey("key")); + } + [Fact] public async Task Conditional_Update_When_Not_Found_Inserts() { var db = await ArrowDb.CreateInMemory();