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
52 changes: 0 additions & 52 deletions ArrowDbCore.sln

This file was deleted.

13 changes: 13 additions & 0 deletions ArrowDbCore.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Solution>
<Folder Name="/benchmarks/">
<Project Path="benchmarks/ArrowDbCore.Benchmarks/ArrowDbCore.Benchmarks.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/ArrowDbCore/ArrowDbCore.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ArrowDbCore.Tests.Analyzers/ArrowDbCore.Tests.Analyzers.csproj" />
<Project Path="tests/ArrowDbCore.Tests.Integrity/ArrowDbCore.Tests.Integrity.csproj" />
<Project Path="tests/ArrowDbCore.Tests.Unit/ArrowDbCore.Tests.Unit.csproj" />
</Folder>
</Solution>
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog (Sorted by Date in Descending Order)

## 1.4.0.0

* `GetOrAddAsync` and `Upsert` (which has the `updateCondition` argument) now both have overloads that accept a `TArg` parameter as well as a modified factory function that can use it, in order to avoid closures.

## 1.3.0.0

* Added overloads of `Upsert` that accept a `string` key, while the `ReadOnlySpan<char>` overloads are amazing in specific cases where its use can prevent a string allocation for the lookup, in other places where the input was originally a `string` that was implicitly converted to a `ReadOnlySpan<char>` for the parameter, this would've caused a copy to be allocated for the key when the key did not exist. The same scenario will now use the `string` overload and use it for the key directly, avoiding the intermediate copy.
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ 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 4 overloads:
Upserting (adding or updating) is done via 6 overloads:

```csharp
bool db.Upsert<TValue>(string key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo);
bool db.Upsert<TValue>(string, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition);
bool Upsert<TValue, TArg>(string key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, TArg, bool> updateCondition, TArg updateConditionArgument);
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo);
bool db.Upsert<TValue>(string, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition = null);
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition = null);
bool db.Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, bool> updateCondition);
bool Upsert<TValue, TArg>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, TArg, bool> updateCondition, TArg updateConditionArgument);
```

### Upsert Overloads Best Practices
Expand Down Expand Up @@ -161,6 +163,30 @@ do {

As the example shows retries is the usual way to resolve these conflicts, but custom logic can also be used, you can simply reject the operation, and also use other loops or even `goto` statements if you are brave enough.

In this example, `referenceDate` is a local value, and when used inside the lambda of the `updateCondition` it allocates a [Closure](https://www.youtube.com/watch?v=h3MsnBRqzcY), which depending on whether is a performance critical code section, could be sub-optimal. To address this, a secondary overload is available:

```csharp
bool Upsert<TValue, TArg>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, TArg, bool> updateCondition, TArg updateConditionArgument)

// adapt example to use this
bool noteUpdated = false; // track if conflict was resolved
do {
if (!db.TryGetValue("shopping list", MyJsonContext.Default.Note, out Note? note)) {
// note does not exist, I am skipping this condition as it is not part of the example
}
// we are here, so previous note was found
var referenceDate = note.LastUpdatedUTC; // locally store the reference
note!.Content += "Pizza"; // modify the note
note.LastUpdatedUTC = TimeProvider.System.UtcNow; // update note timestamp
// update on condition that the stored reference is still the same, by checking the timestamp
if (db.Upsert("shopping list", note, MyJsonContext.Default.Note, (reference, date) => reference.LastUpdatedUTC == date), referenceDate) {
noteUpdated = true; // note was updated - this will break out of the loop
}
} while (!noteUpdated);
```

Using the overload we created a different lambda, in which there are no 2 input arguments, one of which is the date to check against, and the scope of the lambda only uses its parameters, which in turn means that no class has to be allocated for the closure, and instead the compiler will generate the lambda as a static method, the argument would then be forwarded from `Upsert` into the lambda during runtime. Avoiding the performance penalty of allocating a closure class for each call.

## `ReadOnlySpan<char>` Key Generation

`ArrowDb` APIs use `ReadOnlySpan<char>` for keys to minimize unnecessary string allocations. Usually using the API with `Upsert` doesn't require specific logic as string can also be interpreted as `ReadOnlySpan<char>`, however when checking if a key exists or removing keys, usually you don't have pre-existing reference to the key, which means you have to use rather low level APIs to efficiently generate a `ReadOnlySpan<char>` key.
Expand Down Expand Up @@ -210,12 +236,15 @@ A common code pattern for caching usually consists of some `GetOrAdd` method, th

```csharp
async ValueTask<TValue> GetOrAddAsync<TValue>(string key, JsonTypeInfo<TValue> jsonTypeInfo, Func<string, ValueTask<TValue>> valueFactory);
async ValueTask<TValue> GetOrAddAsync<TValue, TArg>(string key, JsonTypeInfo<TValue> jsonTypeInfo, Func<string, TArg, ValueTask<TValue>> valueFactory, TArg factoryArgument);
```

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.

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`.

Similarly to `Upsert` - `GetOrAddAsync` also has an overload that accepts `TArg` and and enables closure free execution for optimal performance.

## 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.
Expand Down
23 changes: 22 additions & 1 deletion src/ArrowDbCore/ArrowDb.GetOrAdd.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace ArrowDbCore;

public partial class ArrowDb {
/// <summary>
/// Tries to retrieve a value stored in the database under <paramref name="key"/>, if doesn't exist, it uses the factory to create and add it, then returns it.
/// Tries to retrieve a value stored in the database under <paramref name="key"/>, if it doesn't exist, it uses the factory to create and add it, then returns it.
/// </summary>
/// <typeparam name="TValue">The type of the value to get or add</typeparam>
/// <param name="key">The key at which to find or add the value</param>
Expand All @@ -22,4 +22,25 @@ public async ValueTask<TValue> GetOrAddAsync<TValue>(string key, JsonTypeInfo<TV
Upsert(key, val, jsonTypeInfo);
return val;
}

/// <summary>
/// Tries to retrieve a value stored in the database under <paramref name="key"/>, if it doesn't exist, it uses the factory to create and add it, then returns it.
/// </summary>
/// <typeparam name="TValue">The type of the value to get or add</typeparam>
/// <typeparam name="TArg">The type of the argument for the updateCondition function</typeparam>
/// <param name="key">The key at which to find or add the value</param>
/// <param name="jsonTypeInfo">The json type info for the value type</param>
/// <param name="valueFactory">The function used to generate a value for the key</param>
/// <param name="factoryArgument">An argument that could be provided to the valueFactory function to avoid a closure</param>
/// <returns>The value after finding or adding it</returns>
/// <remarks>
/// </remarks>
public async ValueTask<TValue> GetOrAddAsync<TValue, TArg>(string key, JsonTypeInfo<TValue> jsonTypeInfo, Func<string, TArg, ValueTask<TValue>> valueFactory, TArg factoryArgument) {
if (Lookup.TryGetValue(key, out var source)) {
return JsonSerializer.Deserialize(new ReadOnlySpan<byte>(source), jsonTypeInfo)!;
}
var val = await valueFactory(key, factoryArgument);
Upsert(key, val, jsonTypeInfo);
return val;
}
}
55 changes: 55 additions & 0 deletions src/ArrowDbCore/ArrowDb.Upsert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ public bool Upsert<TValue>(string key, TValue value, JsonTypeInfo<TValue> jsonTy
return Upsert(key, value, jsonTypeInfo);
}

/// <summary>
/// Tries to upsert the specified key with the specified value into the database
/// </summary>
/// <typeparam name="TValue">The type of the value to upsert</typeparam>
/// <typeparam name="TArg">The type of the argument for the updateCondition function</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>
/// <param name="updateCondition">A conditional check that determines whether this update should be performed</param>
/// <param name="updateConditionArgument">An argument that could be provided to the updateCondition function to avoid a closure</param>
/// <returns>True if the value was upserted, false otherwise</returns>
/// <remarks>
/// <para>
/// <paramref name="updateCondition"/> can be used to resolve write conflicts, the update will be rejected only if both conditions are met:
/// </para>
/// <para>1. A value for the specified key exists and successfully deserialized to <typeparamref name="TValue"/></para>
/// <para>2. <paramref name="updateCondition"/> on the reference value returns false</para>
/// </remarks>
public bool Upsert<TValue, TArg>(string key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, TArg, bool> updateCondition, TArg updateConditionArgument) {
if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) &&
!updateCondition(existingReference, updateConditionArgument)) {
return false;
}
return Upsert(key, value, jsonTypeInfo);
}

/// <summary>
/// Tries to upsert the specified key with the specified value into the database
/// </summary>
Expand All @@ -93,4 +119,33 @@ public bool Upsert<TValue>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TV
}
return Upsert(key, value, jsonTypeInfo);
}

/// <summary>
/// Tries to upsert the specified key with the specified value into the database
/// </summary>
/// <typeparam name="TValue">The type of the value to upsert</typeparam>
/// <typeparam name="TArg">The type of the argument for the updateCondition function</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>
/// <param name="updateCondition">A conditional check that determines whether this update should be performed</param>
/// <param name="updateConditionArgument">An argument that could be provided to the updateCondition function to avoid a closure</param>
/// <returns>True if the value was upserted, false otherwise</returns>
/// <remarks>
/// <para>
/// <paramref name="updateCondition"/> can be used to resolve write conflicts, the update will be rejected only if both conditions are met:
/// </para>
/// <para>1. A value for the specified key exists and successfully deserialized to <typeparamref name="TValue"/></para>
/// <para>2. <paramref name="updateCondition"/> on the reference value returns false</para>
/// <para>
/// 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
/// </para>
/// </remarks>
public bool Upsert<TValue, TArg>(ReadOnlySpan<char> key, TValue value, JsonTypeInfo<TValue> jsonTypeInfo, Func<TValue, TArg, bool> updateCondition, TArg updateConditionArgument) {
if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) &&
!updateCondition(existingReference, updateConditionArgument)) {
return false;
}
return Upsert(key, value, jsonTypeInfo);
}
}
2 changes: 1 addition & 1 deletion src/ArrowDbCore/ArrowDb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ private ArrowDb(ConcurrentDictionary<string, byte[]> source, IDbSerializer seria
/// </summary>
~ArrowDb() {
Interlocked.Decrement(ref s_runningInstances);
Semaphore?.Dispose();
Semaphore.Dispose();
}

/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion src/ArrowDbCore/ArrowDbCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.3.0.0</Version>
<Version>1.4.0.0</Version>
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>

<PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/ArrowDbCore/ArrowDbJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ namespace ArrowDbCore;
/// </summary>
[JsonSourceGenerationOptions(WriteIndented = false, AllowTrailingCommas = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true)]
[JsonSerializable(typeof(ConcurrentDictionary<string, byte[]>))]
public partial class ArrowDbJsonContext : JsonSerializerContext {}
public partial class ArrowDbJsonContext : JsonSerializerContext;
8 changes: 4 additions & 4 deletions src/ArrowDbCore/Serializers/AesFileSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ public ValueTask<ConcurrentDictionary<string, byte[]>> DeserializeAsync() {
/// <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;
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
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishTrimmed>true</PublishTrimmed>
<PublishAot>true</PublishAot>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../src/ArrowDbCore/ArrowDbCore.csproj" />
<TrimmerRootAssembly Include="ArrowDbCore" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions tests/ArrowDbCore.Tests.Analyzers/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Console.WriteLine("Hello, World!");
Loading