Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog (Sorted by Date in Descending Order)

## 1.2.0.0

* An overload to `Upsert` without `updateCondition` was added and would now act as default path in case `updateCondition` wasn't specified, this should further optimize such cases by removing condition checks and another reference from the stack during runtime.
* Internal methods which are rather small and frequently invoked will now be prioritized for inlining by JIT, this should slightly improve perf, especially in NativeAot.
* Added a new factory initializer `CreateFromFileWithAes` that received an `Aes` instance as parameter. It will then use it to encrypt and decrypt the output and input during serialization and deserialization respectively.

## 1.1.0.0

* Fixed issue with `FileSerializer` where serialization would write over existing file data which could create invalid tokens, causing deserialization to fail.
Expand Down
35 changes: 23 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[![NuGet Downloads](https://img.shields.io/nuget/dt/ArrowDb?style=flat&label=Nuget%20-%20ArrowDb)](https://www.nuget.org/packages/ArrowDb)
[![Unit Tests](https://github.com/dusrdev/ArrowDb/actions/workflows/unit-tests.yaml/badge.svg)](https://github.com/dusrdev/ArrowDb/actions/workflows/unit-tests.yaml)
[![Integrity Tests](https://github.com/dusrdev/ArrowDb/actions/workflows/integrity-tests.yaml/badge.svg)](https://github.com/dusrdev/ArrowDb/actions/workflows/integrity-tests.yaml)

</div>

ArrowDb is a fast, lightweight, and type-safe key-value database designed for .NET.
Expand Down Expand Up @@ -97,10 +97,11 @@ bool db.TryGetValue<TValue>(ReadOnlySpan<char> key, JsonTypeInfo<TValue> jsonTyp

Notice that all APIs accept keys as `ReadOnlySpan<char>` to avoid unnecessary allocations. This means that if you check for a key by some slice of a string, there is no need to allocate a string just for the lookup.

Upserting (adding or updating) is done via a single method:
Upserting (adding or updating) is done via a 2 overloads:

```csharp
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool>? updateCondition = null); // upserts a value into the ArrowDb instance
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo);
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition = null); // upserts a value into the ArrowDb instance
```

And removal:
Expand All @@ -112,21 +113,18 @@ void Clear(); // removes all entries from the Arrow

## Optimistic Concurrency Control

`ArrowDb` uses optimistic concurrency control as a way to resolve write conflicts, similar to MongoDb. This is done via the optional `updateCondition` parameter of the `Upsert` method.
`ArrowDb` uses optimistic concurrency control as a way to resolve write conflicts, similar to MongoDb. This is done via the overload of `Upsert` with the `updateCondition` parameter.

The updateCondition is a predicate that is invoked on the reference value that is currently stored in the db under the same key.
The `updateCondition` is a predicate that is invoked on the reference value that is currently stored in the db under the same key.

For ArrowDb to reject the update (and return `false`), ALL of the following 3 conditions must be met:
For ArrowDb to reject the update (and return `false`), ALL of the following 2 conditions must be met:

1. The `updateCondition` predicate must not be null.
2. An entry with the same key must exist in the db and be successfully parsed into the specified type.
3. The `updateCondition` predicate returns `false` when invoked on the reference value.
1. An entry with the same key must exist in the db and be successfully parsed into the specified type.
2. The `updateCondition` predicate returns `false` when invoked on the reference value.

This means that all other cases would allow addition/update:

* If `updateCondition` is null, no check is performed on the same key before adding/updating.
* This also means that the type will not be validated to be the same as the existing value, and will just overwrite it.
* If `updateCondition` is not null, but the key does not exist, the update is allowed - and regarded as an addition. If a strict "update or nothing" behavior is desired, combine `ContainsKey` into the workflow before calling `Upsert`.
* If `updateCondition` is used and the key does not exist, the update is allowed - and regarded as an addition. If a strict "update or nothing" behavior is desired, combine `ContainsKey` into the workflow before calling `Upsert`.

To illustrate this, Let’s look at an example of a timestamped `Note` entity:

Expand Down Expand Up @@ -196,6 +194,19 @@ builder.Services.AddSingleton(() => ArrowDb.CreateInMemory().GetAwaiter().GetRes
// Since this isn’t persisted, you may also use it as a Transient or Scoped service (whatever fits your needs).
```

## Encryption

As seen earlier, the default recommended serializer is `FileSerializer`, which serializes the db to a file on disk. In addition to it, `ArrowDb` also features a similar serializer that encrypts the db to a file on disk (the `AesFileSerializer`), for that the serializer requires an `Aes` instance to be passed along with the path to the file.

```csharp
string path = "store.db";
using var aes = Aes.Create();
var db = await ArrowDb.CreateFromFileWithAes(path, aes);
// or with dependency injection
builder.Services.AddSingleton(_ => Aes.Create());
builder.Services.AddSingleton(services => ArrowDb.CreateFromFileWithAes(path, services.GetRequiredService<Aes>()).GetAwaiter().GetResult());
```

## Serialization

To enhance the use cases of `ArrowDb` it was designed to allow for custom serialization (the methods of persisting the db).
Expand Down
18 changes: 17 additions & 1 deletion src/ArrowDbCore/ArrowDb.Factory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
namespace ArrowDbCore;
using System.Security.Cryptography;

using ArrowDbCore.Serializers;

namespace ArrowDbCore;

public partial class ArrowDb {
/// <summary>
Expand All @@ -12,6 +16,18 @@ public static async ValueTask<ArrowDb> CreateFromFile(string path) {
return new ArrowDb(data, serializer);
}

/// <summary>
/// Initializes an <see cref="Aes"/> managed file/disk backed database at the specified path
/// </summary>
/// <param name="path">The path that the file that backs the database</param>
/// <param name="aes">The <see cref="Aes"/> instance to use</param>
/// <returns>A database instance</returns>
public static async ValueTask<ArrowDb> CreateFromFileWithAes(string path, Aes aes) {
var serializer = new AesFileSerializer(path, aes, ArrowDbJsonContext.Default.ConcurrentDictionaryStringByteArray);
var data = await serializer.DeserializeAsync();
return new ArrowDb(data, serializer);
}

/// <summary>
/// Initializes an in-memory database
/// </summary>
Expand Down
5 changes: 4 additions & 1 deletion src/ArrowDbCore/ArrowDb.Serialization.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ArrowDbCore;
using System.Runtime.CompilerServices;

namespace ArrowDbCore;

public partial class ArrowDb {
/// <summary>
Expand All @@ -23,6 +25,7 @@ public async Task SerializeAsync() {
/// <summary>
/// Waits for the semaphore if the database is currently serializing
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WaitIfSerializing() {
if (Semaphore.CurrentCount == 0) {
Semaphore.Wait();
Expand Down
27 changes: 19 additions & 8 deletions src/ArrowDbCore/ArrowDb.Upsert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@
namespace ArrowDbCore;

public partial class ArrowDb {
/// <summary>
/// Upsert the specified key with the specified value into the database
/// </summary>
/// <typeparam name="TValue">The type of the value to upsert</typeparam>
/// <param name="key">The key at which to upsert the value</param>
/// <param name="value">The value to upsert</param>
/// <param name="jsonTypeInfo">The json type info for the value type</param>
/// <returns>True</returns>
public bool Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo) {
WaitIfSerializing(); // block if the database is currently serializing
byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo);
Lookup[key] = utf8value;
OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // trigger change event
return true;
}

/// <summary>
/// Tries to upsert the specified key with the specified value into the database
/// </summary>
Expand All @@ -21,16 +37,11 @@ public partial class ArrowDb {
/// <para>2. A value for the specified key exists and successfully deserialized to <typeparamref name="TValue"/></para>
/// <para>3. <paramref name="updateCondition"/> on the reference value returns false</para>
/// </remarks>
public bool Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool>? updateCondition = null) {
if (updateCondition is not null &&
TryGetValue(key, jsonTypeInfo, out TValue existingReference) &&
public bool Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition) {
if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) &&
!updateCondition(existingReference)) {
return false;
}
WaitIfSerializing(); // block if the database is currently serializing
byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo);
Lookup[key] = utf8value;
OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // trigger change event
return true;
return Upsert(key, value, jsonTypeInfo);
}
}
6 changes: 3 additions & 3 deletions src/ArrowDbCore/ArrowDbCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.1.0.0</Version>
<Version>1.2.0.0</Version>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<PropertyGroup>
<Authors>David Shnayder</Authors>
<Copyright>David Shnayder</Copyright>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReadmeFile>Readme.Nuget.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageId>ArrowDb</PackageId>
Expand All @@ -28,7 +28,7 @@

<ItemGroup>
<None Include="LICENSE.txt" Pack="true" PackagePath="LICENSE.txt" />
<None Include="README.md" Pack="true" PackagePath="README.md" />
<None Include="Readme.Nuget.md" Pack="true" PackagePath="Readme.Nuget.md" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 0 additions & 1 deletion src/ArrowDbCore/README.md

This file was deleted.

14 changes: 14 additions & 0 deletions src/ArrowDbCore/Readme.Nuget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# ArrowDb

A fast, lightweight, and type-safe key-value database designed for .NET.

* Super-Lightweight (dll size is <= 20KB - approximately 9X smaller than [UltraLiteDb](https://github.com/rejemy/UltraLiteDB))
* Ultra-Fast (1,000,000 random operations / ~100ms on M2 MacBook Pro)
* Minimal-Allocation (~2KB for serialization of 1,000,000 items)
* Thread-Safe and Concurrent
* ACID compliant on transaction level
* Type-Safe (no reflection - compile-time enforced via source-generated `JsonSerializerContext`)
* Cross-Platform and Fully AOT-compatible
* Super-Easy API near mirroring of `Dictionary<TKey, TValue>`

Information on usage can be found in the [README](https://github.com/dusrdev/ArrowDb/blob/stable/README.md).
59 changes: 59 additions & 0 deletions src/ArrowDbCore/Serializers/AesFileSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

namespace ArrowDbCore.Serializers;

/// <summary>
/// An <see cref="Aes"/> managed file/disk backed serializer
/// </summary>
public sealed class AesFileSerializer : IDbSerializer {
/// <summary>
/// The path to the file
/// </summary>
private readonly string _path;

/// <summary>
/// The Aes instance
/// </summary>
private readonly Aes _aes;

/// <summary>
/// The json type info for the dictionary
/// </summary>
private readonly JsonTypeInfo<ConcurrentDictionary<string, byte[]>> _jsonTypeInfo;

/// <summary>
/// Initializes a new instance of the <see cref="AesFileSerializer"/> class.
/// </summary>
/// <param name="path">The path to the file</param>
/// <param name="aes">The <see cref="Aes"/> instance to use</param>
/// <param name="jsonTypeInfo">The json type info for the dictionary</param>
public AesFileSerializer(string path, Aes aes, JsonTypeInfo<ConcurrentDictionary<string, byte[]>> jsonTypeInfo) {
_path = path;
_aes = aes;
_jsonTypeInfo = jsonTypeInfo;
}

/// <inheritdoc />
public ValueTask<ConcurrentDictionary<string, byte[]>> DeserializeAsync() {
if (!File.Exists(_path) || new FileInfo(_path).Length == 0) {
return ValueTask.FromResult(new ConcurrentDictionary<string, byte[]>());
}
using var fileStream = File.OpenRead(_path);
using var decryptor = _aes.CreateDecryptor();
using var cryptoStream = new CryptoStream(fileStream, decryptor, CryptoStreamMode.Read);
var res = JsonSerializer.Deserialize(cryptoStream, _jsonTypeInfo);
return ValueTask.FromResult(res ?? new ConcurrentDictionary<string, byte[]>());
}

/// <inheritdoc />
public ValueTask SerializeAsync(ConcurrentDictionary<string, byte[]> data) {
using var fileStream = File.Create(_path);
using var encryptor = _aes.CreateEncryptor();
using var cryptoStream = new CryptoStream(fileStream, encryptor, CryptoStreamMode.Write);
JsonSerializer.Serialize(cryptoStream, data, _jsonTypeInfo);
return ValueTask.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

namespace ArrowDbCore;

namespace ArrowDbCore.Serializers;

/// <summary>
/// A file/disk backed serializer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;

namespace ArrowDbCore;

namespace ArrowDbCore.Serializers;

/// <summary>
/// An in-memory serializer (does nothing)
Expand Down
28 changes: 21 additions & 7 deletions tests/ArrowDbCore.Tests.Integrity/LargeFile.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using Bogus;
using System.Security.Cryptography;

using Bogus;

namespace ArrowDbCore.Tests.Integrity;

public class LargeFile {
[Fact]
public async Task LargeFile_Passes_OneReadWriteCycle() {
private static async Task LargeFile_Passes_OneReadWriteCycle(string path, Func<ValueTask<ArrowDb>> factory) {
const int itemCount = 500_000;

var faker = new Faker<Person>();
Expand All @@ -15,11 +16,9 @@ public async Task LargeFile_Passes_OneReadWriteCycle() {
faker.RuleFor(p => p.IsMarried, (f, _) => f.Random.Bool());

var buffer = new char[256];

var path = Sharpify.Utils.Env.PathInBaseDirectory("long-test.db");
try {
// load the db
var db = await ArrowDb.CreateFromFile(path);
var db = await factory();
// clear
db.Clear();
// add items
Expand All @@ -32,7 +31,7 @@ public async Task LargeFile_Passes_OneReadWriteCycle() {
await db.SerializeAsync();
var actualCount = db.Count;
// try to load again
var db2 = await ArrowDb.CreateFromFile(path);
var db2 = await factory();
Assert.Equal(actualCount, db2.Count);
} finally {
if (File.Exists(path)) {
Expand All @@ -42,4 +41,19 @@ public async Task LargeFile_Passes_OneReadWriteCycle() {

// this test fails if an exception is thrown
}

[Fact]
public async Task LargeFile_Passes_OneReadWriteCycle_FileSerializer() {
var path = Sharpify.Utils.Env.PathInBaseDirectory("long-test-file-serializer.db");
await LargeFile_Passes_OneReadWriteCycle(path, () => ArrowDb.CreateFromFile(path));
}

[Fact]
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));
}
}
Loading