diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs
index 5e79220ccf8..6711852448a 100644
--- a/sdks/csharp/examples~/regression-tests/client/Program.cs
+++ b/sdks/csharp/examples~/regression-tests/client/Program.cs
@@ -83,7 +83,8 @@ void ValidateBTreeIndexes(IRemoteDbContext conn)
Log.Debug("Checking indexes...");
foreach (var data in conn.Db.ExampleData.Iter())
{
- Debug.Assert(conn.Db.ExampleData.Indexed.Filter(data.Id).Contains(data));
+ Log.Debug($"{data}: [{string.Join(", ", conn.Db.ExampleData.Indexed.Filter(data.Indexed))}]");
+ Debug.Assert(conn.Db.ExampleData.Indexed.Filter(data.Indexed).Contains(data));
}
var outOfIndex = conn.Db.ExampleData.Iter().ToHashSet();
@@ -107,10 +108,38 @@ void OnSubscriptionApplied(SubscriptionEventContext context)
waiting++;
context.Reducers.Add(1, 1);
+ Log.Debug("Calling Add");
+ waiting++;
+ context.Reducers.Add(2, 1);
+
+ Log.Debug("Calling Add");
+ waiting++;
+ context.Reducers.Add(3, 1);
+
+ Log.Debug("Calling Add");
+ waiting++;
+ context.Reducers.Add(4, 2);
+
+ Log.Debug("Calling Add");
+ waiting++;
+ context.Reducers.Add(6, 2);
+
+ Log.Debug("Calling Add");
+ waiting++;
+ context.Reducers.Add(5, 2);
+
Log.Debug("Calling Delete");
waiting++;
context.Reducers.Delete(1);
+ Log.Debug("Calling Delete");
+ waiting++;
+ context.Reducers.Delete(2);
+
+ Log.Debug("Calling Delete");
+ waiting++;
+ context.Reducers.Delete(3);
+
Log.Debug("Calling Add");
waiting++;
context.Reducers.Add(1, 1);
diff --git a/sdks/csharp/src/MultiDictionary.cs b/sdks/csharp/src/MultiDictionary.cs
index 2225f336494..4ab49772652 100644
--- a/sdks/csharp/src/MultiDictionary.cs
+++ b/sdks/csharp/src/MultiDictionary.cs
@@ -18,6 +18,7 @@ namespace SpacetimeDB
///
///
internal struct MultiDictionary : IEquatable>
+ where TValue : struct
{
// The actual data.
readonly Dictionary RawDict;
@@ -66,10 +67,6 @@ public static MultiDictionary FromEnumerable(IEnumerableWhether the key is entirely new to the dictionary. If it was already present, we assert that the old value is equal to the new value.
public bool Add(TKey key, TValue value)
{
- if (value == null)
- {
- throw new NullReferenceException("Null values are forbidden in multidictionary");
- }
Debug.Assert(RawDict != null);
Debug.Assert(key != null);
if (RawDict.TryGetValue(key, out var result))
@@ -362,6 +359,7 @@ public override string ToString()
///
///
internal struct MultiDictionaryDelta : IEquatable>
+ where TValue : struct
{
///
/// A change to an individual value associated to a key.
@@ -638,10 +636,6 @@ public MultiDictionaryDelta(IEqualityComparer keyComparer, IEqualityCompar
///
public void Add(TKey key, TValue value)
{
- if (value == null)
- {
- throw new NullReferenceException("Null values are forbidden in multidictionary");
- }
Debug.Assert(RawDict != null);
Debug.Assert(key != null);
KeyDelta result;
diff --git a/sdks/csharp/src/SmallHashSet.cs b/sdks/csharp/src/SmallHashSet.cs
new file mode 100644
index 00000000000..04d0d49be46
--- /dev/null
+++ b/sdks/csharp/src/SmallHashSet.cs
@@ -0,0 +1,352 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+#nullable enable
+
+///
+/// A hashset optimized to store small numbers of values of type T.
+/// Used because many of the hash sets in our BTreeIndexes store only one value.
+///
+///
+internal struct SmallHashSet : IEnumerable
+where T : struct
+where EQ : IEqualityComparer, new()
+{
+ static readonly EQ DefaultEqualityComparer = new();
+ static readonly ThreadLocal>> Pool = new(() => new(), false);
+
+ // Assuming each HashSet uses 128 bytes of memory, this means
+ // our pool will use ~64 MB of memory per thread when full.
+ // However, the HashSets in use will only grow, so the pool may get larger over time.
+ static readonly int MAX_POOL_SIZE = 500_000;
+
+ // Invariant: zero or one of the following is not null.
+ T? Value;
+ HashSet? Values;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Add(T newValue)
+ {
+ if (Values == null)
+ {
+ if (Value == null)
+ {
+ Value = newValue;
+ }
+ else
+ {
+ Values = AllocHashSet();
+ Values.Add(newValue);
+ Values.Add(Value.Value);
+ Value = null;
+ }
+ }
+ else
+ {
+ Values.Add(newValue);
+ }
+ }
+
+ public void Remove(T remValue)
+ {
+ if (Value != null && DefaultEqualityComparer.Equals(Value.Value, remValue))
+ {
+ Value = null;
+ }
+ else if (Values != null && Values.Contains(remValue))
+ {
+ Values.Remove(remValue);
+
+ // If we're not storing values and there's room in the pool, reuse this allocation.
+ // Otherwise, we can keep the allocation around: all of the logic in this class will still
+ // work.
+ var LocalPool = Pool.Value;
+ if (Values.Count == 0 && LocalPool.Count < MAX_POOL_SIZE)
+ {
+ LocalPool.Push(Values);
+ Values = null;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool Contains(T value)
+ {
+ if (Values != null)
+ {
+ return Values.Contains(value);
+ }
+ else if (Value != null)
+ {
+ return DefaultEqualityComparer.Equals(Value.Value, value);
+ }
+ return false;
+ }
+
+ public int Count
+ {
+ get
+ {
+ if (Value != null)
+ {
+ return 1;
+ }
+ else if (Values != null)
+ {
+ return Values.Count;
+ }
+ return 0;
+ }
+
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ if (Value != null)
+ {
+ return new SingleElementEnumerator(Value.Value);
+ }
+ else if (Values != null)
+ {
+ return Values.GetEnumerator();
+ }
+ else
+ {
+ return new NoElementEnumerator();
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ ///
+ /// Allocate a new HashSet with the capacity to store at least 2 elements.
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static HashSet AllocHashSet()
+ {
+ if (Pool.Value.TryPop(out var result))
+ {
+ return result;
+ }
+ else
+ {
+ return new(2, DefaultEqualityComparer);
+ }
+ }
+}
+
+///
+/// This is a silly object.
+///
+///
+internal struct SingleElementEnumerator : IEnumerator
+where T : struct
+{
+ T value;
+ enum State
+ {
+ Unstarted,
+ Started,
+ Finished
+ }
+
+ State state;
+
+ public SingleElementEnumerator(T value)
+ {
+ this.value = value;
+ state = State.Unstarted;
+ }
+
+ public T Current => value;
+
+ object IEnumerator.Current => Current;
+
+ public void Dispose()
+ {
+ }
+
+ public bool MoveNext()
+ {
+ if (state == State.Unstarted)
+ {
+ state = State.Started;
+ return true;
+ }
+ else if (state == State.Started)
+ {
+ state = State.Finished;
+ return false;
+ }
+ return false;
+ }
+
+ public void Reset()
+ {
+ state = State.Started;
+ }
+}
+
+///
+/// This is a very silly object.
+///
+///
+internal struct NoElementEnumerator : IEnumerator
+where T : struct
+{
+ public T Current => new();
+
+ object IEnumerator.Current => Current;
+
+ public void Dispose()
+ {
+ }
+
+ public bool MoveNext()
+ {
+ return false;
+ }
+
+ public void Reset()
+ {
+ }
+}
+
+
+internal struct SmallHashSetOfPreHashedRow : IEnumerable
+{
+ static readonly ThreadLocal>> Pool = new(() => new(), false);
+
+ // Assuming each HashSet uses 128 bytes of memory, this means
+ // our pool will use at most 512 MB of memory per thread.
+ // Since in the current design of the SDK, only the main thread produces SmallHashSets,
+ // this should be fine.
+ static readonly int MAX_POOL_SIZE = 4_000_000;
+
+ // Invariant: zero or one of the following is not null.
+ PreHashedRow? Value;
+ HashSet? Values;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Add(PreHashedRow newValue)
+ {
+ if (Values == null)
+ {
+ if (Value == null)
+ {
+ Value = newValue;
+ }
+ else
+ {
+ Values = AllocHashSet();
+ Values.Add(newValue);
+ Values.Add(Value.Value);
+ Value = null;
+ }
+ }
+ else
+ {
+ Values.Add(newValue);
+ }
+ }
+
+ public void Remove(PreHashedRow remValue)
+ {
+ if (Value != null && Value.Value.Equals(remValue))
+ {
+ Value = null;
+ }
+ else if (Values != null && Values.Contains(remValue))
+ {
+ Values.Remove(remValue);
+
+ // If we're not storing values and there's room in the pool, reuse this allocation.
+ // Otherwise, we can keep the allocation around: all of the logic in this class will still
+ // work.
+ var LocalPool = Pool.Value;
+ if (Values.Count == 0 && LocalPool.Count < MAX_POOL_SIZE)
+ {
+ LocalPool.Push(Values);
+ Values = null;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool Contains(PreHashedRow value)
+ {
+ if (Values != null)
+ {
+ return Values.Contains(value);
+ }
+ else if (Value != null)
+ {
+ return Value.Value.Equals(value);
+ }
+ return false;
+ }
+
+ public int Count
+ {
+ get
+ {
+ if (Value != null)
+ {
+ return 1;
+ }
+ else if (Values != null)
+ {
+ return Values.Count;
+ }
+ return 0;
+ }
+
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ if (Value != null)
+ {
+ return new SingleElementEnumerator(Value.Value);
+ }
+ else if (Values != null)
+ {
+ return Values.GetEnumerator();
+ }
+ else
+ {
+ return new NoElementEnumerator();
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ ///
+ /// Allocate a new HashSet with the capacity to store at least 2 elements.
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static HashSet AllocHashSet()
+ {
+ if (Pool.Value.TryPop(out var result))
+ {
+ return result;
+ }
+ else
+ {
+ return new(2, PreHashedRowComparer.Default);
+ }
+ }
+}
+
+
+#nullable disable
\ No newline at end of file
diff --git a/sdks/csharp/src/SmallHashSet.cs.meta b/sdks/csharp/src/SmallHashSet.cs.meta
new file mode 100644
index 00000000000..6cb2204aea6
--- /dev/null
+++ b/sdks/csharp/src/SmallHashSet.cs.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 38df61787d652154bb082d3ddbb49869
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs
index c19f05b2ed4..6cd7184c6fa 100644
--- a/sdks/csharp/src/SpacetimeDBClient.cs
+++ b/sdks/csharp/src/SpacetimeDBClient.cs
@@ -226,7 +226,7 @@ internal struct ParsedDatabaseUpdate
// Map: table handles -> (primary key -> IStructuralReadWrite).
// If a particular table has no primary key, the "primary key" is just the row itself.
// This is valid because any [SpacetimeDB.Type] automatically has a correct Equals and HashSet implementation.
- public Dictionary> Updates;
+ public Dictionary> Updates;
// Can't override the default constructor. Make sure you use this one!
public static ParsedDatabaseUpdate New()
@@ -236,11 +236,11 @@ public static ParsedDatabaseUpdate New()
return result;
}
- public MultiDictionaryDelta