From ce725102a25d6a55e866340505081a3ab0de3729 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 13:02:53 -0400 Subject: [PATCH 1/7] Pre-hash rows on the message pre-processing thread --- src/SpacetimeDBClient.cs | 10 ++-- src/Table.cs | 100 ++++++++++++++++++++++++++------- tests~/MultiDictionaryTests.cs | 11 ++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/SpacetimeDBClient.cs b/src/SpacetimeDBClient.cs index 3f83e696..9aca4316 100644 --- a/src/SpacetimeDBClient.cs +++ b/src/SpacetimeDBClient.cs @@ -215,7 +215,7 @@ struct ProcessedDatabaseUpdate // 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 ProcessedDatabaseUpdate New() @@ -225,11 +225,11 @@ public static ProcessedDatabaseUpdate 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, EqualityComparer.Default); Updates[table] = delta; } @@ -266,13 +266,13 @@ struct ProcessedMessage /// /// /// - static IStructuralReadWrite Decode(IRemoteTableHandle table, BinaryReader reader, out object primaryKey) + 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/src/Table.cs b/src/Table.cs index 1394f10d..e0d76491 100644 --- a/src/Table.cs +++ b/src/Table.cs @@ -27,14 +27,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. @@ -42,7 +42,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. @@ -148,7 +148,7 @@ public RemoteTableHandle(IDbConnection conn) : base(conn) { } // - 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, EqualityComparer.Default); private static IReadWrite? _serializer; @@ -174,7 +174,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); public event RowEventHandler? OnInsert; @@ -186,7 +186,7 @@ private static IReadWrite Serializer 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}"); @@ -239,21 +239,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 { @@ -274,7 +274,7 @@ 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(); +} + #nullable disable diff --git a/tests~/MultiDictionaryTests.cs b/tests~/MultiDictionaryTests.cs index 5abba65a..50a681fc 100644 --- a/tests~/MultiDictionaryTests.cs +++ b/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 From 40796c675eed538a4c08e2d9b0bd768e7c7b3c26 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 13:52:09 -0400 Subject: [PATCH 2/7] Implement raison d'etre of this PR, not re-hashing when inserting into btree indexes --- src/Table.cs | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/Table.cs b/src/Table.cs index e0d76491..dc59ea08 100644 --- a/src/Table.cs +++ b/src/Table.cs @@ -75,41 +75,49 @@ 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)) { rows = new(); cache.Add(key, rows); } - rows.Add(row); + rows.Add(preHashed); }; - 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); @@ -118,7 +126,7 @@ public BTreeIndexBase(RemoteTableHandle table) } 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; } @@ -130,12 +138,14 @@ 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 event Action? OnInternalInsert; - private event Action? OnInternalDelete; + private event Action? OnInternalInsert; + private event Action? OnInternalDelete; // These are implementations of the type-erased interface. object? IRemoteTableHandle.GetPrimaryKey(IStructuralReadWrite row) => GetPrimaryKey((Row)row); @@ -276,7 +286,7 @@ void IRemoteTableHandle.Apply(IEventContext context, MultiDictionaryDelta Date: Fri, 16 May 2025 14:03:17 -0400 Subject: [PATCH 3/7] Less allocations? --- src/SpacetimeDBClient.cs | 2 +- src/Table.cs | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/SpacetimeDBClient.cs b/src/SpacetimeDBClient.cs index 9aca4316..b0303b24 100644 --- a/src/SpacetimeDBClient.cs +++ b/src/SpacetimeDBClient.cs @@ -229,7 +229,7 @@ public MultiDictionaryDelta DeltaForTable(IRemoteTableHand { if (!Updates.TryGetValue(table, out var delta)) { - delta = new MultiDictionaryDelta(EqualityComparer.Default, EqualityComparer.Default); + delta = new MultiDictionaryDelta(EqualityComparer.Default, PreHashedRowComparer.Default); Updates[table] = delta; } diff --git a/src/Table.cs b/src/Table.cs index dc59ea08..2bb0da49 100644 --- a/src/Table.cs +++ b/src/Table.cs @@ -105,7 +105,7 @@ public BTreeIndexBase(RemoteTableHandle table) var key = GetKey(row); if (!cache.TryGetValue(key, out var rows)) { - rows = new(); + rows = new(PreHashedRowComparer.Default); cache.Add(key, rows); } rows.Add(preHashed); @@ -129,6 +129,7 @@ public IEnumerable Filter(Column value) => cache.TryGetValue(value, out var rows) ? rows.Select(preHashed => (Row)preHashed.Row) : Enumerable.Empty(); } + protected abstract string RemoteTableName { get; } string IRemoteTableHandle.RemoteTableName => RemoteTableName; @@ -158,7 +159,7 @@ public RemoteTableHandle(IDbConnection conn) : base(conn) { } // - 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; @@ -405,4 +406,19 @@ 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 From 39f53afd5c8a9d87298b39d307e0a9f278a086b9 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 14:29:42 -0400 Subject: [PATCH 4/7] Allocate fewer hash sets --- src/SmallHashSet.cs | 112 +++++++++++++++++++++++++++++++++++++++ src/SmallHashSet.cs.meta | 7 +++ src/Table.cs | 4 +- 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/SmallHashSet.cs create mode 100644 src/SmallHashSet.cs.meta diff --git a/src/SmallHashSet.cs b/src/SmallHashSet.cs new file mode 100644 index 00000000..9c2a159e --- /dev/null +++ b/src/SmallHashSet.cs @@ -0,0 +1,112 @@ +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +#nullable enable + +/// +/// A hashset optimized to store small numbers of values of type T. +/// Used because many of the hash sets in our BTree indexes +/// +/// +internal struct SmallHashSet : IEnumerable +where T : struct +where EQ : IEqualityComparer, new() +{ + static EQ DefaultEqualityComparer = new(); + + // 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 = new(2, DefaultEqualityComparer) + { + newValue, + Value.Value + }; + Value = null; + } + } + else + { + Values.Add(newValue); + } + } + + public void Remove(T remValue) + { + if (Value != null && DefaultEqualityComparer.Equals(Value.Value, remValue)) + { + Value = null; + } + if (Values != null && Values.Contains(remValue)) + { + Values.Remove(remValue); + // Do not try to go back to single-row state. + // We might as well keep the allocation around if this set has needed to store multiple values before. + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Contains(T value) + { + if (Values != null) + { + return Values.Contains(value); + } + 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) + { + yield return Value.Value; + } + else if (Values != null) + { + foreach (var value in Values) + { + yield return value; + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} +#nullable disable \ No newline at end of file diff --git a/src/SmallHashSet.cs.meta b/src/SmallHashSet.cs.meta new file mode 100644 index 00000000..6cb2204a --- /dev/null +++ b/src/SmallHashSet.cs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 38df61787d652154bb082d3ddbb49869 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Table.cs b/src/Table.cs index 2bb0da49..741b94f1 100644 --- a/src/Table.cs +++ b/src/Table.cs @@ -94,7 +94,7 @@ 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) { @@ -105,7 +105,7 @@ public BTreeIndexBase(RemoteTableHandle table) var key = GetKey(row); if (!cache.TryGetValue(key, out var rows)) { - rows = new(PreHashedRowComparer.Default); + rows = new(); cache.Add(key, rows); } rows.Add(preHashed); From b32c9ea67fbb47e162f0ab2c74706c4e35377de7 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 14:41:46 -0400 Subject: [PATCH 5/7] Add test for SmallHashSet --- tests~/SmallHashSetTests.cs | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests~/SmallHashSetTests.cs diff --git a/tests~/SmallHashSetTests.cs b/tests~/SmallHashSetTests.cs new file mode 100644 index 00000000..71687bb2 --- /dev/null +++ b/tests~/SmallHashSetTests.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using CsCheck; + +public 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(); + 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)); + } + + }, iter: 10_000); + + } + +} \ No newline at end of file From 1bad0b606a3f56757a2ea369b5eda90a75d37158 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 15:09:34 -0400 Subject: [PATCH 6/7] Save an allocation when calling Filter --- src/SmallHashSet.cs | 92 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/src/SmallHashSet.cs b/src/SmallHashSet.cs index 9c2a159e..bf9aca81 100644 --- a/src/SmallHashSet.cs +++ b/src/SmallHashSet.cs @@ -1,12 +1,13 @@ using System.Collections; using System.Collections.Generic; +using System.Linq.Expressions; using System.Runtime.CompilerServices; #nullable enable /// /// A hashset optimized to store small numbers of values of type T. -/// Used because many of the hash sets in our BTree indexes +/// Used because many of the hash sets in our BTreeIndexes store only one value. /// /// internal struct SmallHashSet : IEnumerable @@ -93,14 +94,15 @@ public IEnumerator GetEnumerator() { if (Value != null) { - yield return Value.Value; + return new SingleElementEnumerator(Value.Value); } else if (Values != null) { - foreach (var value in Values) - { - yield return value; - } + return Values.GetEnumerator(); + } + else + { + return new NoElementEnumerator(); } } @@ -109,4 +111,82 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } } + +/// +/// 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() + { + } +} + #nullable disable \ No newline at end of file From 2410e123db1757f847bf0427bf9fbb169b7a8429 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Fri, 16 May 2025 15:28:17 -0400 Subject: [PATCH 7/7] Fix regression tests & make them more aggressive --- examples~/regression-tests/client/Program.cs | 31 +++++++++++++++++++- src/Table.cs | 21 +++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/examples~/regression-tests/client/Program.cs b/examples~/regression-tests/client/Program.cs index 5e79220c..67118524 100644 --- a/examples~/regression-tests/client/Program.cs +++ b/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/src/Table.cs b/src/Table.cs index 741b94f1..1b58b6d4 100644 --- a/src/Table.cs +++ b/src/Table.cs @@ -103,12 +103,21 @@ public BTreeIndexBase(RemoteTableHandle table) // 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(preHashed); }; table.OnInternalDelete += preHashed => @@ -122,6 +131,12 @@ public BTreeIndexBase(RemoteTableHandle table) { 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; + } }; }