From 623df458927e9382f4ac8db7424c3451f9316a51 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 19 Feb 2025 17:03:55 +0200 Subject: [PATCH 1/7] Added string based overloads for upsert --- src/ArrowDbCore/ArrowDb.Upsert.cs | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/ArrowDbCore/ArrowDb.Upsert.cs b/src/ArrowDbCore/ArrowDb.Upsert.cs index 995ec83..ce9cd1b 100644 --- a/src/ArrowDbCore/ArrowDb.Upsert.cs +++ b/src/ArrowDbCore/ArrowDb.Upsert.cs @@ -12,6 +12,25 @@ public partial class ArrowDb { /// The value to upsert /// The json type info for the value type /// True + public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo) { + WaitIfSerializing(); // block if the database is currently serializing + byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); + Source[key] = utf8value; + OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // trigger change event + return true; + } + + /// + /// Upsert the specified key with the specified value into the database + /// + /// The type of the value to upsert + /// The key at which to upsert the value + /// The value to upsert + /// The json type info for the value type + /// True + /// + /// 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) { WaitIfSerializing(); // block if the database is currently serializing byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); @@ -37,6 +56,34 @@ public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo2. A value for the specified key exists and successfully deserialized to /// 3. on the reference value returns false /// + public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition) { + if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && + !updateCondition(existingReference)) { + return false; + } + return Upsert(key, value, jsonTypeInfo); + } + + /// + /// Tries to upsert the specified key with the specified value into the database + /// + /// The type of the value to upsert + /// The key at which to upsert the value + /// The value to upsert + /// The json type info for the value type + /// A conditional check that determines whether this update should be performed + /// True if the value was upserted, false otherwise + /// + /// + /// can be used to resolve write conflicts, the update will be rejected only if all of the following conditions are met: + /// + /// 1. is not null + /// 2. A value for the specified key exists and successfully deserialized to + /// 3. on the reference value returns false + /// + /// 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) { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && !updateCondition(existingReference)) { From e15053b67e90faffb65655d15bd1527eb0bb2705 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 19 Feb 2025 17:04:03 +0200 Subject: [PATCH 2/7] Added GetOrAddAsync --- src/ArrowDbCore/ArrowDb.GetOrAdd.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/ArrowDbCore/ArrowDb.GetOrAdd.cs diff --git a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs new file mode 100644 index 0000000..93b58be --- /dev/null +++ b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace ArrowDbCore; + +public partial class ArrowDb { + /// + /// Tries to retrieve a value stored in the database under , if doesn't exist, it uses the factory to create and add it, then returns it. + /// + /// The type of the value to get or add + /// 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 + /// The value after finding or adding it + /// + /// + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> factory) { + if (Lookup.TryGetValue(key, out var source)) { + return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; + } + var val = await factory(key); + Upsert(key, val, jsonTypeInfo); + return val; + } +} From 86f9cc41f83c7401666b36c594d330d00254eaff Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 19 Feb 2025 17:04:59 +0200 Subject: [PATCH 3/7] Added GetOrAddAsync --- src/ArrowDbCore/ArrowDb.GetOrAdd.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs index 93b58be..2e496a2 100644 --- a/src/ArrowDbCore/ArrowDb.GetOrAdd.cs +++ b/src/ArrowDbCore/ArrowDb.GetOrAdd.cs @@ -10,15 +10,15 @@ public partial class ArrowDb { /// The type of the value to get or add /// 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 + /// The function used to generate a value for the key /// The value after finding or adding it /// /// - public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> factory) { + public async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory) { if (Lookup.TryGetValue(key, out var source)) { return JsonSerializer.Deserialize(new ReadOnlySpan(source), jsonTypeInfo)!; } - var val = await factory(key); + var val = await valueFactory(key); Upsert(key, val, jsonTypeInfo); return val; } From 0a0b4d95a9b7144ff400f246f7ef0920d40d3a13 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 19 Feb 2025 17:05:11 +0200 Subject: [PATCH 4/7] Updated tests --- tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs | 34 +++++++++++ tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs | 56 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs create mode 100644 tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs diff --git a/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs new file mode 100644 index 0000000..7af0365 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/GetOrAddAsync.cs @@ -0,0 +1,34 @@ +namespace ArrowDbCore.Tests.Unit; + +public class GetOrAddAsync { +#pragma warning disable xUnit1031 // Do not use blocking task operations in test method +// this is required here for testing purposes + [Fact] + public async Task GetOrAddAsync_ReturnsSynchronously_WhenExists() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + db.Upsert("1", 1, JContext.Default.Int32); // add before + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async _ => { + await Task.Delay(1000); + return 1; + }); + Assert.True(task.IsCompletedSuccessfully); + + Assert.Equal(1, task.GetAwaiter().GetResult()); + + } +#pragma warning restore xUnit1031 // Do not use blocking task operations in test method + + [Fact] + public async Task GetOrAddAsync_ReturnsAsynchronously_WhenNotExists() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + // doesn't exist + var task = db.GetOrAddAsync("1", JContext.Default.Int32, async _ => { + await Task.Delay(1000); + return 1; + }); + Assert.False(task.IsCompletedSuccessfully); + Assert.Equal(1, await task); + } +} \ No newline at end of file diff --git a/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs b/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs new file mode 100644 index 0000000..3f72936 --- /dev/null +++ b/tests/ArrowDbCore.Tests.Unit/Upserts.Spans.cs @@ -0,0 +1,56 @@ +namespace ArrowDbCore.Tests.Unit; + +public class Upserts_Spans { + [Fact] + public async Task Upsert_Span_When_Not_Found_Inserts() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + ReadOnlySpan key = "1"; + db.Upsert(key, 1, JContext.Default.Int32); + Assert.True(db.ContainsKey(key)); + Assert.Equal(1, db.Count); + } + + [Fact] + public async Task Upsert_Span_When_Found_Overwrites() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + ReadOnlySpan key = "1"; + db.Upsert(key, 1, JContext.Default.Int32); + Assert.True(db.ContainsKey(key)); + Assert.Equal(1, db.Count); + db.Upsert(key, 2, JContext.Default.Int32); + Assert.True(db.TryGetValue(key, JContext.Default.Int32, out var value)); + Assert.Equal(2, value); + } + + [Fact] + public async Task Conditional_Update_When_Not_Found_Inserts() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + ReadOnlySpan key = "1"; + Assert.True(db.Upsert(key, 1, JContext.Default.Int32, reference => reference == 3)); + } + + [Fact] + public async Task Conditional_Update_When_Found_And_Valid_Updates() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + ReadOnlySpan key = "1"; + db.Upsert(key, 1, JContext.Default.Int32); + Assert.True(db.Upsert(key, 3, JContext.Default.Int32, reference => reference == 1)); + Assert.True(db.TryGetValue(key, JContext.Default.Int32, out var value)); + Assert.Equal(3, value); + } + + [Fact] + public async Task Conditional_Update_When_Found_And_Invalid_Returns_False() { + var db = await ArrowDb.CreateInMemory(); + Assert.Equal(0, db.Count); + ReadOnlySpan key = "1"; + db.Upsert(key, 1, JContext.Default.Int32); + Assert.False(db.Upsert(key, 3, JContext.Default.Int32, reference => reference == 3)); + Assert.True(db.TryGetValue(key, JContext.Default.Int32, out var value)); + Assert.Equal(1, value); + } +} \ No newline at end of file From 4f31616ee17653e79e78e8f8f217a04fd6fd5de5 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 19 Feb 2025 17:28:07 +0200 Subject: [PATCH 5/7] Updated version and docs --- CHANGELOG.md | 5 +++++ README.md | 26 ++++++++++++++++++++++++-- src/ArrowDbCore/ArrowDbCore.csproj | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459f6e1..84d5105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog (Sorted by Date in Descending Order) +## 1.3.0.0 + +* Added overloads of `Upsert` that accept a `string` key, while the `ReadOnlySpan` overloads are amazing in specific cases where its use can prevent a string allocation for the lookup, in other places where the input was originally a `string` that was implicitly converted to a `ReadOnlySpan` for the parameter, this would've caused a copy to be allocated for the key when the key did not exist. The same scenario will now use the `string` overload and use it for the key directly, avoiding the intermediate copy. +* Added a `ValueTask` based `GetOrAddAsync` method, commonly used in caching scenarios. + ## 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. diff --git a/README.md b/README.md index 1bc0ca5..e07666c 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,23 @@ bool db.TryGetValue(ReadOnlySpan key, JsonTypeInfo jsonTyp Notice that all APIs accept keys as `ReadOnlySpan` 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 2 overloads: +Upserting (adding or updating) is done via 4 overloads: ```csharp +bool db.Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo); bool db.Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo); -bool db.Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition = null); // upserts a value into the ArrowDb instance +bool db.Upsert(string, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition = null); +bool db.Upsert(ReadOnlySpan key, TValue value, JsonTypeInfo jsonTypeInfo, Func updateCondition = null); ``` +### Upsert Overloads Best Practices + +As noted above, There are 2 main upsert methods, but each has 2 options, a `ReadOnlySpan` key, or a `string` key. + +The `ReadOnlySpan` methods are best used for scenarios where the key is generated using a slice of a string (whether from regular string, stackallocated buffers, interop and so and forth), and cases where the value for the same key is being frequently updated, in which case this method will replace the value without allocating the key. + +The `string` methods are best for addition and cases where the parameter in the caller method is already of type `string`, in these case the direct use of `string` will prevent a `string` allocation by the lookup. + And removal: ```csharp @@ -194,6 +204,18 @@ 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). ``` +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. + +`ArrowDb` supports this via the `async ValueTask` method: + +```csharp +async ValueTask GetOrAddAsync(string key, JsonTypeInfo jsonTypeInfo, Func> valueFactory); +``` + +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`. + ## 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. diff --git a/src/ArrowDbCore/ArrowDbCore.csproj b/src/ArrowDbCore/ArrowDbCore.csproj index 2e9e0b3..20f8a0b 100644 --- a/src/ArrowDbCore/ArrowDbCore.csproj +++ b/src/ArrowDbCore/ArrowDbCore.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - 1.2.0.0 + 1.3.0.0 true From 8d0184a0a654616c2275d514ee4cd890bdfc0d5d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 21 Feb 2025 08:51:16 +0200 Subject: [PATCH 6/7] Added IDictionaryAccessor and implementations --- .../ArrowDb.IDictionaryAccessor.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs diff --git a/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs b/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs new file mode 100644 index 0000000..b17b369 --- /dev/null +++ b/src/ArrowDbCore/ArrowDb.IDictionaryAccessor.cs @@ -0,0 +1,37 @@ +namespace ArrowDbCore; + +public partial class ArrowDb { + /// + /// Provides an interface that unifies methods of upserting values to ArrowDb + /// + /// + private interface IDictionaryAccessor where TKey : allows ref struct { + /// + /// Assigns the to the in + /// + /// The ArrowDb instance + /// The key to use + /// The value to add/update + void Upsert(ArrowDb instance, TKey key, byte[] value); + } + + /// + /// Implements by using the source dictionary directly + /// + private readonly ref struct StringAccessor : IDictionaryAccessor { + /// + public void Upsert(ArrowDb instance, string key, byte[] value) { + instance.Source[key] = value; + } + } + + /// + /// Implements by using the lookup + /// + private readonly ref struct ReadOnlySpanAccessor : IDictionaryAccessor> { + /// + public void Upsert(ArrowDb instance, ReadOnlySpan key, byte[] value) { + instance.Lookup[key] = value; + } + } +} From 3628aefa86513f286cd5dee6875ebee9440d959b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 21 Feb 2025 08:51:38 +0200 Subject: [PATCH 7/7] Unified Upsert methods via the new IDictionaryAccessor --- src/ArrowDbCore/ArrowDb.Upsert.cs | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/ArrowDbCore/ArrowDb.Upsert.cs b/src/ArrowDbCore/ArrowDb.Upsert.cs index ce9cd1b..86fdc97 100644 --- a/src/ArrowDbCore/ArrowDb.Upsert.cs +++ b/src/ArrowDbCore/ArrowDb.Upsert.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ArrowDbCore; @@ -13,11 +14,7 @@ public partial class ArrowDb { /// The json type info for the value type /// True public bool Upsert(string key, TValue value, JsonTypeInfo jsonTypeInfo) { - WaitIfSerializing(); // block if the database is currently serializing - byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); - Source[key] = utf8value; - OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // trigger change event - return true; + return UpsertCore(key, value, jsonTypeInfo, default); } /// @@ -32,10 +29,17 @@ 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) { - WaitIfSerializing(); // block if the database is currently serializing - byte[] utf8value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); - Lookup[key] = utf8value; - OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // trigger change event + 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 { + WaitIfSerializing(); // Block if serializing + byte[] utf8Value = JsonSerializer.SerializeToUtf8Bytes(value, jsonTypeInfo); + accessor.Upsert(this, key, utf8Value); + OnChangeInternal(ArrowDbChangeEventArgs.Upsert); // Trigger change event return true; } @@ -50,11 +54,10 @@ public bool Upsert(ReadOnlySpan key, TValue value, JsonTypeInfoTrue if the value was upserted, false otherwise /// /// - /// can be used to resolve write conflicts, the update will be rejected only if all of the following conditions are met: + /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// - /// 1. is not null - /// 2. A value for the specified key exists and successfully deserialized to - /// 3. on the reference value returns false + /// 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) { if (TryGetValue(key, jsonTypeInfo, out TValue existingReference) && @@ -75,11 +78,10 @@ public bool Upsert(string key, TValue value, JsonTypeInfo jsonTy /// True if the value was upserted, false otherwise /// /// - /// can be used to resolve write conflicts, the update will be rejected only if all of the following conditions are met: + /// can be used to resolve write conflicts, the update will be rejected only if both conditions are met: /// - /// 1. is not null - /// 2. A value for the specified key exists and successfully deserialized to - /// 3. on the reference value returns false + /// 1. A value for the specified key exists and successfully deserialized to + /// 2. on the reference value returns false /// /// 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 ///