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