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/.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 87413d9..3cf58f2 100644 --- a/ArrowDbCore.slnx +++ b/ArrowDbCore.slnx @@ -6,11 +6,14 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce24b7..e83a33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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`. +- 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 - 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..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`) @@ -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. 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 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 -// 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. @@ -82,8 +77,43 @@ Up until now, the data was stored in-memory, to finalize and persist the changes ```csharp await db.SerializeAsync(); +// or +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: @@ -126,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 @@ -232,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. @@ -242,17 +276,17 @@ 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 `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. @@ -262,11 +296,16 @@ 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); -// 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 @@ -283,12 +322,15 @@ The `IDbSerializer` is exposed and can be used to implement custom serializers: ```csharp public interface IDbSerializer { - ValueTask> DeserializeAsync(); - ValueTask SerializeAsync(ConcurrentDictionary data); + 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 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. 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. @@ -306,6 +348,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: @@ -315,6 +359,12 @@ await db.RollbackAsync(); 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. @@ -335,20 +385,20 @@ 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. +`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/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/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/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..cbfc3e4 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) }; @@ -30,12 +33,14 @@ public void Setup() { Trace.Assert(_items.Length == Count); - _db = ArrowDb.CreateInMemory().GetAwaiter().GetResult(); + _db = ArrowDb.CreateInMemory().AsTask().GetAwaiter().GetResult(); } [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..9ee4b70 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/SerializationToFileBenchmark.cs @@ -13,23 +13,29 @@ namespace ArrowDbCore.Benchmarks.VersionComparison; [MemoryDiagnoser(false)] [RankColumn] [Config(typeof(VersionComparisonConfig))] -public class SerializationToFileBenchmarks { +public class SerializationToFileBenchmarks +{ private ArrowDb _db = default!; + private string _dbPath = 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) }; - _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]; - 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 +45,17 @@ public void Setup() { } [IterationCleanup] - public void Cleanup() { - if (File.Exists("test.db")) { - File.Delete("test.db"); + public void Cleanup() + { + if (File.Exists(_dbPath)) + { + File.Delete(_dbPath); } } [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..6b797f9 100644 --- a/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs +++ b/benchmarks/ArrowDbCore.Benchmarks.VersionComparison/VersionComparisonConfig.cs @@ -3,42 +3,93 @@ 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; namespace ArrowDbCore.Benchmarks.VersionComparison; -public class VersionComparisonConfig : ManualConfig { +public class VersionComparisonConfig : ManualConfig +{ public const string PackageId = "ArrowDb"; + private const string UseLocalArrowDbProperty = "/p:UseLocalArrowDb=true"; - public VersionComparisonConfig() { - var (stable, latest) = GetLatestVersionsAsync(PackageId) + public VersionComparisonConfig() + { + 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) { - // Point at the official NuGet v3 API - var source = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); + private static async Task GetLatestStableVersionBelowAsync(string packageId, NuGetVersion localVersion) + { + 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, @@ -47,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."); } -} \ No newline at end of file +} 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/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..9b639e2 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) }; @@ -30,12 +33,14 @@ public void Setup() { Trace.Assert(_items.Length == Count); - _db = ArrowDb.CreateInMemory().GetAwaiter().GetResult(); + _db = ArrowDb.CreateInMemory().AsTask().GetAwaiter().GetResult(); } [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..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; @@ -13,23 +14,29 @@ namespace ArrowDbCore.Benchmarks; [MemoryDiagnoser(false)] [RankColumn] [MediumRunJob] -public class SerializationToFileBenchmarks { +public class SerializationToFileBenchmarks +{ + private FileSerializer? _fileSerializer; 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) }; - _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]; - 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 +46,19 @@ public void Setup() { } [IterationCleanup] - public void Cleanup() { - if (File.Exists("test.db")) { + public void Cleanup() + { + _fileSerializer?.Dispose(); + + 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/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..e26c554 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbInitializationHostedService.cs @@ -0,0 +1,25 @@ +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..0614116 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbProvider.cs @@ -0,0 +1,79 @@ +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..894ada7 --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/ArrowDbServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +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..4c9413f --- /dev/null +++ b/src/ArrowDbCore.DependencyInjection/IArrowDbProvider.cs @@ -0,0 +1,13 @@ +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 179e1cd..9c14fdb 100644 --- a/src/ArrowDbCore/ArrowDb.Factory.cs +++ b/src/ArrowDbCore/ArrowDb.Factory.cs @@ -4,16 +4,22 @@ namespace ArrowDbCore; -public partial class ArrowDb { +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) { + /// + /// 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); - var data = await serializer.DeserializeAsync(); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// @@ -21,31 +27,53 @@ 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) { + /// + /// 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); - var data = await serializer.DeserializeAsync(); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// /// 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(); - return new ArrowDb(data, serializer); + return await CreateFromSerializer(serializer, disposeSerializer: true, cancellationToken); } /// /// 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(); - return new ArrowDb(data, serializer); + public static async ValueTask CreateCustom(TSerializer serializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer + { + cancellationToken.ThrowIfCancellationRequested(); + 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(TSerializer serializer, bool disposeSerializer, CancellationToken cancellationToken = default) where TSerializer : IDbSerializer + { + cancellationToken.ThrowIfCancellationRequested(); + return await CreateFromSerializer(serializer, disposeSerializer, cancellationToken); } /// @@ -57,7 +85,8 @@ public static async ValueTask CreateCustom(IDbSerializer serializer) { /// /// 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); @@ -68,10 +97,25 @@ 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; } -} \ No newline at end of file + + 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 + { + await serializer.DisposeAsync(); + throw; + } + } +} diff --git a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs index 4182bdd..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. /// @@ -11,6 +12,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 +24,16 @@ 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) { - 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)!; } - var val = await valueFactory(key); + + cancellationToken.ThrowIfCancellationRequested(); + var val = await valueFactory(key, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); Upsert(key, val, jsonTypeInfo); return val; } @@ -40,6 +47,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 +59,17 @@ 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) { - 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)!; } - 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.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 75a0202..41134f6 100644 --- a/src/ArrowDbCore/ArrowDb.Serialization.cs +++ b/src/ArrowDbCore/ArrowDb.Serialization.cs @@ -2,23 +2,33 @@ namespace ArrowDbCore; -public partial class ArrowDb { +public partial class ArrowDb +{ /// /// Serializes the database /// /// /// 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 (Interlocked.Read(ref _pendingChanges) == 0) { + /// A cancellation token. + public async Task SerializeAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + + if (Interlocked.Read(ref _pendingChanges) == 0) + { return; } - try { - await Semaphore.WaitAsync(); + + await Semaphore.WaitAsync(cancellationToken); + try + { 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 { + } + finally + { Semaphore.Release(); } } @@ -27,8 +37,10 @@ public async Task SerializeAsync() { /// 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(); } @@ -37,17 +49,24 @@ private void WaitIfSerializing() { /// /// Rolls the entire database to the last persisted state /// - public async Task RollbackAsync() { - try { - await Semaphore.WaitAsync(); + /// A cancellation token. + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + + await Semaphore.WaitAsync(cancellationToken); + try + { 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>(); Interlocked.Exchange(ref _pendingChanges, 0); - } finally { + } + finally + { Semaphore.Release(); } } -} \ No newline at end of file +} 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 0d87f78..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 /// @@ -37,6 +38,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 /// @@ -45,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); } @@ -75,10 +82,13 @@ 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); } @@ -86,9 +96,14 @@ private ArrowDb(ConcurrentDictionary source, IDbSerializer seria /// /// Finalizer (called when the instance is garbage collected) /// - ~ArrowDb() { - Interlocked.Decrement(ref s_runningInstances); + ~ArrowDb() + { + if (DisposeSerializer) + Serializer.Dispose(); + Semaphore.Dispose(); + + Interlocked.Decrement(ref s_runningInstances); } /// @@ -97,6 +112,11 @@ 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) + { + ObjectDisposedException.ThrowIf(Serializer.IsDisposed, Serializer); + return new(this, cancellationToken); + } +} diff --git a/src/ArrowDbCore/ArrowDbCore.csproj b/src/ArrowDbCore/ArrowDbCore.csproj index ad63200..5dfb7cc 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 @@ -43,7 +43,7 @@ - + @@ -61,6 +61,9 @@ <_Parameter1>ArrowDbCore.Tests.Integrity + + <_Parameter1>ArrowDbCore.DependencyInjection + 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 new file mode 100644 index 0000000..6d8dcab --- /dev/null +++ b/src/ArrowDbCore/ArrowDbOwnershipException.cs @@ -0,0 +1,28 @@ +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/ArrowDbTransactionScope.cs b/src/ArrowDbCore/ArrowDbTransactionScope.cs index 7bf6e4c..8ff344d 100644 --- a/src/ArrowDbCore/ArrowDbTransactionScope.cs +++ b/src/ArrowDbCore/ArrowDbTransactionScope.cs @@ -4,42 +4,53 @@ 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; /// /// 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); } /// /// Disposes the scope and calls /// - public async ValueTask DisposeAsync() { - if (_disposed) { + public async ValueTask DisposeAsync() + { + if (_disposed) + { return; } - if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) { - await _database.SerializeAsync().ConfigureAwait(false); - } + _disposed = true; + if (Interlocked.Decrement(ref _database.TransactionDepth) == 0) + { + await _database.SerializeAsync(_cancellationToken).ConfigureAwait(false); + } } /// /// 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.IsCompleted) { + if (task.IsCompletedSuccessfully) + { return; } task.GetAwaiter().GetResult(); } -} \ No newline at end of file +} 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 3352e51..8d59178 100644 --- a/src/ArrowDbCore/IDbSerializer.cs +++ b/src/ArrowDbCore/IDbSerializer.cs @@ -5,15 +5,23 @@ 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 /// - 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..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`) @@ -22,3 +22,21 @@ 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. + +## 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`. + +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..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,23 +20,26 @@ 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 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 dd03266..ffe0b7d 100644 --- a/src/ArrowDbCore/Serializers/BaseFileSerializer.cs +++ b/src/ArrowDbCore/Serializers/BaseFileSerializer.cs @@ -1,62 +1,106 @@ using System.Collections.Concurrent; +using System.Security.Cryptography; + +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 { +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() + { + Access = FileAccess.Write, + Mode = FileMode.Create, + Options = FileOptions.Asynchronous, + Share = FileShare.None, + }; + private readonly string _dbFilePath; - private readonly string _tempFilePath; - private readonly Mutex _mutex; + private readonly SafeFileHandle? _ownershipHandle; + private string? _lastTempFilePath; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The path to the database file. - protected BaseFileSerializer(string path) { + /// + /// 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(); + ~BaseFileSerializer() + { + Dispose(disposing: false); } /// - public ValueTask> DeserializeAsync() { - if (!File.Exists(_dbFilePath) || new FileInfo(_dbFilePath).Length == 0) { - return ValueTask.FromResult(new ConcurrentDictionary()); - } + public async ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); - _mutex.WaitOne(); - try { - using var fileStream = File.OpenRead(_dbFilePath); - return DeserializeData(fileStream); - } finally { - _mutex.ReleaseMutex(); + try + { + await using FileStream fileStream = new(_dbFilePath, ReadStreamOptions); + if (fileStream.Length == 0) + { + return new ConcurrentDictionary(); + } + + return await DeserializeDataAsync(fileStream, cancellationToken); + } + catch (FileNotFoundException) + { + return new ConcurrentDictionary(); } } /// - public ValueTask SerializeAsync(ConcurrentDictionary data) { - _mutex.WaitOne(); - try { - using (var fileStream = File.Create(_tempFilePath)) { - SerializeData(fileStream, data); + 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)) + { + await SerializeDataAsync(fileStream, data, cancellationToken); + await fileStream.FlushAsync(cancellationToken); } - File.Move(_tempFilePath, _dbFilePath, true); - } finally { - _mutex.ReleaseMutex(); - } - return ValueTask.CompletedTask; + File.Move(tempFilePath, _dbFilePath, true); + } + finally + { + TryDeleteFile(tempFilePath); + } } /// @@ -64,21 +108,99 @@ public ValueTask SerializeAsync(ConcurrentDictionary data) { /// /// 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 bool IsDisposed => _disposed; /// - public void Dispose() { - if (_disposed) return; + public void Dispose() + { + if (_disposed) + { + return; + } - _mutex.Dispose(); - _disposed = true; + Dispose(disposing: true); GC.SuppressFinalize(this); } -} \ No newline at end of file + + /// + 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; + } + + 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); + } + } + + 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..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,18 +17,21 @@ 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 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/src/ArrowDbCore/Serializers/InMemorySerializer.cs b/src/ArrowDbCore/Serializers/InMemorySerializer.cs index 46225bb..6c9f948 100644 --- a/src/ArrowDbCore/Serializers/InMemorySerializer.cs +++ b/src/ArrowDbCore/Serializers/InMemorySerializer.cs @@ -6,14 +6,41 @@ namespace ArrowDbCore.Serializers; /// /// An in-memory serializer (does nothing) /// -public sealed class InMemorySerializer : IDbSerializer { +public sealed class InMemorySerializer : IDbSerializer +{ + private bool _disposed; + + /// + public bool IsDisposed => _disposed; + /// /// Returns an empty dictionary /// - public ValueTask> DeserializeAsync() => 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) => ValueTask.CompletedTask; -} \ No newline at end of file + 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..0094355 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/DependencyInjection.cs @@ -0,0 +1,247 @@ +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..3ef38c1 --- /dev/null +++ b/tests/ArrowDbCore.DependencyInjection.Tests/FileBackedTestHelpers.cs @@ -0,0 +1,29 @@ +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.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/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.Integrity/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs new file mode 100644 index 0000000..c87d8e5 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Integrity/FileBackedTestHelpers.cs @@ -0,0 +1,37 @@ +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..b231f59 100644 --- a/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs +++ b/tests/ArrowDbCore.Tests.Integrity/LargeFile.cs @@ -8,9 +8,13 @@ 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; var faker = new Faker(); faker.UseSeed(1337); @@ -20,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 - var db = await factory(); + 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); @@ -34,30 +40,43 @@ 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(); aes.GenerateIV(); await LargeFile_Passes_OneReadWriteCycle(path, () => 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..ebedcfa 100644 --- a/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs +++ b/tests/ArrowDbCore.Tests.Integrity/OverwriteForceClear.cs @@ -8,9 +8,12 @@ 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; var faker = new Faker(); faker.UseSeed(1337); @@ -20,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 - var db = await factory(); + 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); @@ -42,27 +47,34 @@ 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(); aes.GenerateIV(); await SerializeOverwritesExistingFile(path, () => 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..d43742b 100644 --- a/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs +++ b/tests/ArrowDbCore.Tests.Integrity/ReadWriteCycles.cs @@ -8,10 +8,13 @@ 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; var faker = new Faker(); faker.UseSeed(1337); @@ -21,42 +24,54 @@ 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 - var db = await factory(); + 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); } // save await db.SerializeAsync(); + FileBackedTestHelpers.ReleaseOwnership(db); + db = null; } - } finally { - if (File.Exists(path)) { - File.Delete(path); + } + finally + { + if (db is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db); } + + FileBackedTestHelpers.DeleteArtifacts(path); } // this test fails if an exception is thrown } [Fact] - public async Task FileIO_Passes_ReadWriteCycles_FileSerializer() { + public async Task FileIO_Passes_ReadWriteCycles_FileSerializer() + { var path = Sharpify.Utils.Env.PathInBaseDirectory("rdc-test-file-serializer.db"); await FileIO_Passes_ReadWriteCycles(path, () => 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(); aes.GenerateIV(); await FileIO_Passes_ReadWriteCycles(path, () => 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..6b93b46 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Probes.FileOwnership/Program.cs @@ -0,0 +1,21 @@ +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.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.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/ArrowDbCore.Tests.Unit.csproj b/tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj index 09eae78..f5e24c7 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 @@ -27,6 +28,7 @@ + diff --git a/tests/ArrowDbCore.Tests.Unit/Cancellation.cs b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs new file mode 100644 index 0000000..b4b5a94 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Cancellation.cs @@ -0,0 +1,160 @@ +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 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(); + 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 +{ + 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/Concurrency.cs b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs index 5af99f7..ccab93d 100644 --- a/tests/ArrowDbCore.Tests.Unit/Concurrency.cs +++ b/tests/ArrowDbCore.Tests.Unit/Concurrency.cs @@ -4,39 +4,63 @@ 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(); - 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); + } - await Task.WhenAll(tasks); - await db.SerializeAsync(); + if (db is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db); + } - // Assert - var db2 = await CreateDb(path, useAes, aes); - Assert.Equal(taskCount, db2.Count); - File.Delete(path); + FileBackedTestHelpers.DeleteArtifacts(path); + } } - 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!); } return await ArrowDb.CreateFromFile(path); } -} \ No newline at end of file +} diff --git a/tests/ArrowDbCore.Tests.Unit/Disposal.cs b/tests/ArrowDbCore.Tests.Unit/Disposal.cs new file mode 100644 index 0000000..2e33ec7 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Disposal.cs @@ -0,0 +1,146 @@ +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/FileBackedTestHelpers.cs b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs new file mode 100644 index 0000000..9bca437 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileBackedTestHelpers.cs @@ -0,0 +1,37 @@ +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..7440d0c --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileOwnership.cs @@ -0,0 +1,112 @@ +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/FileSerializerAsync.cs b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs new file mode 100644 index 0000000..88a0ccd --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/FileSerializerAsync.cs @@ -0,0 +1,148 @@ +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(); + } + } +} diff --git a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs index 55ce15e..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,13 +74,14 @@ 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(); // 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 +89,42 @@ 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/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 242d31e..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,22 +54,31 @@ 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; 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() { - if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) { + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Interlocked.Exchange(ref _blockNextDeserialize, 0) == 0) + { return ValueTask.FromResult(new ConcurrentDictionary()); } @@ -72,57 +86,85 @@ public ValueTask> DeserializeAsync() { 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) => 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 { +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."); } } @@ -135,4 +177,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/Serialization.cs b/tests/ArrowDbCore.Tests.Unit/Serialization.cs index a0af9c6..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,32 +84,48 @@ 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) { - try { - var db = await factory(); + private static async Task File_Serializes_And_Deserializes_As_Expected(string path, Func> factory) + { + ArrowDb? db = null; + ArrowDb? db2 = null; + try + { + 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); + } + finally + { + if (db2 is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db2); + } + + if (db is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db); } + + FileBackedTestHelpers.DeleteArtifacts(path); } } [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(); @@ -108,9 +133,12 @@ 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) { - try { - var db = await factory(); + private static async Task File_Serializes_And_Rollback_As_Expected(string path, Func> factory) + { + ArrowDb? db = null; + try + { + db = await factory(); db.Upsert("1", 1, JContext.Default.Int32); Assert.True(db.ContainsKey("1")); Assert.Equal(1, db.Count); @@ -129,26 +157,32 @@ 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 { - // cleanup - if (File.Exists(path)) { - File.Delete(path); + } + finally + { + if (db is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db); } + + FileBackedTestHelpers.DeleteArtifacts(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(); 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/SerializationPendingChanges.cs b/tests/ArrowDbCore.Tests.Unit/SerializationPendingChanges.cs index 7f5ec38..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,72 +50,106 @@ 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); public readonly TaskCompletionSource AllowSerializeToFinish = new(TaskCreationOptions.RunContinuationsAsynchronously); - public ValueTask> DeserializeAsync() { + public bool IsDisposed => _disposed; + + public ValueTask> DeserializeAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); return ValueTask.FromResult(new ConcurrentDictionary()); } - public ValueTask SerializeAsync(ConcurrentDictionary data) { + 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; + } } } // 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."); } } @@ -122,4 +162,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 { } 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 055bfcc..349e6ea 100644 --- a/tests/ArrowDbCore.Tests.Unit/Transactions.cs +++ b/tests/ArrowDbCore.Tests.Unit/Transactions.cs @@ -4,76 +4,112 @@ 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(); - 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); - Assert.Equal(2, db.PendingChanges); + await using (var scope2 = db.BeginTransaction()) + { + db.Upsert("key2", person, JContext.Default.Person); + Assert.Equal(2, db.PendingChanges); - // Still shouldn't serialize + // Still shouldn't serialize + } + Assert.Equal(2, db.PendingChanges); + Assert.Equal(0, new FileInfo(path).Length); } - var db2 = await CreateDb(path, useAes, aes); - Assert.Equal(0, db2.Count); - Assert.Equal(2, db.PendingChanges); + // 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); + } - // Assert - var db3 = await CreateDb(path, useAes, aes); - Assert.Equal(2, db3.Count); - Assert.Equal(0, db3.PendingChanges); - File.Delete(path); + if (db is not null) + { + FileBackedTestHelpers.ReleaseOwnership(db); + } + + FileBackedTestHelpers.DeleteArtifacts(path); + } } [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(); - 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) { - if (useAes) { + private async Task CreateDb(string path, bool useAes, Aes? aes = null) + { + if (useAes) + { return await ArrowDb.CreateFromFileWithAes(path, aes!); } return await ArrowDb.CreateFromFile(path); } -} \ No newline at end of file +} 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 +}