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 DeltaForTable(IRemoteTableHandle table) + public MultiDictionaryDelta DeltaForTable(IRemoteTableHandle table) { if (!Updates.TryGetValue(table, out var delta)) { - delta = new MultiDictionaryDelta(EqualityComparer.Default, EqualityComparer.Default); + delta = new MultiDictionaryDelta(EqualityComparer.Default, PreHashedRowComparer.Default); Updates[table] = delta; } @@ -278,13 +278,13 @@ internal struct ParsedMessage /// /// /// - internal static IStructuralReadWrite Decode(IRemoteTableHandle table, BinaryReader reader, out object primaryKey) + internal static PreHashedRow Decode(IRemoteTableHandle table, BinaryReader reader, out object primaryKey) { var obj = table.DecodeValue(reader); // TODO(1.1): we should exhaustively check that GenericEqualityComparer works // for all types that are allowed to be primary keys. - var primaryKey_ = table.GetPrimaryKey(obj); + var primaryKey_ = table.GetPrimaryKey(obj.Row); primaryKey_ ??= obj; primaryKey = primaryKey_; diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 4f90cf8276f..17f7373e6a7 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -28,14 +28,14 @@ public interface IRemoteTableHandle internal string RemoteTableName { get; } internal Type ClientTableType { get; } - internal IStructuralReadWrite DecodeValue(BinaryReader reader); + internal PreHashedRow DecodeValue(BinaryReader reader); /// /// Start applying a delta to the table. /// This is called for all tables before any updates are actually applied, allowing OnBeforeDelete to be invoked correctly. /// /// - internal void PreApply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta); + internal void PreApply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta); /// /// Apply a delta to the table. @@ -43,7 +43,7 @@ public interface IRemoteTableHandle /// Should fix up indices, to be ready for PostApply. /// /// - internal void Apply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta); + internal void Apply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta); /// /// Finish applying a delta to a table. @@ -76,52 +76,76 @@ public abstract class IndexBase public abstract class UniqueIndexBase : IndexBase where Column : IEquatable { - private readonly Dictionary cache = new(); + // This is not typed, to avoid the runtime overhead of generics. + // Despite that, every preHashedRow.Row in this cache is guaranteed to be of type Row. + private readonly Dictionary cache = new(); public UniqueIndexBase(RemoteTableHandle table) { - table.OnInternalInsert += row => cache.Add(GetKey(row), row); - table.OnInternalDelete += row => cache.Remove(GetKey(row)); + // Guaranteed to be a valid cast by contract of OnInternalInsert. + table.OnInternalInsert += row => cache.Add(GetKey((Row)row.Row), row); + // Guaranteed to be a valid cast by contract of OnInternalDelete. + table.OnInternalDelete += row => cache.Remove(GetKey((Row)row.Row)); } - public Row? Find(Column value) => cache.TryGetValue(value, out var row) ? row : null; + public Row? Find(Column value) => cache.TryGetValue(value, out var row) ? (Row)row.Row : null; } public abstract class BTreeIndexBase : IndexBase where Column : IEquatable, IComparable { // TODO: change to SortedDictionary when adding support for range queries. - private readonly Dictionary> cache = new(); + private readonly Dictionary cache = new(); public BTreeIndexBase(RemoteTableHandle table) { - table.OnInternalInsert += row => + table.OnInternalInsert += preHashed => { + // Guaranteed to be a valid cast by contract of OnInternalInsert. + var row = (Row)preHashed.Row; var key = GetKey(row); - if (!cache.TryGetValue(key, out var rows)) + if (cache.TryGetValue(key, out var rows)) { - rows = new(); + rows.Add(preHashed); + // Need to update the parent dictionary: rows is a mutable struct. + // Just updating the local `rows` variable won't update the parent dict. + cache[key] = rows; + } + else + { + rows = new() + { + preHashed + }; cache.Add(key, rows); } - rows.Add(row); }; - table.OnInternalDelete += row => + table.OnInternalDelete += preHashed => { + // Guaranteed to be a valid cast by contract of OnInternalDelete. + var row = (Row)preHashed.Row; var key = GetKey(row); var keyCache = cache[key]; - keyCache.Remove(row); + keyCache.Remove(preHashed); if (keyCache.Count == 0) { cache.Remove(key); } + else + { + // Need to update the parent dictionary: keyCache is a mutable struct. + // Just updating the local `keyCache` variable won't update the parent dict. + cache[key] = keyCache; + } }; } public IEnumerable Filter(Column value) => - cache.TryGetValue(value, out var rows) ? rows : Enumerable.Empty(); + cache.TryGetValue(value, out var rows) ? rows.Select(preHashed => (Row)preHashed.Row) : Enumerable.Empty(); } + protected abstract string RemoteTableName { get; } string IRemoteTableHandle.RemoteTableName => RemoteTableName; @@ -131,18 +155,20 @@ public RemoteTableHandle(IDbConnection conn) : base(conn) { } protected virtual object? GetPrimaryKey(Row row) => null; // These events are used by indices to add/remove rows to their dictionaries. + // They can assume the Row stored in the PreHashedRow passed is of the correct type; + // the check is done before performing these callbacks. // TODO: figure out if they can be merged into regular OnInsert / OnDelete. // I didn't do that because that delays the index updates until after the row is processed. // In theory, that shouldn't be the issue, but I didn't want to break it right before leaving :) // - Ingvar - private AbstractEventHandler OnInternalInsertHandler { get; } = new(); - private event Action OnInternalInsert + private AbstractEventHandler OnInternalInsertHandler { get; } = new(); + private event Action OnInternalInsert { add => OnInternalInsertHandler.AddListener(value); remove => OnInternalInsertHandler.RemoveListener(value); } - private AbstractEventHandler OnInternalDeleteHandler { get; } = new(); - private event Action OnInternalDelete + private AbstractEventHandler OnInternalDeleteHandler { get; } = new(); + private event Action OnInternalDelete { add => OnInternalDeleteHandler.AddListener(value); remove => OnInternalDeleteHandler.RemoveListener(value); @@ -159,7 +185,7 @@ private event Action OnInternalDelete // - Primary keys, if we have them. // - The entire row itself, if we don't. // But really, the keys are whatever SpacetimeDBClient chooses to give us. - private readonly MultiDictionary Entries = new(EqualityComparer.Default, EqualityComparer.Default); + private readonly MultiDictionary Entries = new(EqualityComparer.Default, PreHashedRowComparer.Default); private static IReadWrite? _serializer; @@ -185,7 +211,7 @@ private static IReadWrite Serializer } // The function to use for decoding a type value. - IStructuralReadWrite IRemoteTableHandle.DecodeValue(BinaryReader reader) => Serializer.Read(reader); + PreHashedRow IRemoteTableHandle.DecodeValue(BinaryReader reader) => new PreHashedRow(Serializer.Read(reader)); public delegate void RowEventHandler(EventContext context, Row row); private CustomRowEventHandler OnInsertHandler { get; } = new(); @@ -217,7 +243,7 @@ public event UpdateEventHandler OnUpdate public int Count => (int)Entries.CountDistinct; - public IEnumerable Iter() => Entries.Entries.Select(entry => (Row)entry.Value); + public IEnumerable Iter() => Entries.Entries.Select(entry => (Row)entry.Value.Row); public Task RemoteQuery(string query) => conn.RemoteQuery($"SELECT {RemoteTableName}.* FROM {RemoteTableName} {query}"); @@ -270,21 +296,21 @@ void InvokeUpdate(IEventContext context, IStructuralReadWrite oldRow, IStructura } } - List> wasInserted = new(); - List<(object key, IStructuralReadWrite oldValue, IStructuralReadWrite newValue)> wasUpdated = new(); - List> wasRemoved = new(); + List> wasInserted = new(); + List<(object key, PreHashedRow oldValue, PreHashedRow newValue)> wasUpdated = new(); + List> wasRemoved = new(); - void IRemoteTableHandle.PreApply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta) + void IRemoteTableHandle.PreApply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta) { Debug.Assert(wasInserted.Count == 0 && wasUpdated.Count == 0 && wasRemoved.Count == 0, "Call Apply and PostApply before calling PreApply again"); foreach (var (_, value) in Entries.WillRemove(multiDictionaryDelta)) { - InvokeBeforeDelete(context, value); + InvokeBeforeDelete(context, value.Row); } } - void IRemoteTableHandle.Apply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta) + void IRemoteTableHandle.Apply(IEventContext context, MultiDictionaryDelta multiDictionaryDelta) { try { @@ -305,9 +331,9 @@ void IRemoteTableHandle.Apply(IEventContext context, MultiDictionaryDelta +/// An immutable row, with its hash precomputed. +/// Inserting values into indexes on the main thread requires a lot of hashing, and for large rows, +/// this takes a lot of time. +/// Pre-computing the hash saves main thread time. +/// It costs time on the preprocessing thread, but hopefully that thread is less loaded. +/// Also, once we parallelize message pre-processing, we can split this work over a thread pool. +/// +/// You MUST create objects of this type with the single-argument constructor. +/// Default-initializing an object of this type breaks its invariant, which is that Hash is the hash of Row. +/// +internal struct PreHashedRow +{ + /// + /// The row itself. + /// Mutating this value breaks the invariant of this type. + /// Mutations should be impossible in our workflow, but you never know. + /// + public readonly IStructuralReadWrite Row; + + /// + /// The hash of the row. + /// + readonly int Hash; + + public PreHashedRow(IStructuralReadWrite Row) + { + this.Row = Row; + Hash = Row.GetHashCode(); + } + + public override int GetHashCode() + { + return Hash; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(PreHashedRow other) + // compare hashes too: speeds up if not equal, not expensive if they are equal. + => Hash == other.Hash && Row.Equals(other.Row); + + public override bool Equals(object? other) + { + if (other == null) + { + return false; // it is impossible for Row to be null + } + var other_ = other as PreHashedRow?; + if (other_ == null) + { + return false; + } + return Equals(other_.Value); + } + + public override string ToString() + => Row.ToString(); +} + +internal class PreHashedRowComparer : IEqualityComparer +{ + public static PreHashedRowComparer Default = new(); + + public bool Equals(PreHashedRow x, PreHashedRow y) + { + return x.Equals(y); + } + + public int GetHashCode(PreHashedRow obj) + { + return obj.GetHashCode(); + } +} + +#nullable disable diff --git a/sdks/csharp/tests~/MultiDictionaryTests.cs b/sdks/csharp/tests~/MultiDictionaryTests.cs index 5abba65af46..50a681fc85e 100644 --- a/sdks/csharp/tests~/MultiDictionaryTests.cs +++ b/sdks/csharp/tests~/MultiDictionaryTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using CsCheck; using SpacetimeDB; +using SpacetimeDB.Types; using Xunit; public class MultiDictionaryTests @@ -404,4 +405,14 @@ public void InsertThenDeleteOfOldRow() Assert.True(wasMaybeUpdated.Contains((1, 2, 3)), $"{dict}: {wasMaybeUpdated}"); #pragma warning restore xUnit2017 } + + [Fact] + public void PreHashedRowEqualsWorks() + { + var row = new PreHashedRow( + new User(Identity.From(Convert.FromBase64String("l0qzG1GPRtC1mwr+54q98tv0325gozLc6cNzq4vrzqY=")), null, true)); + Assert.Equal( + row, row + ); + } } \ No newline at end of file diff --git a/sdks/csharp/tests~/SmallHashSetTests.cs b/sdks/csharp/tests~/SmallHashSetTests.cs new file mode 100644 index 00000000000..7f918035752 --- /dev/null +++ b/sdks/csharp/tests~/SmallHashSetTests.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using CsCheck; + +public partial class SmallHashSetTests +{ + Gen> GenOperationList = Gen.Int[0, 32].SelectMany(count => + Gen.Select(Gen.Int[0, 3].List[count], Gen.Bool.List[count], (values, removes) => values.Zip(removes).ToList()) + ); + + class IntEqualityComparer : IEqualityComparer + { + public bool Equals(int x, int y) + => x == y; + + public int GetHashCode([DisallowNull] int obj) + => obj.GetHashCode(); + } + + [Fact] + public void SmallHashSetIsLikeHashSet() + { + GenOperationList.Sample(ops => + { + HashSet ints = new(new IntEqualityComparer()); + SmallHashSet smallInts = new(); + foreach (var it in ops) + { + var (value, remove) = it; + if (remove) + { + ints.Remove(value); + smallInts.Remove(value); + } + else + { + ints.Add(value); + smallInts.Add(value); + } + Debug.Assert(ints.SetEquals(smallInts), $"{CollectionToString(ints)} != {CollectionToString(smallInts)}"); + } + + }, iter: 10_000); + + } + + [SpacetimeDB.Type] + partial class IntHolder + { + public int Int; + } + + [Fact] + public void SmallHashSet_PreHashedRow_IsLikeHashSet() + { + GenOperationList.Sample(ops => + { + HashSet ints = new(new PreHashedRowComparer()); + SmallHashSetOfPreHashedRow smallInts = new(); + foreach (var it in ops) + { + var (valueWrapped, remove) = it; + var value = new PreHashedRow(new IntHolder { Int = valueWrapped }); + + if (remove) + { + ints.Remove(value); + smallInts.Remove(value); + } + else + { + ints.Add(value); + smallInts.Add(value); + } + Debug.Assert(ints.SetEquals(smallInts), $"{CollectionToString(ints)} != {CollectionToString(smallInts)}"); + } + + }, iter: 10_000); + + } + + string CollectionToString(IEnumerable collection) + { + StringBuilder result = new(); + result.Append("{"); + bool first = true; + foreach (var item in collection) + { + if (!first) + { + result.Append($", "); + } + first = false; + result.Append(item); + } + result.Append("}"); + return result.ToString(); + } + +} \ No newline at end of file diff --git a/sdks/csharp/tests~/tests.csproj b/sdks/csharp/tests~/tests.csproj index 30b5ba54f57..00cc3b7faf5 100644 --- a/sdks/csharp/tests~/tests.csproj +++ b/sdks/csharp/tests~/tests.csproj @@ -8,6 +8,7 @@ false true + true